@signalridge/pi-workflows 1.11.1 → 1.12.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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.12.0
4
+ ### Minor Changes
5
+
6
+ - 3096585: Give Workflows one rich below-editor overview and keep Subagents Fleet limited to independent agents. Preserve workflow navigation, completed-call token semantics, periodic projection/footer updates, and exact retained-child access through the owner navigator and explicit `/agents` retained-child paths, including when Fleet is disabled. No execution/model policy or managed/UI protocol changes.
7
+
8
+ Update both packages together for the new layout: older Subagents can still duplicate the new overview; newer Subagents paired with older Workflows leaves the older upper progress panel. Existing running sessions do not acquire these UI changes until a later normal activation.
9
+
10
+ ### Patch Changes
11
+
12
+ - 3096585: Do not charge managed-request preflight rejections as submissions, and finalize schema-invalid logical calls with their exact settled attempt metadata. Count all owner-side workflows in the passive overview without expanding the bounded peer snapshot.
13
+ - 3096585: Validate complete managed requests before allocating dispatch leases, preserving local validation diagnostics and completed resume prefixes. Apply structured-output constraints conjunctively and extract balanced JSON from large malformed replies in linear time without changing bounded repair behavior.
14
+ - 3096585: Validate persisted transcript pointers with the managed protocol contract and settle logical calls when a token budget prevents a repair attempt, preserving the completed child's usage and ownership on restore.
15
+ - Updated dependencies [3096585]
16
+ - Updated dependencies [3096585]
17
+ - @signalridge/pi-ui@1.5.0
18
+
3
19
  ## 1.11.1
4
20
  ### Patch Changes
5
21
 
package/README.md CHANGED
@@ -265,7 +265,13 @@ Phase entry, task attempts and call attribution come from fenced runtime observa
265
265
 
266
266
  A selected root agent call can open its retained conversation through the subagent owner's optional exact-attempt UI endpoint. Unknown or reused old attempts are not redirected to a newer child. Workflow controls remain here; steering, child stop, sessions, model policy and foreground handoff remain in `pi-subagents`. The optional UI protocol does not change the required managed v4 handshake.
267
267
 
268
- The passive progress panel uses a terminal-height row budget, prioritizes live calls and reports overflow rather than growing as runs × calls. Names/results are sanitized only in display copies; journals and stored source remain unchanged. Fullscreen, regular terminal, RPC and print paths retain their distinct public API boundaries.
268
+ Workflows owns one rich passive overview **below the editor**; there is no second upper workflow list. Rows put the workflow name first, followed by semantic status, the observed current phase, running/total call counts, done/failed counts, elapsed time and available **settled-call tokens**. Tokens are completed call-result totals, not live in-flight usage; unknown is omitted and an explicit zero stays zero. Paused, stopping and childless runs remain visible; recent terminal runs linger for four seconds, while all retained history stays in `/workflows`.
269
+
270
+ `/workflows-progress compact|detailed|status|max <N>` retains its settings: `status` reports them, `detailed` adds bounded call rows, and `max` caps shown runs/calls. The panel uses a terminal-height row budget, prioritizes live calls and reports overflow rather than growing as runs × calls. Names/results are sanitized only in display copies; journals and stored source remain unchanged. The panel takes no empty-prompt arrow keys: `/workflows` is the authoritative navigator. Closing or disposing presentation never stops execution. Fullscreen, regular terminal, RPC and print paths retain their distinct public API boundaries.
271
+
272
+ Subagents Fleet lists independent agents only. For retained children, use a selected root call in `/workflows`, or `/agents → Retained workflow children` to open the owner inspector or directly inspect the bounded child list. The latter also works when the workflow UI peer is absent or Fleet is disabled. `/agents → Agent runs` retains individual sessions and child steering/stop controls; eligible independent foreground agents keep handoff. Workflow controls and model/execution policy do not move across owners. The optional validated UI query/change/open bridge remains available to external observers and is not a usage RPC.
273
+
274
+ **Mixed-version UI caveat:** update both packages together for this ownership layout. An older Subagents may still render its own workflow overview alongside the new panel. A newer Subagents with older Workflows hides owned Fleet rows but leaves the older upper progress list in place. Managed protocol v4 and exact-attempt conversation admission are unchanged; no untyped event guessing or automatic legacy-peer suppression is used.
269
275
 
270
276
  ### Background result notifications
271
277
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-workflows",
3
- "version": "1.11.1",
3
+ "version": "1.12.0",
4
4
  "description": "Deterministic JavaScript orchestration workflows backed by pi-subagents managed spawning.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@signalridge/pi-subagents-protocol": "^1.6.0",
26
- "@signalridge/pi-ui": "^1.4.1",
26
+ "@signalridge/pi-ui": "^1.5.0",
27
27
  "acorn": "^8.16.0"
28
28
  },
