@signalridge/pi-workflows 1.11.0 → 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,35 @@
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
+
19
+ ## 1.11.1
20
+ ### Patch Changes
21
+
22
+ - da9ed0f: - Reject rename hardlink aliases and retargeted queue identities before writes; support all LSP newline styles and identify possibly partially modified targets on write failure.
23
+ - Fence pending physical Plan model mutations at cancellable navigation/replacement gates, reconcile handoffs by native selection chronology, and scope thinking-only restoration to its originating model.
24
+ - Compose Goal guidance through an owned structured prompt section. Resolve overflow usage against physical response models and invalidate stale size evidence after persisted context edits in each independent continuation helper.
25
+ - Persist queued Rules reminders/offers only at authenticated native admission, match file URL paths, and provide usable rule-file references when read_rule is unavailable.
26
+ - Inherit nested workers' thinking from their immediate spawning session while preserving explicit policy precedence.
27
+ - Refresh skill discovery for custom Analytics runs without consuming pending user attribution, and finalize successful Codex diagnostics only after UI cleanup and the final owned result fence.
28
+ - da9ed0f: Restore workflow keyword and standing `/effort high|ultra` authorization on Pi 1.2 and later. Those hosts report the session as busy while its own run starts, so the hidden guidance that authorizes the `workflow` tool was silently dropped for every idle prompt. The idle check now applies only on hosts that do not identify the starting run, and runs started by a custom message never consume an armed prompt.
29
+ - da9ed0f: Stop settled workflow results from waking a session whose context overflowed the model window. While the latest response on the branch is a context overflow with no compaction after it, results are still delivered and acknowledged as context but no wake is sent, and one warning per overflow episode asks to run `/compact` (or `/codex-compact` when installed) or start a new session. A compaction or a later successful response restores normal wakes. Previously each wake resent the same over-window context and failed again. The package now declares `@earendil-works/pi-ai` as a host peer dependency.
30
+ - Updated dependencies [da9ed0f]
31
+ - @signalridge/pi-ui@1.4.1
32
+
3
33
  ## 1.11.0
4
34
  ### Minor Changes
5
35
 
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
 
@@ -306,6 +312,14 @@ awaits while navigation or shutdown retires the owner, the obsolete
306
312
  extension-sourced wake is handled without starting a turn. Interactive/RPC
307
313
  input and other extensions' messages are not consumed by this guard.
308
314
 
315
+ No wake is sent while the session's latest meaningful response overflowed the model's
316
+ context window and no compaction followed it: that turn would resend the same
317
+ over-window context and fail again. Results are still delivered and acknowledged
318
+ as context, and one warning per overflow episode asks to run `/compact` (or
319
+ `/codex-compact` when installed) or start a new session. A compaction or a later
320
+ successful response restores normal wakes. Aborted responses and transient errors
321
+ (including HTTP 503 and rate limits) do not prove context recovery.
322
+
309
323
  This is a handoff check, not a navigation lock: public hooks cannot fence an
310
324
  arbitrary later async input or preparation handler that navigates after our
311
325
  input hook has accepted the wake. Earlier transforms must preserve the private
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-workflows",
3
- "version": "1.11.0",
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",
@@ -15,6 +15,7 @@
15
15
  "lifecycle": "stable"
16
16
  },
17
17
  "peerDependencies": {
18
+ "@earendil-works/pi-ai": "*",
18
19
  "@earendil-works/pi-coding-agent": "*",
19
20
  "@earendil-works/pi-tui": "*",
20
21
  "@signalridge/pi-subagents": ">=1.9.0",
@@ -22,7 +23,7 @@
22
23
  },
23
24
  "dependencies": {
24
25
  "@signalridge/pi-subagents-protocol": "^1.6.0",
25
- "@signalridge/pi-ui": "^1.4.0",
26
+ "@signalridge/pi-ui": "^1.5.0",
26
27
  "acorn": "^8.16.0"
27
28
  },
28
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.0"
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.0`
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.0`
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.0"
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.0"
5
+ version: "1.12.0"
6
6
  ---
7
7
 
8
8
  # Workflow review
package/src/arming.ts CHANGED
@@ -235,15 +235,21 @@ export class WorkflowTurnArming {
235
235
  }
236
236
 
