pi-subagents 0.65.0 → 0.66.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.
Files changed (104) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +1 -1
  3. package/agents/researcher.md +23 -13
  4. package/docs/agents.md +17 -3
  5. package/docs/configuration.md +34 -0
  6. package/docs/extension-api.md +94 -0
  7. package/docs/models.md +58 -1
  8. package/docs/observability.md +42 -2
  9. package/docs/tool-reference.md +11 -5
  10. package/docs/workflows.md +22 -7
  11. package/package.json +4 -1
  12. package/runner-server-preload.mjs +13 -0
  13. package/skills/pi-subagents/SKILL.md +2 -1
  14. package/skills/pi-subagents/references/execution-controls.md +19 -2
  15. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
  16. package/skills/pi-subagents/references/multi-lane-orchestration.md +2 -0
  17. package/src/agents/advertised-agent-prompt.ts +63 -0
  18. package/src/agents/agent-management.ts +14 -1
  19. package/src/agents/agent-serializer.ts +2 -0
  20. package/src/agents/agents.ts +8 -0
  21. package/src/api/preflight.ts +5 -1
  22. package/src/api/shared-types.ts +1 -1
  23. package/src/api/workflow-resources.ts +6 -0
  24. package/src/extension/config.ts +4 -2
  25. package/src/extension/index.ts +71 -4
  26. package/src/extension/public-execution.ts +0 -1
  27. package/src/extension/rpc.ts +4 -21
  28. package/src/extension/schemas.ts +9 -7
  29. package/src/extension/tool-description.ts +10 -5
  30. package/src/integrations/pi-web-session-liveness.ts +73 -0
  31. package/src/intercom/native-supervisor-channel.ts +102 -88
  32. package/src/intercom/supervisor-ui.ts +3 -2
  33. package/src/missions/workflow-state.ts +37 -16
  34. package/src/runs/background/active-async-capacity.ts +18 -18
  35. package/src/runs/background/async-execution.ts +8 -1
  36. package/src/runs/background/async-job-tracker.ts +35 -3
  37. package/src/runs/background/async-resume.ts +3 -1
  38. package/src/runs/background/async-retention.ts +9 -0
  39. package/src/runs/background/async-status-snapshot.ts +10 -12
  40. package/src/runs/background/async-status.ts +17 -9
  41. package/src/runs/background/auto-drain.ts +40 -29
  42. package/src/runs/background/chain-root-attachment.ts +8 -0
  43. package/src/runs/background/control-channel.ts +78 -44
  44. package/src/runs/background/notify.ts +88 -12
  45. package/src/runs/background/owned-process-tree.ts +6 -6
  46. package/src/runs/background/process-terminal.ts +23 -23
  47. package/src/runs/background/retained-nested-route-tracker.ts +96 -0
  48. package/src/runs/background/run-child-session.ts +62 -33
  49. package/src/runs/background/run-status.ts +75 -5
  50. package/src/runs/background/runner-aliases.ts +46 -8
  51. package/src/runs/background/runner-child-launch.ts +86 -0
  52. package/src/runs/background/stale-run-reconciler.ts +3 -1
  53. package/src/runs/background/subagent-runner.ts +430 -208
  54. package/src/runs/background/subagent-wait.ts +3 -0
  55. package/src/runs/background/wait-completions.ts +4 -0
  56. package/src/runs/foreground/async-steering-action.ts +19 -0
  57. package/src/runs/foreground/execution.ts +116 -26
  58. package/src/runs/foreground/foreground-history.ts +3 -1
  59. package/src/runs/foreground/prompt-audit.ts +9 -5
  60. package/src/runs/foreground/subagent-executor.ts +531 -227
  61. package/src/runs/foreground/workflow-detach-reconcile.ts +8 -5
  62. package/src/runs/foreground/workflow-foreground-steering.ts +56 -2
  63. package/src/runs/shared/acceptance.ts +16 -3
  64. package/src/runs/shared/agent-contract.ts +1 -1
  65. package/src/runs/shared/async-status-projection.ts +47 -47
  66. package/src/runs/shared/child-hooks.ts +151 -2
  67. package/src/runs/shared/child-launch.ts +18 -13
  68. package/src/runs/shared/child-session.ts +55 -24
  69. package/src/runs/shared/child-tool-plan.ts +2 -2
  70. package/src/runs/shared/completion-evidence.ts +2 -2
  71. package/src/runs/shared/completion-guard.ts +1 -0
  72. package/src/runs/shared/host-step-status.ts +11 -11
  73. package/src/runs/shared/llm-intent-arbiter.ts +30 -20
  74. package/src/runs/shared/model-exclusions.ts +2 -1
  75. package/src/runs/shared/model-fallback.ts +41 -8
  76. package/src/runs/shared/nested-events.ts +8 -8
  77. package/src/runs/shared/orca-progress-tabs.ts +6 -0
  78. package/src/runs/shared/parallel-handoff.ts +57 -12
  79. package/src/runs/shared/parallel-utils.ts +3 -2
  80. package/src/runs/shared/readonly-drain-observation.ts +42 -0
  81. package/src/runs/shared/readonly-model-continuation.ts +69 -0
  82. package/src/runs/shared/readonly-session-evidence.ts +307 -0
  83. package/src/runs/shared/run-fanout-budget.ts +8 -8
  84. package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
  85. package/src/runs/shared/subagent-prompt-runtime.ts +13 -3
  86. package/src/runs/shared/worktree-cleanup-plan.ts +6 -3
  87. package/src/runs/shared/worktree-setup-command.ts +190 -0
  88. package/src/runs/shared/worktree.ts +403 -210
  89. package/src/shared/model-response-aliases.ts +13 -0
  90. package/src/shared/types.ts +89 -60
  91. package/src/shared/utils.ts +10 -2
  92. package/src/shared/watch-strategy.ts +2 -0
  93. package/src/shared/workflow-child-permit.ts +18 -13
  94. package/src/tui/fleet-status.ts +1 -1
  95. package/src/tui/fleet.ts +11 -5
  96. package/src/tui/render.ts +44 -15
  97. package/src/workflows/chat-progress.ts +3 -3
  98. package/src/workflows/scripted-workflow.ts +70 -16
  99. package/src/workflows/workflow-checklist.ts +15 -18
  100. package/src/workflows/workflow-child-summary.ts +57 -8
  101. package/src/workflows/workflow-preflight.ts +19 -19
  102. package/src/workflows/workflow-receipt.ts +3 -3
  103. package/src/workflows/workflow-resources.ts +96 -21
  104. package/src/workflows/workflow-settlement.ts +3 -0