29
29
  "pi": {
@@ -2,7 +2,7 @@
2
2
  name: workflow-authoring
3
3
  description: Design, write, review, and debug Pi JavaScript workflows that coordinate multiple subagents. Use when a request needs named dependencies, bounded fan-out, per-item pipelines, quality gates, saved or nested workflows, pause/resume, or workflow progress; do not use it merely to run an existing named workflow.
4
4
  metadata:
5
- version: "1.11.1"
5
+ version: "1.12.0"
6
6
  ---
7
7
 
8
8
  # Workflow authoring
@@ -2,7 +2,7 @@
2
2
  # Workflow capability index
3
3
 
4
4
  Contract format: `2.0.0`<br>
5
- Contract content / skill / extension: `1.11.1`
5
+ Contract content / skill / extension: `1.12.0`
6
6
 
7
7
  This compact generated index covers supported runtime globals and workflow-tool inputs. For constraints, compatibility behavior, internal boundaries, and dynamic-reference ownership, follow the [exhaustive generated facts](capability-details.md).
8
8
 
@@ -2,7 +2,7 @@
2
2
  # Exhaustive workflow capability facts
3
3
 
4
4
  Contract format: `2.0.0`<br>
5
- Contract content / skill / extension: `1.11.1`
5
+ Contract content / skill / extension: `1.12.0`
6
6
 
7
7
  Every exact fact below is projected from the installed extension's capability contract. Explanatory judgment belongs in the hand-written references next to this file.
8
8
 
@@ -21,7 +21,9 @@ Call `agent(prompt, { label, schema?, strength? })`; it returns text, a schema-v
21
21
 
22
22
  Use `agent(prompt, { thread: "implementer" })` when the same managed subagent must receive a later follow-up with its complete conversation intact. Reuse a thread name sequentially; never put same-thread calls in one `parallel()` batch. Threads are scoped to one workflow run/session and cannot use worktree isolation. A pause or process restart may require a fresh thread session.
23
23
 
24
- When JavaScript reads fields, pass a small plain JSON Schema. Schema noncompliance after repair throws and bypasses agent retries. Catch it only to return an explicit incomplete outcome without reading missing fields. Return objects, arrays, strings, numbers, booleans, and `null`—not functions, promises, cycles, `BigInt`, or runtime handles.
24
+ Managed requests are validated locally before dispatch. The complete effective prompt must be nonblank and at most 100,000 characters after trimming, including schema instructions and any repair feedback. Leave room for both when sizing a prompt; aggregate compact summaries or bounded excerpts instead of concatenating full worker outputs. An oversized request throws a validation error without allocating a child or invalidating the completed journal prefix. A submitted request with a lost/ambiguous reply still requires positive exact-owner cleanup; a missing reconciliation record is not proof of cleanup.
25
+
26
+ When JavaScript reads fields, pass a small plain JSON Schema. Supported constraints are conjunctive: `const` and `enum` do not bypass type, required/properties/items, bounds, or `additionalProperties: false`. Unsupported keywords are rejected, not silently ignored. JSON extraction accepts prose/fences and balanced nested values, handles escaped strings, and can recover a later balanced candidate after a malformed prefix in linear time; balanced but invalid JSON still enters bounded repair. Schema noncompliance after repair throws and bypasses agent retries. Catch it only to return an explicit incomplete outcome without reading missing fields. Return objects, arrays, strings, numbers, booleans, and `null`—not functions, promises, cycles, `BigInt`, or runtime handles.
25
27
 
26
28
  ## Routing and support
27
29
 
@@ -2,7 +2,7 @@
2
2
  name: workflow-patterns
3
3
  description: Choose and run one of Pi's built-in multi-agent workflows for research, adversarial review, code review, multi-perspective analysis, or codebase auditing. Use when the request matches a named pattern; use workflow-authoring instead for a new script or custom topology.
4
4
  metadata:
5
- version: "1.11.1"
5
+ version: "1.12.0"
6
6
  ---
7
7
 
8
8
  # Built-in workflow patterns
@@ -2,7 +2,7 @@
2
2
  name: workflow-review
3
3
  description: Audit an existing Pi JavaScript workflow for topology, prompt/data flow, limits, failure handling, cancellation, resume safety, and publication compatibility. Use when reviewing or debugging a workflow script or saved run; do not use to run a standard named workflow.
4
4
  metadata:
5
- version: "1.11.1"
5
+ version: "1.12.0"
6
6
  ---
7
7
 
8
8
  # Workflow review
package/src/engine.ts CHANGED
@@ -20,7 +20,13 @@
20
20
  import { createHash, randomUUID } from "node:crypto";
21
21
  import type { ManagedSpawnResponse } from "@signalridge/pi-subagents-protocol";
22
22
  import { isManagedAgentTier } from "@signalridge/pi-subagents-protocol";
23
- import { describeWorkflowError, WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
23
+ import {
24
+ AllocationFreeDispatchError,
25
+ describeWorkflowError,
26
+ WorkflowError,
27
+ WorkflowErrorCode,
28
+ wrapError,
29
+ } from "./errors.js";
24
30
  import { type WorkflowInspection, type WorkflowInspectionHeader, WorkflowPresentation } from "./inspection.js";
25
31
  import {
26
32
  applyRecoveryEvent,
@@ -46,6 +52,7 @@ import {
46
52
  type DispatchTask,
47
53
  type ManagedProtocolCheck,
48
54
  type ManagedSpawnClient,
55
+ prepareManagedSpawnRequest,
49
56
  type WorkflowEventBus,
50
57
  } from "./rpc-client.js";
51
58
  import {
@@ -179,6 +186,8 @@ interface ManagedCallFacts {
179
186
  executionGeneration: number;
180
187
  attemptId: string;
181
188
  agentId: string;
189
+ /** Exact-owner terminal admission, not merely a queued/running identity. */
190
+ settled?: boolean;
182
191
  tokenCount?: number;
183
192
  outputFile?: string;
184
193
  compactionCount: number;
@@ -788,6 +797,7 @@ export class WorkflowEngine {
788
797
  facts.agentId !== agentId
789
798
  )
790
799
  return;
800
+ facts.settled = true;
791
801
  facts.tokenCount = terminal.tokenCount;
792
802
  facts.outputFile = terminal.outputFile;
793
803
  facts.compactionCount = Math.max(facts.compactionCount, terminal.compactionCount);
@@ -1564,6 +1574,12 @@ export class WorkflowEngine {
1564
1574
  onAgentEnd: (event) => {
1565
1575
  if (!isPresentationCurrent()) return;
1566
1576
  const nodeId = event.id.startsWith(`${runId}:`) ? event.id.slice(runId.length + 1) : undefined;
1577
+ // A completed child is not a successful logical call until runtime
1578
+ // validation accepts it. Exhausted schema/repair failures must settle
1579
+ // the current journal attempt too, not just its volatile presentation.
1580
+ if (nodeId !== undefined && event.error !== undefined)
1581
+ this.finalizeLogicalCallFailure(state, nodeId, event.error);
1582
+ if (!isPresentationCurrent()) return;
1567
1583
  const persisted = nodeId === undefined ? undefined : state.run.callResults[nodeId];
1568
1584
  const persistedTokens =
1569
1585
  nodeId !== undefined && persisted?.attemptId === state.run.attemptIds[nodeId]
@@ -1712,7 +1728,6 @@ export class WorkflowEngine {
1712
1728
  const spawnNodeId = `call-${nodeId}`;
1713
1729
  const base = state.generations.get(nodeId) ?? 0;
1714
1730
  const generation = base + (runOptions.attempt ?? 1);
1715
- state.generations.set(nodeId, generation);
1716
1731
  const spawnAttemptId = `attempt-${generation}`;
1717
1732
  const attemptId = `${runId}/${nodeId}/attempt-${generation}`;
1718
1733
 
@@ -1725,13 +1740,6 @@ export class WorkflowEngine {
1725
1740
  { recoverable: false },
1726
1741
  );
1727
1742
  }
1728
- // This dispatch is authoritative for the current script revision. Do not
1729
- // carry an identity from the previous script: deleting `tier` must clear
1730
- // the recorded one instead of silently preserving the old route.
1731
- const tierIdentity: CallTierIdentity = tier === undefined ? {} : { tier };
1732
- state.callTiers.set(nodeId, tierIdentity);
1733
- run.callTiers[nodeId] = tierIdentity;
1734
- state.managedCallFacts.delete(nodeId);
1735
1743
  const task: DispatchTask = {
1736
1744
  subagent_type: type,
1737
1745
  prompt,
@@ -1743,6 +1751,25 @@ export class WorkflowEngine {
1743
1751
  ...(runOptions.isolation === undefined ? {} : { isolation: runOptions.isolation }),
1744
1752
  };
1745
1753
 
1754
+ // Validate the complete effective wire request (schema/repair text included)
1755
+ // before any attempt fact or submitted lease. A deterministic caller error
1756
+ // owns no remote allocation; cleanup must not mask its original diagnostic.
1757
+ try {
1758
+ prepareManagedSpawnRequest(task, runId, spawnNodeId, spawnAttemptId);
1759
+ } catch (error: unknown) {
1760
+ // This proof is minted only on the allocation-free side of dispatch;
1761
+ // errors from client.spawn below remain conservatively chargeable.
1762
+ throw new AllocationFreeDispatchError(error);
1763
+ }
1764
+ state.generations.set(nodeId, generation);
1765
+ // This dispatch is authoritative for the current script revision. Do not
1766
+ // carry an identity from the previous script: deleting `tier` must clear
1767
+ // the recorded one instead of silently preserving the old route.
1768
+ const tierIdentity: CallTierIdentity = tier === undefined ? {} : { tier };
1769
+ state.callTiers.set(nodeId, tierIdentity);
1770
+ run.callTiers[nodeId] = tierIdentity;
1771
+ state.managedCallFacts.delete(nodeId);
1772
+
1746
1773
  // Journal the attempt rotation only for top-level calls. A nested frame is
1747
1774
  // represented by its parent workflow_result replay boundary instead of
1748
1775
  // leaking child call facts into the parent's numeric call namespace.
@@ -2235,6 +2262,33 @@ export class WorkflowEngine {
2235
2262
  state.run.updatedAt = Date.now();
2236
2263
  }
2237
2264
 
2265
+ private finalizeLogicalCallFailure(state: ScriptRunState, nodeId: string, error: string): void {
2266
+ const run = state.run;
2267
+ const facts = state.managedCallFacts.get(nodeId);
2268
+ // No guesses about unknown remote admissions, stale attempts, nested calls,
2269
+ // or failures already committed by dispatch's lifecycle terminal path.
2270
+ if (
2271
+ run.callStatus[nodeId] !== "running" ||
2272
+ !facts?.settled ||
2273
+ facts.executionGeneration !== state.executionGeneration ||
2274
+ facts.attemptId !== run.attemptIds[nodeId]
2275
+ )
2276
+ return;
2277
+ const result = resultFromLifecycle(
2278
+ "failed",
2279
+ facts.agentId,
2280
+ undefined,
2281
+ error,
2282
+ facts.compactionCount,
2283
+ facts.tokenCount,
2284
+ state.callTiers.get(nodeId)?.tier,
2285
+ );
2286
+ result.attemptId = facts.attemptId;
2287
+ if (facts.outputFile !== undefined) result.outputFile = facts.outputFile;
2288
+ // No call hash/store delta: invalid output can never enter the replay cache.
2289
+ this.commitCallTerminal(run.runId, nodeId, result);
2290
+ }
2291
+
2238
2292
  private commitCallTerminal(runId: string, nodeId: string, result: CallResult, required = false): void {
2239
2293
  const state = this.runs.get(runId);
2240
2294
  if (!state) return;
package/src/errors.ts CHANGED
@@ -67,6 +67,20 @@ export class WorkflowError extends Error {
67
67
  }
68
68
  }
69
69
 
70
+ /**
71
+ * Host-only proof that effective managed-request validation rejected before any
72
+ * attempt rotation or remote allocation. Never infer this from a code/message,
73
+ * or attach it to an error received after submitting a spawn.
74
+ */
75
+ export class AllocationFreeDispatchError extends WorkflowError {
76
+ constructor(cause: unknown) {
77
+ super(cause instanceof Error ? cause.message : String(cause), WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
78
+ recoverable: false,
79
+ details: cause,
80
+ });
81
+ }
82
+ }
83
+
70
84
  // Transported VM throws must not acquire provider/abort classification merely
71
85
  // because IPC reconstructed them in the host Error realm. Keep provenance and
72
86
  // helper-computed text outside user data (including non-Error object throws).
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { defineTool } from "@earendil-works/pi-coding-agent";
5
- import { Text, truncateToWidth } from "@earendil-works/pi-tui";
5
+ import { Text } from "@earendil-works/pi-tui";
6
6
  import { sanitizeDisplayText } from "@signalridge/pi-ui";
7
7
  import { Type } from "@sinclair/typebox";
8
8
  import { DEFAULT_TRIGGER_WORD, hasTriggerWord, WORKFLOW_ARMING_MESSAGE_TYPE, WorkflowTurnArming } from "./arming.js";
@@ -26,8 +26,9 @@ import {
26
26
  WORKFLOW_STRENGTHS,
27
27
  type WorkflowStrengthTable,
28
28
  } from "./strengths.js";
29
- import { liveWidgetLines, showWorkflowNavigator } from "./ui.js";
29
+ import { showWorkflowNavigator } from "./ui.js";
30
30
  import { buildWorkflowUiSnapshot, openOwnedConversation, WorkflowUiBridge, type WorkflowUiSurface } from "./ui-bus.js";
31
+ import { overviewRuns, workflowOverviewLines } from "./ui-overview.js";
31
32
  import {
32
33
  loadProjectWorkflowSettings,
33
34
  loadWorkflowSettings,
@@ -509,11 +510,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
509
510
  .map((summary) => workflowEngine.getRun(String(summary.runId)))
510
511
  .filter((run): run is NonNullable<typeof run> => run !== undefined);
511
512
  if (statusSource?.()) tui.setStatus("pi-workflows", workflowStatusSummary(runs));
512
- const hasActive = runs.some((run) => !["completed", "failed", "stopped"].includes(run.status));
513
+ const hasProgress = overviewRuns(runs).length > 0;
513
514
  const surfaceId = workflowUiSurfaceId;
515
+ const snapshot = buildWorkflowUiSnapshot(workflowEngine, surfaceId);
514
516
  tui.setWidget(
515
517
  "pi-workflows",
516
- hasActive
518
+ hasProgress
517
519
  ? (host, theme) => ({
518
520
  render(width) {
519
521
  if (
@@ -521,29 +523,33 @@ export default function piWorkflows(pi: ExtensionAPI): void {
521
523
  engine !== workflowEngine ||
522
524
  workflowEngine.isDisposed() ||
523
525
  workflowUiSurfaceId !== surfaceId ||
526
+ !ownsDeliveryBranch() ||
524
527
  !Number.isFinite(width) ||
525
528
  width < 1
526
529
  )
527
530
  return [];
528
531
  const height = Number.isFinite(host.terminal.rows) ? Math.max(0, host.terminal.rows) : 24;
529
- const rowBudget = Math.max(1, Math.min(12, Math.floor(height / 4)));
530
- return liveWidgetLines(
532
+ const rowBudget = Math.max(0, Math.min(12, Math.floor(height / 4)));
533
+ return workflowOverviewLines(
531
534
  runs,
535
+ snapshot.runs,
536
+ theme,
537
+ width,
538
+ rowBudget,
532
539
  workflowSettings.progressMode,
533
540
  workflowSettings.maxAgentsShown,
534
- rowBudget,
535
- ).map((line, index) =>
536
- truncateToWidth(theme.fg(index === 0 ? "accent" : "muted", line), Math.floor(width)),
537
541
  );
538
542
  },
539
543
  invalidate() {},
540
544
  })
541
545
  : undefined,
546
+ { placement: "belowEditor" },
542
547
  );
548
+ // Publication/footer cadence is independent of passive row visibility.
543
549
  workflowUiBridge?.publish();
544
- if (hasActive && !widgetTimer) {
550
+ if (hasProgress && !widgetTimer) {
545
551
  widgetTimer = setInterval(refreshWidget, 2_000);
546
- } else if (!hasActive && widgetTimer) {
552
+ } else if (!hasProgress && widgetTimer) {
547
553
  clearInterval(widgetTimer);
548
554
  widgetTimer = undefined;
549
555
  }
@@ -765,9 +771,6 @@ export default function piWorkflows(pi: ExtensionAPI): void {
765
771
  // re-run every finished call untabled.
766
772
  workflowEngine.strengths = () => workflowSettings.strengths;
767
773
  workflowEngine.loadSavedWorkflow = resolveNestedWorkflow;
768
- // Command/tool registrations persist across session replacement, but the
769
- // widget belongs to the newly restored engine and must be refreshed too.
770
- refreshWidget();
771
774
  if (surfaceRegistered) return;
772
775
  surfaceRegistered = true;
773
776
 
@@ -2008,7 +2011,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
2008
2011
  return;
2009
2012
  }
2010
2013
  engine = recoveredEngine;
2011
- widgetUi = ctx.mode === "tui" ? ctx.ui : undefined;
2014
+ widgetUi = ctx.mode === "tui" && ctx.hasUI ? ctx.ui : undefined;
2012
2015
  registerSurface();
2013
2016
 
2014
2017
  // Public API methods target the current session: generation + branch
@@ -2017,7 +2020,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
2017
2020
  deliveryBranchAnchor = journalBranchAnchor;
2018
2021
  registerStatusActions();
2019
2022
  workflowUiBridge ??= new WorkflowUiBridge(pi.events, workflowUiSurface);
2020
- workflowUiBridge.publish();
2023
+ // Bind the presentation only after its session/branch source is installed.
2024
+ refreshWidget();
2025
+ if (!widgetUi) workflowUiBridge.publish();
2021
2026
  inputPreparing = false;
2022
2027
  compacting = false;
2023
2028
  navigating = false;
package/src/journal.ts CHANGED
@@ -24,7 +24,7 @@
24
24
 
25
25
  import { createHash } from "node:crypto";
26
26
  import type { ManagedOwner } from "@signalridge/pi-subagents-protocol";
27
- import { isManagedAgentTier } from "@signalridge/pi-subagents-protocol";
27
+ import { isManagedAgentTier, parseManagedTerminalSnapshot } from "@signalridge/pi-subagents-protocol";
28
28
  import {
29
29
  MAX_CALLS_PER_WORKFLOW,
30
30
  normalizeAgentRetries,
@@ -402,6 +402,17 @@ function parseResult(raw: unknown, label: string): CallResult {
402
402
  }
403
403
  const updatedAt = validTimestamp(raw.updatedAt, label);
404
404
  const tierIdentity = parseCallTierIdentity(raw, label);
405
+ // Transcript pointers have their own managed protocol contract (including
406
+ // normalization and the 2000-character bound), not the journal identity cap.
407
+ const outputFile =
408
+ raw.outputFile === undefined
409
+ ? undefined
410
+ : parseManagedTerminalSnapshot({
411
+ status,
412
+ outputFile: raw.outputFile,
413
+ compactionCount,
414
+ completedAt: updatedAt,
415
+ }).outputFile;
405
416
  const result: CallResult = {
406
417
  status,
407
418
  compactionCount,
@@ -413,9 +424,7 @@ function parseResult(raw: unknown, label: string): CallResult {
413
424
  : { attemptId: boundedString(raw.attemptId, `${label}.attemptId`, JOURNAL_ID_LIMIT) }),
414
425
  ...(raw.result === undefined ? {} : { result: raw.result }),
415
426
  ...(raw.error === undefined ? {} : { error: boundedString(raw.error, `${label}.error`, JOURNAL_ERROR_LIMIT) }),
416
- ...(raw.outputFile === undefined
417
- ? {}
418
- : { outputFile: boundedString(raw.outputFile, `${label}.outputFile`, JOURNAL_ID_LIMIT) }),
427
+ ...(outputFile === undefined ? {} : { outputFile }),
419
428
  ...(raw.tokenCount === undefined
420
429
  ? {}
421
430
  : {
package/src/rpc-client.ts CHANGED
@@ -6,10 +6,12 @@ import type {
6
6
  } from "@signalridge/pi-subagents-protocol";
7
7
  import {
8
8
  PROTOCOL_VERSION,
9
+ parseManagedSpawnRequest,
9
10
  parseManagedSpawnResponse,
10
11
  parseProtocolPing,
11
12
  requiredCapabilitiesMatch,
12
13
  } from "@signalridge/pi-subagents-protocol";
14
+ import { WorkflowError, WorkflowErrorCode } from "./errors.js";
13
15
  import type { WorkflowOwner } from "./journal.js";
14
16
 
15
17
  /** Minimal dispatch task for a script-run agent() call. */
@@ -298,6 +300,31 @@ export interface ManagedSpawnClient {
298
300
  checkProtocol?: () => Promise<ManagedProtocolCheck>;
299
301
  }
300
302
 
303
+ /** Shared wire preflight, also used before the engine submits an allocation lease. */
304
+ export function prepareManagedSpawnRequest(task: DispatchTask, runId: string, nodeId: string, attemptId = "attempt-1") {
305
+ try {
306
+ return parseManagedSpawnRequest({
307
+ requestId: requestId(),
308
+ spawnKey: `${runId}/${nodeId}/${attemptId}`,
309
+ type: task.subagent_type,
310
+ prompt: task.prompt,
311
+ description: task.description,
312
+ ...(task.tier === undefined ? {} : { tier: task.tier }),
313
+ ...(task.toolset === undefined ? {} : { toolset: task.toolset }),
314
+ ...(task.thread === undefined ? {} : { thread: task.thread }),
315
+ ...(task.excludeTools === undefined ? {} : { excludeTools: task.excludeTools }),
316
+ ...(task.isolation === undefined ? {} : { isolation: task.isolation }),
317
+ owner: { extension: "pi-workflows", runId, nodeId, attemptId },
318
+ });
319
+ } catch (error: unknown) {
320
+ throw new WorkflowError(
321
+ error instanceof Error ? error.message : String(error),
322
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
323
+ { recoverable: false, details: error },
324
+ );
325
+ }
326
+ }
327
+
301
328
  /** Event-bus-only client for the additive pi-subagents managed protocol. */
302
329
  export function createManagedSpawnClient(
303
330
  events: WorkflowEventBus,
@@ -325,29 +352,15 @@ export function createManagedSpawnClient(
325
352
  * fingerprint still reuses the persisted tombstone (per-call cache).
326
353
  */
327
354
  async spawn(task, runId, nodeId, attemptId, signal) {
328
- const effectiveAttemptId = attemptId ?? `attempt-1`;
329
- const spawnKey = `${runId}/${nodeId}/${effectiveAttemptId}`;
355
+ // Outside the transport catch: a local rejection never emitted a request,
356
+ // so it must not create reconciliation or owned-cleanup uncertainty.
357
+ const request = prepareManagedSpawnRequest(task, runId, nodeId, attemptId);
358
+ const { spawnKey, owner } = request;
330
359
  const requestSignal =
331
360
  signal && sessionSignal ? AbortSignal.any([signal, sessionSignal]) : (signal ?? sessionSignal);
332
361
  let data: unknown;
333
362
  try {
334
- data = await call<unknown>(
335
- "subagents:rpc:spawn-managed",
336
- {
337
- requestId: requestId(),
338
- spawnKey,
339
- type: task.subagent_type,
340
- prompt: task.prompt,
341
- description: task.description,
342
- ...(task.tier === undefined ? {} : { tier: task.tier }),
343
- ...(task.toolset === undefined ? {} : { toolset: task.toolset }),
344
- ...(task.thread === undefined ? {} : { thread: task.thread }),
345
- ...(task.excludeTools === undefined ? {} : { excludeTools: task.excludeTools }),
346
- ...(task.isolation === undefined ? {} : { isolation: task.isolation }),
347
- owner: { extension: "pi-workflows", runId, nodeId, attemptId: effectiveAttemptId },
348
- },
349
- requestSignal,
350
- );
363
+ data = await call<unknown>("subagents:rpc:spawn-managed", { ...request }, requestSignal);
351
364
  } catch (error: unknown) {
352
365
  // Reconciliation must not inherit the signal that caused the original
353
366
  // request to fail: shutdown/abort can happen after allocation but before
@@ -357,7 +370,7 @@ export function createManagedSpawnClient(
357
370
  {
358
371
  requestId: requestId(),
359
372
  spawnKey,
360
- owner: { extension: "pi-workflows", runId, nodeId, attemptId: effectiveAttemptId },
373
+ owner,
361
374
  },
362
375
  undefined,
363
376
  5_000,
package/src/runtime.ts CHANGED
@@ -25,7 +25,7 @@ import type { ManagedRoutingPolicy } from "@signalridge/pi-subagents-protocol";
25
25
  import { agentTierPolicyIdentity, isManagedAgentTier } from "@signalridge/pi-subagents-protocol";
26
26
  import { parse } from "acorn";
27
27
  import { checkRuntimeBindings } from "./capabilities.js";
28
- import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
28
+ import { AllocationFreeDispatchError, WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
29
29
  import {
30
30
  executeOrchestration,
31
31
  normalizeOrchestration,
@@ -1038,31 +1038,34 @@ async function withTimeout<T>(
1038
1038
 
1039
1039
  /** Extract the first balanced JSON value ({...} or [...]) from text. */
1040
1040
  export function findJsonBlock(text: string): string | undefined {
1041
- for (let start = 0; start < text.length; start += 1) {
1042
- if (text[start] !== "{" && text[start] !== "[") continue;
1043
- const stack: string[] = [];
1044
- let inString = false;
1045
- let escaped = false;
1046
- for (let i = start; i < text.length; i += 1) {
1047
- const char = text[i];
1048
- if (inString) {
1049
- if (escaped) escaped = false;
1050
- else if (char === "\\") escaped = true;
1051
- else if (char === '"') inString = false;
1052
- continue;
1053
- }
1054
- if (char === '"') {
1055
- inString = true;
1056
- continue;
1057
- }
1058
- if (char === "{" || char === "[") stack.push(char === "{" ? "}" : "]");
1059
- else if (char === "}" || char === "]") {
1060
- if (stack.pop() !== char) break;
1061
- if (stack.length === 0) return text.slice(start, i + 1);
1062
- }
1063
- }
1041
+ // Suffix summaries avoid rescanning after every malformed opening bracket.
1042
+ // stringEnd[i] is the position after a closing quote when already in a string
1043
+ // at i; contentEnd[i] is the first unconsumed closing bracket after skipping
1044
+ // balanced children/strings, or -1 for an incomplete/mismatched suffix.
1045
+ // Compute candidates even inside strings: an unmatched/malformed outer prefix
1046
+ // must still allow a later, independently balanced candidate, as before.
1047
+ const stringEnd = new Int32Array(text.length + 2);
1048
+ const contentEnd = new Int32Array(text.length + 1);
1049
+ stringEnd[text.length] = stringEnd[text.length + 1] = contentEnd[text.length] = -1;
1050
+ let start = -1;
1051
+ let end = -1;
1052
+ for (let i = text.length - 1; i >= 0; i--) {
1053
+ const char = text[i];
1054
+ stringEnd[i] = char === '"' ? i + 1 : stringEnd[i + (char === "\\" ? 2 : 1)];
1055
+ if (char === "}" || char === "]") contentEnd[i] = i;
1056
+ else if (char === '"') {
1057
+ const after = stringEnd[i + 1];
1058
+ contentEnd[i] = after < 0 ? -1 : contentEnd[after];
1059
+ } else if (char === "{" || char === "[") {
1060
+ const close = contentEnd[i + 1];
1061
+ if (close >= 0 && text[close] === (char === "{" ? "}" : "]")) {
1062
+ start = i;
1063
+ end = close + 1;
1064
+ contentEnd[i] = contentEnd[end];
1065
+ } else contentEnd[i] = -1;
1066
+ } else contentEnd[i] = contentEnd[i + 1];
1064
1067
  }
1065
- return undefined;
1068
+ return start < 0 ? undefined : text.slice(start, end);
1066
1069
  }
1067
1070
 
1068
1071
  // ── Minimal plain-JSON-Schema validator ──────────────────────────────────────
@@ -1078,13 +1081,11 @@ function validateValue(value: unknown, schema: SchemaNode, path: string): string
1078
1081
  if (schema.const !== undefined) {
1079
1082
  if (JSON.stringify(value) !== JSON.stringify(schema.const))
1080
1083
  errors.push(`${path}: expected const ${JSON.stringify(schema.const)}`);
1081
- return errors;
1082
1084
  }
1083
1085
  if (schema.enum !== undefined) {
1084
1086
  if (!(schema.enum as unknown[]).some((e) => JSON.stringify(e) === JSON.stringify(value))) {
1085
1087
  errors.push(`${path}: value not in enum`);
1086
1088
  }
1087
- return errors;
1088
1089
  }
1089
1090
  const type = schema.type;
1090
1091
  if (type !== undefined) {
@@ -1790,12 +1791,34 @@ export async function runWorkflow<T = unknown>(
1790
1791
  ? `\n\nReply with a single raw JSON value (object or array) matching exactly this JSON Schema:\n${JSON.stringify(schema)}\nDo not wrap it in prose or markdown fences.`
1791
1792
  : "";
1792
1793
  let repairFeedback = "";
1794
+ let previousAttempt: { tokens: number; tokenUsage?: AgentUsage; tier?: string } | undefined;
1793
1795
 
1794
1796
  try {
1795
1797
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1796
1798
  // Outside the attempt catch: a rejected admission is not a provider
1797
1799
  // attempt and must not be charged an estimated failure or retried.
1798
- checkBudget();
1800
+ try {
1801
+ checkBudget();
1802
+ } catch (error) {
1803
+ const workflowError = wrapError(error, { agentLabel: label });
1804
+ // A between-attempt denial ends the logical call, but it neither
1805
+ // starts nor charges another physical child. Preserve the previous
1806
+ // attempt's observations; the engine independently requires exact
1807
+ // settled-generation facts before making a durable terminal result.
1808
+ if (previousAttempt && !isAborted() && workflowError.code === WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED) {
1809
+ options.onAgentEnd?.({
1810
+ id: deltaKey,
1811
+ label,
1812
+ ...phaseObservation(assignedPhase),
1813
+ result: null,
1814
+ ...previousAttempt,
1815
+ error: workflowError.message,
1816
+ errorCode: workflowError.code,
1817
+ recoverable: workflowError.recoverable,
1818
+ });
1819
+ }
1820
+ throw error;
1821
+ }
1799
1822
  usage = undefined;
1800
1823
  const externalSignal = options.signal;
1801
1824
  let onExternalAbort: (() => void) | undefined;
@@ -1964,7 +1987,12 @@ export async function runWorkflow<T = unknown>(
1964
1987
  if (isAborted()) throw error;
1965
1988
 
1966
1989
  const workflowError = wrapError(error, { agentLabel: label });
1967
- const tokens = recordTokens(null);
1990
+ const allocationFree = error instanceof AllocationFreeDispatchError;
1991
+ // Only trusted local preflight proves this attempt never submitted.
1992
+ // A rejected repair still owns the logical call's earlier dispatch;
1993
+ // neither transport text nor a lost reply can refund that lease.
1994
+ if (allocationFree && attempt === 1) shared.physicalAgentCount--;
1995
+ const tokens = allocationFree ? 0 : recordTokens(null);
1968
1996
  // This attempt's store writes must not survive it — a failed attempt
1969
1997
  // shares this call's deltaKey with every other attempt, so without
1970
1998
  // rolling back here its writes would stay live in the store and merge
@@ -1977,6 +2005,7 @@ export async function runWorkflow<T = unknown>(
1977
2005
  `agent "${label}" attempt ${attempt}/${maxAttempts} failed: ${workflowError.code} ${workflowError.message}; retrying`,
1978
2006
  );
1979
2007
  options.onRetrySpend?.(tokens);
2008
+ previousAttempt = { tokens, tokenUsage: usage, tier: resolvedTier };
1980
2009
  continue;
1981
2010
  }
1982
2011
 
@@ -0,0 +1,97 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import type { WorkflowUiRunSummary } from "@signalridge/pi-subagents-protocol";
4
+ import type { ScriptRun } from "./journal.js";
5
+ import { sanitizeDisplayText } from "./safe-text.js";
6
+ import { callIndexes, isTerminal, statusMark } from "./ui-presentation.js";
7
+
8
+ export const OVERVIEW_LINGER_MS = 4_000;
9
+
10
+ /** Terminal history remains in /workflows, not an indefinitely growing passive list. */
11
+ export function overviewRuns(runs: readonly ScriptRun[], now = Date.now()): ScriptRun[] {
12
+ return runs.filter((run) => !isTerminal(run.status) || now - run.updatedAt < OVERVIEW_LINGER_MS);
13
+ }
14
+
15
+ function statusColor(status: string): "success" | "error" | "warning" | "accent" | "muted" {
16
+ if (status === "completed") return "success";
17
+ if (status === "failed") return "error";
18
+ if (["paused", "pausing", "interrupted", "stopping", "stopped"].includes(status)) return "warning";
19
+ return status === "running" ? "accent" : "muted";
20
+ }
21
+
22
+ function tokens(count: number): string {
23
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
24
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
25
+ return String(count);
26
+ }
27
+
28
+ /**
29
+ * The workflow owner's passive overview. Authority/phase/settled usage come from
30
+ * its existing projection, never from child records or in-flight usage polling.
31
+ * No keyboard listener: /workflows is the sole workflow navigator.
32
+ */
33
+ export function workflowOverviewLines(
34
+ runs: readonly ScriptRun[],
35
+ summaries: readonly WorkflowUiRunSummary[],
36
+ theme: Pick<Theme, "fg" | "bold">,
37
+ width: number,
38
+ maxRows: number,
39
+ mode: "compact" | "detailed" = "compact",
40
+ maxAgentsShown = 6,
41
+ now = Date.now(),
42
+ ): string[] {
43
+ if (!Number.isFinite(width) || width < 1 || !Number.isFinite(maxRows) || maxRows < 1) return [];
44
+ const columns = Math.floor(width);
45
+ const rows = Math.min(32, Math.floor(maxRows));
46
+ const max = Number.isFinite(maxAgentsShown) ? Math.max(1, Math.min(32, Math.floor(maxAgentsShown))) : 6;
47
+ const available = new Map(overviewRuns(runs, now).map((run) => [run.runId, run]));
48
+ const entries = summaries.filter((summary) => available.has(summary.runId));
49
+ if (!available.size) return [];
50
+ // The peer bridge caps publication at 256 summaries. It supplies bounded row
51
+ // metadata, never authority over how many owner-side runs exist or are live.
52
+ const active = [...available.values()].filter((run) => !isTerminal(run.status)).length;
53
+ const lines = [theme.fg("muted", `Workflows · ${active} active · /workflows`)];
54
+ if (rows === 1) return lines.map((line) => truncateToWidth(line, columns));
55
+ let shown = Math.min(max, entries.length, rows - 1);
56
+ if (available.size > shown && shown + 2 > rows) shown = Math.max(0, shown - 1);
57
+ const hidden = available.size - shown;
58
+ for (const [index, summary] of entries.slice(0, shown).entries()) {
59
+ const run = available.get(summary.runId);
60
+ if (!run) continue;
61
+ const color = statusColor(summary.status);
62
+ const phase = summary.currentPhase ? ` · ${sanitizeDisplayText(summary.currentPhase, 80)}` : "";
63
+ const stats = `${summary.runningCalls}/${summary.callCount} in flight · ${summary.completedCalls} done · ${summary.failedCalls} failed`;
64
+ // These are settled-call totals (including explicit zero), NOT live usage.
65
+ const usage = summary.tokenCount === undefined ? "" : ` · ${tokens(summary.tokenCount)} settled tokens`;
66
+ const metadata = `${phase} · ${stats}${summary.restored ? " · restored" : ""}`;
67
+ const left = ` ${theme.fg(color, "▸")} ${theme.bold(sanitizeDisplayText(summary.name, 80))} · ${theme.fg(color, summary.status)}${theme.fg("muted", metadata)}`;
68
+ const right = theme.fg("muted", `${Math.floor(summary.elapsedMs / 1_000)}s${usage}`);
69
+ const runLine = lines.length;
70
+ if (columns >= 48) {
71
+ // Preserve elapsed/settled usage on ordinary terminals, but never let
72
+ // secondary stats consume the primary name on a narrow viewport.
73
+ const boundedRight = truncateToWidth(right, Math.floor(columns / 3));
74
+ const boundedLeft = truncateToWidth(left, columns - visibleWidth(boundedRight) - 1);
75
+ const gap = Math.max(1, columns - visibleWidth(boundedLeft) - visibleWidth(boundedRight));
76
+ lines.push(boundedLeft + " ".repeat(gap) + boundedRight);
77
+ } else lines.push(`${left} ${right}`);
78
+ if (mode !== "detailed") continue;
79
+ const calls = callIndexes(run);
80
+ const live = calls.filter((id) => run.callStatus[id] === "running");
81
+ const liveIds = new Set(live);
82
+ const prioritized = [...live, ...calls.filter((id) => !liveIds.has(id))];
83
+ const reserved = shown - index - 1 + (hidden > 0 ? 1 : 0);
84
+ const callRows = Math.max(0, Math.min(max, rows - lines.length - reserved));
85
+ const visible = prioritized.slice(0, callRows);
86
+ for (const id of visible) {
87
+ const status = run.callStatus[id] ?? run.callResults[id]?.status ?? "unknown";
88
+ lines.push(` ${theme.fg(statusColor(status), statusMark(status))} ${theme.fg("muted", `${id}: ${status}`)}`);
89
+ }
90
+ const omitted = calls.length - visible.length;
91
+ if (omitted > 0)
92
+ lines[visible.length ? lines.length - 1 : runLine] += theme.fg("muted", ` · ${omitted} more calls`);
93
+ }
94
+ if (hidden > 0) lines.push(theme.fg("muted", ` ${hidden} more workflows · /workflows`));
95
+ if (lines.length < rows) lines.push(""); // One separator before the next dock/footer surface.
96
+ return lines.map((line) => truncateToWidth(line, columns));
97
+ }