237
237
  consume(event: BeforeAgentStartEvent, ctx: ExtensionContext): void {
238
+ // Pi 1.2+ also announces idle custom-message runs; only a user prompt can
239
+ // carry the armed input, and earlier hosts never reached here for them.
240
+ if (event.messageType === "custom") return;
238
241
  const pending = this.pending;
239
242
  const ambiguous = this.ambiguous;
240
243
  this.pending = undefined; // one-use, also for mismatched/consumed prompts
241
244
  this.current = undefined;
242
245
  try {
246
+ // Pi 1.2+ (which reports messageType) counts the session busy while its
247
+ // own run prepares and queues concurrent sends into it. Earlier hosts were
248
+ // still idle here, so a busy session meant another run had started.
243
249
  if (
244
250
  ambiguous ||
245
251
  !pending?.content ||
246
- !ctx.isIdle() ||
252
+ (event.messageType === undefined && !ctx.isIdle()) ||
247
253
  event.prompt !== pending.text ||
248
254
  inputKey("", event.images) !== pending.images ||
249
255
  ctx.sessionManager.getSessionId() !== pending.sessionId ||
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Overflow-aware waking. After a response overflowed the model's context window and no
3
+ * compaction followed, a turn this extension starts resends the same over-window context and
4
+ * fails again, and each failure is still a billed request. Wakes are held until a compaction
5
+ * or a later successful response ends the episode.
6
+ */
7
+ import { isContextOverflow } from "@earendil-works/pi-ai";
8
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+
10
+ type EpisodeContext = Pick<ExtensionContext, "sessionManager"> & {
11
+ modelRegistry?: { find(provider: string, model: string): { contextWindow?: number } | undefined };
12
+ };
13
+ export type OverflowContext = EpisodeContext & Pick<ExtensionContext, "hasUI" | "ui">;
14
+
15
+ /**
16
+ * The first overflowed response of the current episode: the latest meaningful response on the
17
+ * branch overflowed and neither compaction nor a successful response followed. Aborted and
18
+ * transient error responses say nothing about the context size and are skipped.
19
+ */
20
+ export function overflowEpisode(ctx: EpisodeContext): string | undefined {
21
+ try {
22
+ const branch = ctx.sessionManager.getBranch();
23
+ const projection = ctx.sessionManager.buildSessionProjection?.();
24
+ const projected = projection
25
+ ? new Set(
26
+ projection.entries
27
+ .filter((entry) => entry.messages.some((message) => message.role === "assistant"))
28
+ .map((entry) => entry.sourceEntry.id),
29
+ )
30
+ : undefined;
31
+ let contextEdited = false;
32
+ let episode: string | undefined;
33
+ for (let index = branch.length - 1; index >= 0; index--) {
34
+ const entry = branch[index];
35
+ if (entry.type === "compaction") break;
36
+ if (entry.type === "context_edit") {
37
+ contextEdited = true;
38
+ continue;
39
+ }
40
+ if (entry.type !== "message" || entry.message.role !== "assistant" || entry.message.stopReason === "aborted")
41
+ continue;
42
+ const explicit = entry.message.stopReason === "error" && isContextOverflow(entry.message);
43
+ if (entry.message.stopReason === "error" && !explicit) continue;
44
+ // Pi can omit a failed recovery attempt before a cancelled compaction.
45
+ // That alone does not repair its oversized input; invalidate usage only.
46
+ if (!explicit && projected && !projected.has(entry.id)) break;
47
+ const contextWindow = ctx.modelRegistry?.find(entry.message.provider, entry.message.model)?.contextWindow;
48
+ if (!explicit && (contextEdited || !isContextOverflow(entry.message, contextWindow))) {
49
+ if (entry.message.stopReason === "error") continue;
50
+ break;
51
+ }
52
+ episode = entry.id;
53
+ }
54
+ return episode;
55
+ } catch {
56
+ // A replaced session's context is stale; never hold a wake on a guess.
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ export function overflowNotice(held: string, codexCompact: boolean): string {
62
+ return (
63
+ `Context exceeds the model's window; ${held} without starting a turn. ` +
64
+ `Run ${codexCompact ? "/compact or /codex-compact" : "/compact"}, or start a new session.`
65
+ );
66
+ }
67
+
68
+ /** One guard per extension instance, so the user sees one notice per overflow episode. */
69
+ export class OverflowWakeGuard {
70
+ private noticed: string | undefined;
71
+ constructor(
72
+ private readonly pi: Pick<ExtensionAPI, "getCommands">,
73
+ private readonly held: string,
74
+ ) {}
75
+
76
+ /** True while a turn must not start; the first hold of an episode tells the user why. */
77
+ holds(ctx: OverflowContext | undefined): boolean {
78
+ const episode = ctx ? overflowEpisode(ctx) : undefined;
79
+ if (!ctx || episode === undefined) return false;
80
+ if (episode !== this.noticed) {
81
+ this.noticed = episode;
82
+ try {
83
+ if (ctx.hasUI) ctx.ui.notify(overflowNotice(this.held, this.hasCommand("codex-compact")), "warning");
84
+ } catch {
85
+ // A disposed UI must not block the delivery that follows.
86
+ }
87
+ }
88
+ return true;
89
+ }
90
+
91
+ private hasCommand(name: string): boolean {
92
+ try {
93
+ return this.pi.getCommands().some((command) => command.name === name);
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+ }
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,12 +2,13 @@ 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";
9
9
  import { BUILTIN_WORKFLOWS, validateBuiltinArgs } from "./builtins.js";
10
10
  import { type CommandResult, resolveCodeReviewScope } from "./code-review-scope.js";
11
+ import { OverflowWakeGuard } from "./context-overflow.js";
11
12
  import { type ScriptStartOptions, type ScriptStartResult, WorkflowEngine, WorkflowWaitAbortedError } from "./engine.js";
12
13
  import { JOURNAL_ENTRY_TYPE, type SessionEntryLike } from "./journal.js";
13
14
  import {
@@ -25,8 +26,9 @@ import {
25
26
  WORKFLOW_STRENGTHS,
26
27
  type WorkflowStrengthTable,
27
28
  } from "./strengths.js";
28
- import { liveWidgetLines, showWorkflowNavigator } from "./ui.js";
29
+ import { showWorkflowNavigator } from "./ui.js";
29
30
  import { buildWorkflowUiSnapshot, openOwnedConversation, WorkflowUiBridge, type WorkflowUiSurface } from "./ui-bus.js";
31
+ import { overviewRuns, workflowOverviewLines } from "./ui-overview.js";
30
32
  import {
31
33
  loadProjectWorkflowSettings,
32
34
  loadWorkflowSettings,
@@ -361,6 +363,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
361
363
  let keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
362
364
  /** Public sends are void: acceptance is checked against the owning branch. */
363
365
  let deliveryContext: ExtensionContext | undefined;
366
+ const overflowWakes = new OverflowWakeGuard(pi, "workflow results are added to the conversation");
364
367
  let deliveryTimer: ReturnType<typeof setTimeout> | undefined;
365
368
  let inputPreparing = false;
366
369
  const turnArming = new WorkflowTurnArming();
@@ -507,11 +510,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
507
510
  .map((summary) => workflowEngine.getRun(String(summary.runId)))
508
511
  .filter((run): run is NonNullable<typeof run> => run !== undefined);
509
512
  if (statusSource?.()) tui.setStatus("pi-workflows", workflowStatusSummary(runs));
510
- const hasActive = runs.some((run) => !["completed", "failed", "stopped"].includes(run.status));
513
+ const hasProgress = overviewRuns(runs).length > 0;
511
514
  const surfaceId = workflowUiSurfaceId;
515
+ const snapshot = buildWorkflowUiSnapshot(workflowEngine, surfaceId);
512
516
  tui.setWidget(
513
517
  "pi-workflows",
514
- hasActive
518
+ hasProgress
515
519
  ? (host, theme) => ({
516
520
  render(width) {
517
521
  if (
@@ -519,29 +523,33 @@ export default function piWorkflows(pi: ExtensionAPI): void {
519
523
  engine !== workflowEngine ||
520
524
  workflowEngine.isDisposed() ||
521
525
  workflowUiSurfaceId !== surfaceId ||
526
+ !ownsDeliveryBranch() ||
522
527
  !Number.isFinite(width) ||
523
528
  width < 1
524
529
  )
525
530
  return [];
526
531
  const height = Number.isFinite(host.terminal.rows) ? Math.max(0, host.terminal.rows) : 24;
527
- const rowBudget = Math.max(1, Math.min(12, Math.floor(height / 4)));
528
- return liveWidgetLines(
532
+ const rowBudget = Math.max(0, Math.min(12, Math.floor(height / 4)));
533
+ return workflowOverviewLines(
529
534
  runs,
535
+ snapshot.runs,
536
+ theme,
537
+ width,
538
+ rowBudget,
530
539
  workflowSettings.progressMode,
531
540
  workflowSettings.maxAgentsShown,
532
- rowBudget,
533
- ).map((line, index) =>
534
- truncateToWidth(theme.fg(index === 0 ? "accent" : "muted", line), Math.floor(width)),
535
541
  );
536
542
  },
537
543
  invalidate() {},
538
544
  })
539
545
  : undefined,
546
+ { placement: "belowEditor" },
540
547
  );
548
+ // Publication/footer cadence is independent of passive row visibility.
541
549
  workflowUiBridge?.publish();
542
- if (hasActive && !widgetTimer) {
550
+ if (hasProgress && !widgetTimer) {
543
551
  widgetTimer = setInterval(refreshWidget, 2_000);
544
- } else if (!hasActive && widgetTimer) {
552
+ } else if (!hasProgress && widgetTimer) {
545
553
  clearInterval(widgetTimer);
546
554
  widgetTimer = undefined;
547
555
  }
@@ -763,9 +771,6 @@ export default function piWorkflows(pi: ExtensionAPI): void {
763
771
  // re-run every finished call untabled.
764
772
  workflowEngine.strengths = () => workflowSettings.strengths;
765
773
  workflowEngine.loadSavedWorkflow = resolveNestedWorkflow;
766
- // Command/tool registrations persist across session replacement, but the
767
- // widget belongs to the newly restored engine and must be refreshed too.
768
- refreshWidget();
769
774
  if (surfaceRegistered) return;
770
775
  surfaceRegistered = true;
771
776
 
@@ -1808,7 +1813,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1808
1813
  try {
1809
1814
  if (!isCurrent()) return;
1810
1815
  // Never use a custom-message triggerTurn: that skips input preparation
1811
- // and before_agent_start. The void API is NOT an acceptance promise.
1816
+ // (and, before Pi 1.2, before_agent_start). The void API is NOT an
1817
+ // acceptance promise.
1812
1818
  const missing = batch.filter(([runId]) => !persisted(runId));
1813
1819
  if (missing.length > 0)
1814
1820
  pi.sendMessage(
@@ -1842,7 +1848,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
1842
1848
  }
1843
1849
  // An earlier input hook can await while the same manager navigates. Keep
1844
1850
  // exact owner evidence until our input hook consumes this private wake.
1845
- if (isCurrent() && engine) {
1851
+ // An overflowed parent would resend the same over-window context: the
1852
+ // acknowledged results above wait for the next run instead.
1853
+ if (isCurrent() && engine && !overflowWakes.holds(ctx)) {
1846
1854
  const wake = `${DELIVERY_WAKE_PREFIX}${randomUUID()}] ${DELIVERY_WAKE_TEXT}`;
1847
1855
  wakeAdmissions.set(wake, { generation, context: ctx, engine, anchor: deliveryBranchAnchor });
1848
1856
  while (wakeAdmissions.size > MAX_WAKE_ADMISSIONS) {
@@ -2003,7 +2011,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
2003
2011
  return;
2004
2012
  }
2005
2013
  engine = recoveredEngine;
2006
- widgetUi = ctx.mode === "tui" ? ctx.ui : undefined;
2014
+ widgetUi = ctx.mode === "tui" && ctx.hasUI ? ctx.ui : undefined;
2007
2015
  registerSurface();
2008
2016
 
2009
2017
  // Public API methods target the current session: generation + branch
@@ -2012,7 +2020,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
2012
2020
  deliveryBranchAnchor = journalBranchAnchor;
2013
2021
  registerStatusActions();
2014
2022
  workflowUiBridge ??= new WorkflowUiBridge(pi.events, workflowUiSurface);
2015
- workflowUiBridge.publish();
2023
+ // Bind the presentation only after its session/branch source is installed.
2024
+ refreshWidget();
2025
+ if (!widgetUi) workflowUiBridge.publish();
2016
2026
  inputPreparing = false;
2017
2027
  compacting = false;
2018
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
+ }