@@ -4,10 +4,10 @@ import { writeAtomicJson, writePrivateAtomicJson } from "../../shared/atomic-jso
4
4
  import {
5
5
  SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
6
6
  type AsyncStatus,
7
- type CanonicalSessionTerminalV1,
8
- type ProcessInstanceExitV1,
7
+ type CanonicalSessionTerminal,
8
+ type ProcessInstanceExit,
9
9
  type ProcessTerminalReason,
10
- type ProcessTerminalV1,
10
+ type ProcessTerminal,
11
11
  } from "../../shared/types.ts";
12
12
  import { canonicalSessionId, inspectSessionLease } from "../shared/session-lease.ts";
13
13
  import { releaseActiveRunIndex } from "./active-run-index.ts";
@@ -16,7 +16,7 @@ export interface ProcessTerminalCandidate {
16
16
  version: 1;
17
17
  runId: string;
18
18
  runnerProcessInstanceId: string;
19
- writers: Record<string, ProcessInstanceExitV1[]>;
19
+ writers: Record<string, ProcessInstanceExit[]>;
20
20
  expectedWriters?: Record<string, number>;
21
21
  sessionFile?: string;
22
22
  revivalLeaseToken?: string;
@@ -34,7 +34,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
34
34
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
35
35
  }
36
36
 
37
- function validProcessInstance(value: unknown, kind?: "runner" | "pi-writer"): value is ProcessInstanceExitV1 {
37
+ function validProcessInstance(value: unknown, kind?: "runner" | "pi-writer"): value is ProcessInstanceExit {
38
38
  if (!isRecord(value)) return false;
39
39
  if (typeof value.processInstanceId !== "string" || value.processInstanceId.length === 0) return false;
40
40
  if (kind ? value.kind !== kind : (value.kind !== "runner" && value.kind !== "pi-writer")) return false;
@@ -56,7 +56,7 @@ function validProcessInstance(value: unknown, kind?: "runner" | "pi-writer"): va
56
56
  && (value.processTree.diagnostic === undefined || typeof value.processTree.diagnostic === "string");
57
57
  }
58
58
 
59
- function validInstance(value: unknown): value is ProcessInstanceExitV1 {
59
+ function validInstance(value: unknown): value is ProcessInstanceExit {
60
60
  return validProcessInstance(value, "pi-writer");
61
61
  }
62
62
 
@@ -78,7 +78,7 @@ export function readProcessTerminalCandidate(asyncDir: string): ProcessTerminalC
78
78
  if (!isRecord(raw) || raw.version !== 1 || typeof raw.runId !== "string" || typeof raw.runnerProcessInstanceId !== "string" || !isRecord(raw.writers)) {
79
79
  throw new Error(`Invalid process-terminal candidate in '${asyncDir}'.`);
80
80
  }
81
- const writers: Record<string, ProcessInstanceExitV1[]> = {};
81
+ const writers: Record<string, ProcessInstanceExit[]> = {};
82
82
  for (const [index, entries] of Object.entries(raw.writers)) {
83
83
  if (!Array.isArray(entries) || !entries.every(validInstance)) throw new Error(`Invalid writer process records for child '${index}'.`);
84
84
  writers[index] = entries;
@@ -137,7 +137,7 @@ export function markProcessTerminalCandidateLeaseRelease(asyncDir: string, token
137
137
  writeProcessTerminalCandidate(asyncDir, { ...candidate, revivalLeaseReleaseAcknowledged: acknowledged });
138
138
  }
139
139
 
140
- function unknownProof(runId: string, runnerProcessInstanceId: string, reason: ProcessTerminalReason, diagnostic?: string): ProcessTerminalV1 {
140
+ function unknownProof(runId: string, runnerProcessInstanceId: string, reason: ProcessTerminalReason, diagnostic?: string): ProcessTerminal {
141
141
  return { version: 1, state: "unknown", runId, runnerProcessInstanceId, reason, ...(diagnostic ? { diagnostic } : {}) };
142
142
  }
143
143
 
@@ -147,7 +147,7 @@ function resumeDisposition(state: string | undefined, sessionFile: string | unde
147
147
  return sessionFile && fs.existsSync(sessionFile) ? "resumable" : "unavailable";
148
148
  }
149
149
 
150
- function sessionProjection(candidate: ProcessTerminalCandidate, lease: ReturnType<typeof inspectSessionLease>): CanonicalSessionTerminalV1 | undefined {
150
+ function sessionProjection(candidate: ProcessTerminalCandidate, lease: ReturnType<typeof inspectSessionLease>): CanonicalSessionTerminal | undefined {
151
151
  if (!candidate.sessionFile || lease.state !== "free") return undefined;
152
152
  if (candidate.revivalLeaseToken && candidate.revivalLeaseReleaseAcknowledged !== true) return undefined;
153
153
  return {
@@ -158,7 +158,7 @@ function sessionProjection(candidate: ProcessTerminalCandidate, lease: ReturnTyp
158
158
  };
159
159
  }
160
160
 
161
- function validateProof(raw: unknown, asyncDir: string, fallback?: { runId?: string; runnerProcessInstanceId?: string }): raw is ProcessTerminalV1 {
161
+ function validateProof(raw: unknown, asyncDir: string, fallback?: { runId?: string; runnerProcessInstanceId?: string }): raw is ProcessTerminal {
162
162
  if (!isRecord(raw) || raw.version !== 1 || !["pending", "observed", "unknown", "not-started"].includes(String(raw.state)) || typeof raw.runId !== "string" || !raw.runId || typeof raw.runnerProcessInstanceId !== "string" || !raw.runnerProcessInstanceId) {
163
163
  throw new Error(`Invalid process-terminal proof in '${asyncDir}'.`);
164
164
  }
@@ -177,21 +177,21 @@ function validateProof(raw: unknown, asyncDir: string, fallback?: { runId?: stri
177
177
  return true;
178
178
  }
179
179
 
180
- export function sanitizeProcessTerminal(value: unknown, fallback: { runId?: string; runnerProcessInstanceId?: string }, label = "status"): ProcessTerminalV1 | undefined {
180
+ export function sanitizeProcessTerminal(value: unknown, fallback: { runId?: string; runnerProcessInstanceId?: string }, label = "status"): ProcessTerminal | undefined {
181
181
  if (value === undefined) return undefined;
182
182
  try {
183
183
  validateProof(value, label, fallback);
184
- return value as ProcessTerminalV1;
184
+ return value as ProcessTerminal;
185
185
  } catch (error) {
186
186
  return unknownProof(fallback.runId ?? label, fallback.runnerProcessInstanceId ?? "unknown", "proof-write-failed", errorMessage(error));
187
187
  }
188
188
  }
189
189
 
190
- export function readProcessTerminal(asyncDir: string, fallback?: { runId?: string; runnerProcessInstanceId?: string }): ProcessTerminalV1 | undefined {
190
+ export function readProcessTerminal(asyncDir: string, fallback?: { runId?: string; runnerProcessInstanceId?: string }): ProcessTerminal | undefined {
191
191
  try {
192
192
  const raw = JSON.parse(fs.readFileSync(processTerminalPath(asyncDir), "utf-8")) as unknown;
193
193
  validateProof(raw, asyncDir, fallback);
194
- return raw as ProcessTerminalV1;
194
+ return raw as ProcessTerminal;
195
195
  } catch (error) {
196
196
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
197
197
  return unknownProof(fallback?.runId ?? path.basename(asyncDir), fallback?.runnerProcessInstanceId ?? "unknown", "proof-write-failed", errorMessage(error));
@@ -199,12 +199,12 @@ export function readProcessTerminal(asyncDir: string, fallback?: { runId?: strin
199
199
  }
200
200
 
201
201
  function stepProcessTerminalProof(
202
- proof: ProcessTerminalV1,
202
+ proof: ProcessTerminal,
203
203
  childIndex: number,
204
- state: ProcessTerminalV1["state"],
205
- records: ProcessInstanceExitV1[],
206
- resumeDispositionValue: ProcessTerminalV1["resumeDisposition"],
207
- ): ProcessTerminalV1 {
204
+ state: ProcessTerminal["state"],
205
+ records: ProcessInstanceExit[],
206
+ resumeDispositionValue: ProcessTerminal["resumeDisposition"],
207
+ ): ProcessTerminal {
208
208
  const base = {
209
209
  version: 1 as const,
210
210
  runId: proof.runId,
@@ -221,7 +221,7 @@ function stepProcessTerminalProof(
221
221
  return { ...base, state };
222
222
  }
223
223
 
224
- function overlayStatus(asyncDir: string, proof: ProcessTerminalV1, candidate?: ProcessTerminalCandidate): void {
224
+ function overlayStatus(asyncDir: string, proof: ProcessTerminal, candidate?: ProcessTerminalCandidate): void {
225
225
  const statusPath = path.join(asyncDir, "status.json");
226
226
  try {
227
227
  const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
@@ -244,13 +244,13 @@ export function finalizeProcessTerminal(
244
244
  asyncDir: string,
245
245
  runId: string,
246
246
  runnerClose: RunnerCloseObservation,
247
- ): ProcessTerminalV1 {
247
+ ): ProcessTerminal {
248
248
  const existing = readProcessTerminal(asyncDir, { runId, runnerProcessInstanceId: runnerClose.processInstanceId });
249
249
  if (existing && fs.existsSync(processTerminalPath(asyncDir))) {
250
250
  if (existing.state === "observed" && existing.runId === runId && existing.runnerProcessInstanceId === runnerClose.processInstanceId) return existing;
251
251
  if (existing.state === "unknown") return existing;
252
252
  }
253
- let proof: ProcessTerminalV1;
253
+ let proof: ProcessTerminal;
254
254
  let candidateForOverlay: ProcessTerminalCandidate | undefined;
255
255
  try {
256
256
  const candidate = readProcessTerminalCandidate(asyncDir);
@@ -279,7 +279,7 @@ export function finalizeProcessTerminal(
279
279
  } else if (allWriters.some((writer) => writer.kind === "pi-writer" && writer.processTree.state !== "observed")) {
280
280
  proof = unknownProof(runId, runnerClose.processInstanceId, "process-tree-unverified");
281
281
  } else {
282
- const runner: ProcessInstanceExitV1 = { kind: "runner", ...runnerClose };
282
+ const runner: ProcessInstanceExit = { kind: "runner", ...runnerClose };
283
283
  const canonicalSession = session && sessionProjection(candidate, session);
284
284
  proof = {
285
285
  version: 1,
@@ -0,0 +1,96 @@
1
+ import * as fs from "node:fs";
2
+ import type { SubagentState } from "../../shared/types.ts";
3
+ import { shouldUseNativeFsWatch } from "../../shared/watch-strategy.ts";
4
+ import { hasLiveNestedDescendants, projectNestedEvents } from "../shared/nested-events.ts";
5
+
6
+ interface RetainedNestedRouteTrackerOptions {
7
+ pollIntervalMs?: number;
8
+ platform?: NodeJS.Platform;
9
+ watch?: typeof fs.watch;
10
+ }
11
+
12
+ const DEFAULT_POLL_INTERVAL_MS = 5000;
13
+ const REFRESH_DEBOUNCE_MS = 25;
14
+
15
+ export function createRetainedNestedRouteTracker(
16
+ state: Pick<SubagentState, "retainedForegroundNestedRoutes">,
17
+ options: RetainedNestedRouteTrackerOptions = {},
18
+ ): { track: (rootRunId: string) => void; clear: () => void } {
19
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
20
+ const watch = options.watch ?? fs.watch;
21
+ const watchers = new Map<string, fs.FSWatcher>();
22
+ const refreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
23
+ let poller: ReturnType<typeof setInterval> | undefined;
24
+
25
+ const close = (rootRunId: string): void => {
26
+ watchers.get(rootRunId)?.close();
27
+ watchers.delete(rootRunId);
28
+ const timer = refreshTimers.get(rootRunId);
29
+ if (timer) clearTimeout(timer);
30
+ refreshTimers.delete(rootRunId);
31
+ };
32
+
33
+ const refresh = (rootRunId: string): void => {
34
+ const retained = state.retainedForegroundNestedRoutes?.get(rootRunId);
35
+ if (!retained) {
36
+ close(rootRunId);
37
+ return;
38
+ }
39
+ try {
40
+ if (hasLiveNestedDescendants(projectNestedEvents(retained).children)) return;
41
+ state.retainedForegroundNestedRoutes?.delete(rootRunId);
42
+ close(rootRunId);
43
+ } catch (error) {
44
+ console.error(`Failed to refresh retained nested descendants for foreground run '${rootRunId}':`, error);
45
+ }
46
+ };
47
+
48
+ const scheduleRefresh = (rootRunId: string): void => {
49
+ if (refreshTimers.has(rootRunId)) return;
50
+ const timer = setTimeout(() => {
51
+ refreshTimers.delete(rootRunId);
52
+ refresh(rootRunId);
53
+ }, REFRESH_DEBOUNCE_MS);
54
+ timer.unref?.();
55
+ refreshTimers.set(rootRunId, timer);
56
+ };
57
+
58
+ const ensurePoller = (): void => {
59
+ if (poller || (state.retainedForegroundNestedRoutes?.size ?? 0) === 0) return;
60
+ poller = setInterval(() => {
61
+ for (const rootRunId of state.retainedForegroundNestedRoutes?.keys() ?? []) refresh(rootRunId);
62
+ if ((state.retainedForegroundNestedRoutes?.size ?? 0) > 0) return;
63
+ if (poller) clearInterval(poller);
64
+ poller = undefined;
65
+ }, pollIntervalMs);
66
+ poller.unref?.();
67
+ };
68
+
69
+ const track = (rootRunId: string): void => {
70
+ const retained = state.retainedForegroundNestedRoutes?.get(rootRunId);
71
+ if (!retained) return;
72
+ if (shouldUseNativeFsWatch("retained-nested-route-tracker", options.platform) && !watchers.has(rootRunId)) {
73
+ try {
74
+ const watcher = watch(retained.eventSink, () => scheduleRefresh(rootRunId));
75
+ watcher.on("error", () => close(rootRunId));
76
+ watcher.unref?.();
77
+ watchers.set(rootRunId, watcher);
78
+ } catch {
79
+ // The bounded safety poll below covers unsupported or failed watchers.
80
+ }
81
+ }
82
+ scheduleRefresh(rootRunId);
83
+ ensurePoller();
84
+ };
85
+
86
+ const clear = (): void => {
87
+ if (poller) clearInterval(poller);
88
+ poller = undefined;
89
+ for (const rootRunId of watchers.keys()) close(rootRunId);
90
+ for (const timer of refreshTimers.values()) clearTimeout(timer);
91
+ refreshTimers.clear();
92
+ state.retainedForegroundNestedRoutes?.clear();
93
+ };
94
+
95
+ return { track, clear };
96
+ }
@@ -8,7 +8,7 @@
8
8
  import type { Message } from "@earendil-works/pi-ai";
9
9
  import type { ChildTranscriptWriter } from "../../shared/child-transcript.ts";
10
10
  import { extractTextFromContent, extractToolArgsPreview, getFinalOutput, hasEmptyTerminalAssistantResponse } from "../../shared/utils.ts";
11
- import type { EffectsProjection, RuntimeAcknowledgedChildExtensionsV1, SubagentOutputState, ToolBudgetState, Usage } from "../../shared/types.ts";
11
+ import type { EffectsProjection, RuntimeAcknowledgedChildExtensions, SubagentOutputState, ToolBudgetState, Usage } from "../../shared/types.ts";
12
12
  import {
13
13
  acceptChildWatchdogEvent,
14
14
  applyChildWatchdogMessage,
@@ -22,9 +22,10 @@ import { projectChildLifecycle, type ChildLifecycleAction, type ChildLifecycleSt
22
22
  import { formatSubagentModelVerificationError } from "../shared/model-fallback.ts";
23
23
  import { isMutatingTool, resolveCurrentPath } from "../shared/long-running-guard.ts";
24
24
  import { effectiveToolTimeoutMs, formatToolTimeoutMessage, toolTimeoutCallKey } from "../shared/tool-timeout.ts";
25
- import type { InProcessChildLaunch } from "../shared/child-launch.ts";
25
+ import { createReportedChildSessionInput, type InProcessChildLaunch } from "../shared/child-launch.ts";
26
26
  import { projectChildSessionEventForJson, type ChildSession, type ChildSessionEvent, type ChildSessionFactory } from "../shared/child-session.ts";
27
27
  import { formatSteerMessage } from "../shared/subagent-prompt-runtime.ts";
28
+ import { getReadonlySessionEvidence, requestReadonlySessionEvidence, type SettledReadonlyEvidence } from "../shared/readonly-session-evidence.ts";
28
29
  import type { SteerDeliveryStatus, SteerRequest } from "./control-channel.ts";
29
30
 
30
31
  export interface ChildEventContext {
@@ -93,7 +94,18 @@ export interface RunChildSessionInput {
93
94
  runDeadlineAt?: number;
94
95
  expectedModelForVerification?: string;
95
96
  modelVerificationRegistry?: Array<{ provider: string; id: string; fullId: string }>;
97
+ modelResponseAliases?: Record<string, string[]>;
96
98
  mutationTools?: readonly string[];
99
+ /** Internal guarded continuation handoff; never part of persisted results. */
100
+ readonlyContinuation?: { source: ChildSession; expected: SettledReadonlyEvidence; modelId: string };
101
+ collectReadonlyEvidence?: boolean;
102
+ canContinue?: () => boolean;
103
+ }
104
+
105
+ const settledChildren = new WeakMap<RunChildSessionResult, ChildSession>();
106
+ export function getSettledReadonlyChild(result: RunChildSessionResult): ChildSession | undefined {
107
+ const child = settledChildren.get(result);
108
+ return child && getReadonlySessionEvidence(child) ? child : undefined;
97
109
  }
98
110
 
99
111
  export interface RunChildSessionResult {
@@ -122,7 +134,7 @@ export interface RunChildSessionResult {
122
134
  toolBudget?: ToolBudgetState;
123
135
  toolBudgetBlocked?: boolean;
124
136
  structuredOutput?: unknown;
125
- runtimeAcknowledgedExtensions?: RuntimeAcknowledgedChildExtensionsV1;
137
+ runtimeAcknowledgedExtensions?: RuntimeAcknowledgedChildExtensions;
126
138
  abortRecoveryDiagnostic?: string;
127
139
  effects?: EffectsProjection;
128
140
  }
@@ -468,11 +480,15 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
468
480
  if (event.message.model) {
469
481
  model = event.message.model;
470
482
  if (input.expectedModelForVerification && !hasToolCall) {
471
- const modelVerificationError = formatSubagentModelVerificationError(input.expectedModelForVerification, event.message.model, input.modelVerificationRegistry);
483
+ const modelVerificationError = formatSubagentModelVerificationError(input.expectedModelForVerification, event.message.model, input.modelVerificationRegistry, input.modelResponseAliases);
472
484
  if (modelVerificationError && !error) error = modelVerificationError;
473
485
  }
474
486
  }
475
487
  if (event.message.errorMessage) assistantError = event.message.errorMessage;
488
+ else if (hasToolCall && event.message.stopReason === "toolUse") {
489
+ // A recovered request can finish via a terminating tool, without a text stop.
490
+ assistantError = undefined;
491
+ }
476
492
  const eventUsage = event.message.usage;
477
493
  if (eventUsage) {
478
494
  usage.turns++;
@@ -532,29 +548,33 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
532
548
  : interrupted || (forcedDrainAfterFinalSuccess && !forcedDrainAfterEmptyTerminal)
533
549
  ? 0
534
550
  : finalError || promptError !== undefined ? 1 : 0;
535
- void closed.then(() => resolve(omitUndefined({
536
- exitCode,
537
- messages,
538
- usage,
539
- toolCount,
540
- durationMs: Date.now() - startedAt,
541
- model,
542
- error: stopped ? stopMessage() : timedOut ? (error ?? timeoutMessage()) : interrupted || (forcedDrainAfterFinalSuccess && !forcedDrainAfterEmptyTerminal) ? undefined : finalError,
543
- finalOutput: (timedOut || stopped) && !finalOutput.trim() ? (stopped ? stopMessage() : error ?? timeoutMessage()) : finalOutput,
544
- outputState: finalOutput.trim() ? "present" : "absent",
545
- interrupted: interrupted || undefined,
546
- timedOut: timedOut || undefined,
547
- stopped: stopped || undefined,
548
- observedMutationAttempt,
549
- structuredOutputToolInvoked,
550
- structuredOutputMessageStartIndex,
551
- watchdog: childWatchdogState,
552
- sessionFile: session?.sessionFile,
553
- currentTool,
554
- currentToolArgs,
555
- currentPath,
556
- afterCompactionSettlement: afterCompactionSettlement || undefined,
557
- })));
551
+ void closed.then(() => {
552
+ const result: RunChildSessionResult = omitUndefined({
553
+ exitCode,
554
+ messages,
555
+ usage,
556
+ toolCount,
557
+ durationMs: Date.now() - startedAt,
558
+ model,
559
+ error: stopped ? stopMessage() : timedOut ? (error ?? timeoutMessage()) : interrupted || (forcedDrainAfterFinalSuccess && !forcedDrainAfterEmptyTerminal) ? undefined : finalError,
560
+ finalOutput: (timedOut || stopped) && !finalOutput.trim() ? (stopped ? stopMessage() : error ?? timeoutMessage()) : finalOutput,
561
+ outputState: finalOutput.trim() ? "present" : "absent",
562
+ interrupted: interrupted || undefined,
563
+ timedOut: timedOut || undefined,
564
+ stopped: stopped || undefined,
565
+ observedMutationAttempt,
566
+ structuredOutputToolInvoked,
567
+ structuredOutputMessageStartIndex,
568
+ watchdog: childWatchdogState,
569
+ sessionFile: session?.sessionFile,
570
+ currentTool,
571
+ currentToolArgs,
572
+ currentPath,
573
+ afterCompactionSettlement: afterCompactionSettlement || undefined,
574
+ });
575
+ if (session && !forced && !forcedTermination && !interrupted && !timedOut && !stopped && getReadonlySessionEvidence(session)) settledChildren.set(result, session);
576
+ resolve(result);
577
+ });
558
578
  };
559
579
 
560
580
  input.registerInterrupt?.(() => {
@@ -574,17 +594,25 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
574
594
 
575
595
  void (async () => {
576
596
  try {
577
- const created = await input.factory.create({
578
- ...input.launch.session,
579
- onExtensionError: (extensionError) => {
580
- input.transcriptWriter?.writeStderrLine(`Extension error (${extensionError.extensionPath}, ${extensionError.event}): ${extensionError.error instanceof Error ? extensionError.error.message : String(extensionError.error)}`);
581
- },
582
- });
597
+ const continuation = input.readonlyContinuation;
598
+ const checkContinuation = () => {
599
+ if (!continuation) return;
600
+ if (interrupted || timedOut || stopped || input.canContinue?.() !== true
601
+ || (input.runDeadlineAt !== undefined && Date.now() >= input.runDeadlineAt)
602
+ || getReadonlySessionEvidence(continuation.source) !== continuation.expected
603
+ || continuation.source.detached || continuation.source.shutDown) throw new Error("Read-only continuation handoff vetoed");
604
+ };
605
+ checkContinuation();
606
+ const createInput = createReportedChildSessionInput(input.launch, input.transcriptWriter);
607
+ if (input.collectReadonlyEvidence || continuation) requestReadonlySessionEvidence(createInput, continuation?.expected);
608
+ const created = await input.factory.create(createInput);
583
609
  if (settled) {
584
610
  void created.dispose();
585
611
  return;
586
612
  }
587
613
  session = created;
614
+ checkContinuation();
615
+ if (continuation && (created.modelId !== continuation.modelId || input.launch.capture.completionIntentContext?.()?.model?.api !== continuation.expected.api)) throw new Error("Read-only continuation model changed");
588
616
  unsubscribe = created.subscribe(processEvent);
589
617
  input.registerWatchdogStatus?.((event) => processEvent(event as unknown as ChildSessionEvent));
590
618
  input.registerSteer?.(async (request) => {
@@ -601,6 +629,7 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
601
629
  : { state: "delivered", deliveryStatus: "delivered", message: "Pi accepted the steering input." };
602
630
  });
603
631
  if (interrupted || timedOut || stopped) abortChild();
632
+ checkContinuation();
604
633
  await created.prompt(input.prompt);
605
634
  promptSettled = true;
606
635
  settle(undefined);
@@ -2,12 +2,14 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
4
4
  import { safeTerminalText } from "../../shared/display-text.ts";
5
+ import { getArtifactPaths, getArtifactsDir } from "../../shared/artifacts.ts";
6
+ import { readFleetTranscript } from "../../tui/fleet-transcript.ts";
5
7
  import { formatAsyncRunList, formatAsyncRunOutputPath, formatAsyncRunProgressLabel, formatWorkflowStageLine, listAsyncRuns } from "./async-status.ts";
6
8
  import { formatAsyncResultTranscript, formatAsyncRunTranscript, formatNestedRunTranscript, inspectSubagentFleet } from "./fleet-view.ts";
7
9
  import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
8
10
  import { formatModelThinking } from "../../shared/formatters.ts";
9
11
  import { formatActivityLabel } from "../../shared/status-format.ts";
10
- import { DIRS, type AsyncStatus, type Details, type ForegroundResumeRun, type NestedRunSummary, type SteeringStatus, type SubagentState } from "../../shared/types.ts";
12
+ import { DIRS, type AsyncStatus, type Details, type ForegroundRunControl, type ForegroundResumeRun, type NestedRunSummary, type SteeringStatus, type SubagentState } from "../../shared/types.ts";
11
13
  import { inspectActiveAsyncCapacityOwner, type ActiveAsyncCapacityInspection } from "./active-async-capacity.ts";
12
14
  import { readStatus } from "../../shared/utils.ts";
13
15
  import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
@@ -88,6 +90,7 @@ function formatRunLifecycleDebug(input: { status: AsyncStatus; asyncDir: string;
88
90
  `Run: ${status.runId}`,
89
91
  `Dir: ${asyncDir}`,
90
92
  `Status file: ${path.join(asyncDir, "status.json")}`,
93
+ status.workflowReceiptPath ? `Workflow receipt: ${status.workflowReceiptPath}` : undefined,
91
94
  `Process terminal file: ${path.join(asyncDir, "process-terminal.json")}`,
92
95
  `Session: ${status.sessionId ?? "unknown"}`,
93
96
  `State: ${status.state}`,
@@ -241,6 +244,54 @@ function formatRememberedForegroundStatus(run: ForegroundResumeRun): string {
241
244
  return lines.join("\n");
242
245
  }
243
246
 
247
+ /** Request-only snapshot of the same artifact used by Fleet; no session or output fallback. */
248
+ function formatLiveForegroundTranscript(control: ForegroundRunControl, state: SubagentState, options: { index?: number; lines?: number }): string {
249
+ if (!state.currentSessionId || control.sessionId !== state.currentSessionId) {
250
+ throw new Error(`Foreground run '${control.runId}' is not owned by the current session.`);
251
+ }
252
+ if (options.index !== undefined && (!Number.isInteger(options.index) || options.index < 0)) {
253
+ throw new Error("Transcript index must be a non-negative integer.");
254
+ }
255
+ const children = control.activeChildren
256
+ ? [...control.activeChildren.values()]
257
+ : control.currentAgent ? [{ index: control.currentIndex ?? 0, agent: control.currentAgent, sessionName: control.sessionName }] : [];
258
+ const header = [`Run: ${control.runId}`, "State: live foreground"];
259
+ if (children.length === 0) return `${header.map((line) => safeTerminalText(line)).join("\n")}\nTranscript unavailable: no active foreground child.`;
260
+ if (options.index === undefined && children.length > 1) {
261
+ throw new Error(`Transcript view requires index for foreground run '${control.runId}'. Active child indexes: ${children.map((child) => child.index).join(", ")}.`);
262
+ }
263
+ const child = options.index === undefined ? children[0]! : children.find((child) => child.index === options.index);
264
+ if (!child) throw new Error(`Transcript index ${options.index} is not an active foreground child of '${control.runId}'.`);
265
+ const root = getArtifactsDir(state.parentSessionFile ?? null, control.cwd ?? state.baseCwd, state.artifactDirPreference);
266
+ const transcriptPath = getArtifactPaths(root, control.runId, child.agent, child.index).transcriptPath;
267
+ const transcript = readFleetTranscript(transcriptPath, { trustedRoots: [root] });
268
+ const lineLimit = Number.isFinite(options.lines) ? Math.max(1, Math.min(500, Math.trunc(options.lines!))) : 80;
269
+ const allLines = transcript.events.flatMap((event) => {
270
+ const text = event.kind === "tool"
271
+ ? [`Tool: ${event.name} (${event.status})`, event.argsPayload ?? event.args, event.output ?? event.error].filter(Boolean).join("\n")
272
+ : event.kind === "notice" ? event.text : `${event.kind === "assistant" ? "Assistant" : "Supervisor"}: ${event.text}`;
273
+ return text.split(/\r?\n/);
274
+ });
275
+ let body = allLines.slice(-lineLimit).join("\n");
276
+ let truncated = transcript.truncated || allLines.length > lineLimit
277
+ || transcript.events.some((event) => event.kind === "tool" && event.outputTruncated)
278
+ || body.includes("… message truncated");
279
+ const maxOutputBytes = 32 * 1024;
280
+ const bytes = Buffer.from(body);
281
+ if (bytes.length > maxOutputBytes) {
282
+ // Skip UTF-8 continuation bytes at the beginning of the bounded tail.
283
+ let start = bytes.length - maxOutputBytes;
284
+ while ((bytes[start]! & 0xc0) === 0x80) start++;
285
+ body = bytes.subarray(start).toString("utf-8");
286
+ truncated = true;
287
+ }
288
+ header.push(`Child: ${child.index} (${child.sessionName?.trim() || child.agent})`, `Transcript: ${transcriptPath}`);
289
+ if (transcript.warning) header.push(`Transcript warning: ${transcript.warning}`);
290
+ header.push(`Live transcript tail${truncated ? " (tail truncated)" : ""}:`);
291
+ if (!body) header.push("Transcript unavailable: no readable activity in the bounded artifact tail yet.");
292
+ return [...header.map((line) => safeTerminalText(line)), body].filter(Boolean).join("\n");
293
+ }
294
+
244
295
  function formatRememberedForegroundTranscript(run: ForegroundResumeRun, options: { index?: number; lines?: number }): string {
245
296
  let index = options.index;
246
297
  if (index !== undefined && !Number.isInteger(index)) throw new Error("Transcript index must be an integer.");
@@ -313,6 +364,12 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
313
364
  return inspectSubagentFleet(params, { asyncDirRoot, resultsDir, kill: deps.kill, now: deps.now, state: deps.state, childSafe: Boolean(deps.nested) });
314
365
  }
315
366
  if (!params.id && !params.runId && !params.dir) {
367
+ if (params.view === "transcript" && deps.state?.currentSessionId) {
368
+ const controls = [...deps.state.foregroundControls.values()].filter((control) => control.sessionId === currentSessionId);
369
+ const foreground = controls.find((control) => control.runId === deps.state?.lastForegroundControlId)
370
+ ?? controls.sort((left, right) => right.updatedAt - left.updatedAt)[0];
371
+ if (foreground) return inspectSubagentStatus({ ...params, id: foreground.runId }, deps);
372
+ }
316
373
  if (deps.nested) {
317
374
  return {
318
375
  content: [{ type: "text", text: "Child-safe subagent status requires an id when no foreground run is active." }],
@@ -353,6 +410,13 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
353
410
  } else if (!params.dir && requestedId) {
354
411
  const resolved = resolveSubagentRunId(requestedId, { asyncDirRoot, resultsDir, state: deps.state, nested: deps.nested });
355
412
  if (resolved?.kind === "foreground") {
413
+ const control = deps.state?.foregroundControls.get(resolved.id);
414
+ if (control && deps.state && params.view === "transcript") {
415
+ return {
416
+ content: [{ type: "text", text: formatLiveForegroundTranscript(control, deps.state, params) }],
417
+ details: { mode: "management", results: [] },
418
+ };
419
+ }
356
420
  const run = deps.state?.foregroundRuns?.get(resolved.id);
357
421
  if (run) {
358
422
  try {
@@ -423,7 +487,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
423
487
  const capacity = inspectActiveAsyncCapacityOwner({ runId: diskStatus.runId, sessionId: diskStatus.sessionId, asyncDir }, { rootDir: deps.activeCapacityRoot, liveWorkflowRunIds: new Set(deps.state?.workflowControllers?.keys() ?? []), abandonedSlotReleaseAfterMs: deps.abandonedSlotReleaseAfterMs });
424
488
  return {
425
489
  content: [{ type: "text", text: formatRunLifecycleDebug({ status: diskStatus, asyncDir, sidecarProcessTerminal: sidecar, overlayProcessTerminal: overlay, capacity }) }],
426
- details: { mode: "single", results: [], ...((sidecar ?? overlay) ? { lifecycleStatus: { processTerminal: sidecar ?? overlay } } : {}) },
490
+ details: { mode: "single", results: [], ...(diskStatus.workflowReceiptPath ? { workflowReceiptPath: diskStatus.workflowReceiptPath } : {}), ...((sidecar ?? overlay) ? { lifecycleStatus: { processTerminal: sidecar ?? overlay } } : {}) },
427
491
  };
428
492
  }
429
493
  if (params.view === "transcript") {
@@ -450,7 +514,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
450
514
  const capacity = inspectActiveAsyncCapacityOwner({ runId: status.runId, sessionId: status.sessionId, asyncDir }, { rootDir: deps.activeCapacityRoot, liveWorkflowRunIds: new Set(deps.state?.workflowControllers?.keys() ?? []), abandonedSlotReleaseAfterMs: deps.abandonedSlotReleaseAfterMs });
451
515
  return {
452
516
  content: [{ type: "text", text: formatRunLifecycleDebug({ status, asyncDir, sidecarProcessTerminal: sidecar, overlayProcessTerminal: overlay, capacity }) }],
453
- details: { mode: "single", results: [], ...((sidecar ?? overlay) ? { lifecycleStatus: { processTerminal: sidecar ?? overlay } } : {}) },
517
+ details: { mode: "single", results: [], ...(status.workflowReceiptPath ? { workflowReceiptPath: status.workflowReceiptPath } : {}), ...((sidecar ?? overlay) ? { lifecycleStatus: { processTerminal: sidecar ?? overlay } } : {}) },
454
518
  };
455
519
  }
456
520
  if (params.view === "transcript") {
@@ -626,6 +690,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
626
690
  }
627
691
  }
628
692
  if (nestedWarning) lines.push(`Warning: ${nestedWarning}`);
693
+ if (status.workflowReceiptPath) lines.push(`Workflow receipt: ${status.workflowReceiptPath}`);
629
694
  if (status.sessionFile) lines.push(`Session: ${status.sessionFile}`);
630
695
  const allExternal = (status.steps?.length ?? 0) > 0 && status.steps!.every((step) => step.runner?.type === "external-cli" || step.runner?.type === "external-job");
631
696
  if (status.state === "running" && !allExternal && status.mode !== "workflow") lines.push(`Steer running child: subagent({ action: "steer", id: "${status.runId}", message: "..." })`);
@@ -639,7 +704,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
639
704
 
640
705
  const workflowChildren = parseWorkflowChildSummary(status.workflowChildren);
641
706
  if (workflowChildren && workflowChildren.workflowRunId !== status.runId) throw new Error("workflowChildren.workflowRunId does not match async status runId.");
642
- return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [], ...(status.preflight ? { preflight: status.preflight } : {}), ...(status.workflow?.preflightWarnings?.length ? { preflightWarnings: status.workflow.preflightWarnings } : {}), ...(workflowChildren ? { workflowChildren } : {}), ...(runFanoutBudget ? { runFanoutBudget } : {}), ...(processTerminal ? { lifecycleStatus: { processTerminal } } : {}) } };
707
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [], ...(status.workflowReceiptPath ? { workflowReceiptPath: status.workflowReceiptPath } : {}), ...(status.preflight ? { preflight: status.preflight } : {}), ...(status.workflow?.preflightWarnings?.length ? { preflightWarnings: status.workflow.preflightWarnings } : {}), ...(workflowChildren ? { workflowChildren } : {}), ...(runFanoutBudget ? { runFanoutBudget } : {}), ...(processTerminal ? { lifecycleStatus: { processTerminal } } : {}) } };
643
708
  }
644
709
  }
645
710
 
@@ -680,6 +745,11 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
680
745
  const runId = data.runId ?? data.id ?? resolvedId;
681
746
  const lines = [`Run: ${runId}`, data.toolCallId ? `Tool call: ${data.toolCallId}` : undefined, `State: ${status}`, `Result: ${resultPath}`].filter((line): line is string => Boolean(line));
682
747
  if (data.parallelHandoff?.path) lines.push(`Parallel handoff: ${data.parallelHandoff.path}`);
748
+ const receipt = (data as Record<string, unknown>).workflowReceipt;
749
+ const receiptPath = receipt && typeof receipt === "object" && !Array.isArray(receipt)
750
+ ? (receipt as Record<string, unknown>).path : undefined;
751
+ const workflowReceiptPath = typeof receiptPath === "string" && receiptPath ? receiptPath : undefined;
752
+ if (workflowReceiptPath) lines.push(`Workflow receipt: ${workflowReceiptPath}`);
683
753
  const children = Array.isArray(data.results) ? data.results : data.agent ? [{ agent: data.agent, sessionFile: data.sessionFile }] : [];
684
754
  lines.push(...formatTimeoutRecoveryLines(data.timeoutRecovery, " "));
685
755
  for (const [index, child] of children.entries()) {
@@ -694,7 +764,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
694
764
  if (data.summary) lines.push("", data.summary);
695
765
  const workflowChildren = parseWorkflowChildSummary((data as unknown as Record<string, unknown>).workflowChildren);
696
766
  if (workflowChildren && workflowChildren.workflowRunId !== runId) throw new Error("workflowChildren.workflowRunId does not match the result run id.");
697
- return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [], ...(workflowChildren ? { workflowChildren } : {}) } };
767
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [], ...(workflowReceiptPath ? { workflowReceiptPath } : {}), ...(workflowChildren ? { workflowChildren } : {}) } };
698
768
  } catch (error) {
699
769
  const message = error instanceof Error ? error.message : String(error);
700
770
  return {