pi-subagents 0.65.1 → 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 (85) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +1 -1
  3. package/agents/researcher.md +23 -13
  4. package/docs/agents.md +16 -2
  5. package/docs/configuration.md +18 -0
  6. package/docs/extension-api.md +91 -0
  7. package/docs/models.md +58 -1
  8. package/docs/observability.md +42 -2
  9. package/docs/tool-reference.md +3 -3
  10. package/docs/workflows.md +14 -7
  11. package/package.json +2 -1
  12. package/skills/pi-subagents/references/execution-controls.md +14 -1
  13. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
  14. package/src/agents/advertised-agent-prompt.ts +63 -0
  15. package/src/agents/agent-management.ts +14 -1
  16. package/src/agents/agent-serializer.ts +2 -0
  17. package/src/agents/agents.ts +8 -0
  18. package/src/api/shared-types.ts +1 -1
  19. package/src/api/workflow-resources.ts +6 -0
  20. package/src/extension/index.ts +40 -2
  21. package/src/extension/public-execution.ts +0 -1
  22. package/src/extension/rpc.ts +4 -21
  23. package/src/extension/schemas.ts +8 -6
  24. package/src/extension/tool-description.ts +6 -5
  25. package/src/intercom/native-supervisor-channel.ts +82 -54
  26. package/src/runs/background/active-async-capacity.ts +18 -18
  27. package/src/runs/background/async-job-tracker.ts +35 -3
  28. package/src/runs/background/async-status-snapshot.ts +10 -12
  29. package/src/runs/background/async-status.ts +17 -9
  30. package/src/runs/background/auto-drain.ts +40 -29
  31. package/src/runs/background/chain-root-attachment.ts +8 -0
  32. package/src/runs/background/control-channel.ts +78 -44
  33. package/src/runs/background/notify.ts +86 -12
  34. package/src/runs/background/owned-process-tree.ts +6 -6
  35. package/src/runs/background/process-terminal.ts +23 -23
  36. package/src/runs/background/run-child-session.ts +60 -32
  37. package/src/runs/background/run-status.ts +75 -5
  38. package/src/runs/background/runner-aliases.ts +18 -7
  39. package/src/runs/background/runner-child-launch.ts +86 -0
  40. package/src/runs/background/stale-run-reconciler.ts +3 -1
  41. package/src/runs/background/subagent-runner.ts +412 -209
  42. package/src/runs/background/subagent-wait.ts +3 -0
  43. package/src/runs/background/wait-completions.ts +4 -0
  44. package/src/runs/foreground/async-steering-action.ts +19 -0
  45. package/src/runs/foreground/execution.ts +101 -25
  46. package/src/runs/foreground/subagent-executor.ts +474 -197
  47. package/src/runs/foreground/workflow-detach-reconcile.ts +8 -5
  48. package/src/runs/foreground/workflow-foreground-steering.ts +56 -2
  49. package/src/runs/shared/acceptance.ts +2 -2
  50. package/src/runs/shared/agent-contract.ts +1 -1
  51. package/src/runs/shared/async-status-projection.ts +47 -47
  52. package/src/runs/shared/child-hooks.ts +151 -2
  53. package/src/runs/shared/child-launch.ts +18 -13
  54. package/src/runs/shared/child-session.ts +42 -6
  55. package/src/runs/shared/child-tool-plan.ts +2 -2
  56. package/src/runs/shared/completion-evidence.ts +2 -2
  57. package/src/runs/shared/completion-guard.ts +1 -0
  58. package/src/runs/shared/host-step-status.ts +11 -11
  59. package/src/runs/shared/llm-intent-arbiter.ts +10 -9
  60. package/src/runs/shared/model-fallback.ts +10 -6
  61. package/src/runs/shared/nested-events.ts +5 -5
  62. package/src/runs/shared/orca-progress-tabs.ts +6 -0
  63. package/src/runs/shared/parallel-handoff.ts +57 -12
  64. package/src/runs/shared/parallel-utils.ts +2 -2
  65. package/src/runs/shared/readonly-drain-observation.ts +42 -0
  66. package/src/runs/shared/readonly-model-continuation.ts +69 -0
  67. package/src/runs/shared/readonly-session-evidence.ts +307 -0
  68. package/src/runs/shared/run-fanout-budget.ts +8 -8
  69. package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
  70. package/src/runs/shared/subagent-prompt-runtime.ts +13 -3
  71. package/src/runs/shared/worktree-setup-command.ts +190 -0
  72. package/src/runs/shared/worktree.ts +332 -204
  73. package/src/shared/types.ts +81 -60
  74. package/src/shared/utils.ts +7 -2
  75. package/src/shared/workflow-child-permit.ts +18 -13
  76. package/src/tui/fleet.ts +11 -5
  77. package/src/tui/render.ts +23 -5
  78. package/src/workflows/chat-progress.ts +3 -3
  79. package/src/workflows/scripted-workflow.ts +38 -10
  80. package/src/workflows/workflow-checklist.ts +11 -15
  81. package/src/workflows/workflow-child-summary.ts +57 -8
  82. package/src/workflows/workflow-preflight.ts +19 -19
  83. package/src/workflows/workflow-receipt.ts +3 -3
  84. package/src/workflows/workflow-resources.ts +96 -21
  85. package/src/workflows/workflow-settlement.ts +3 -0
@@ -6,12 +6,13 @@
6
6
  */
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
10
  import type { ChildWatchdogConfig, ChildWatchdogStatusEvent } from "../../watchdog/child-status.ts";
10
11
  import type { ThinkingLevel } from "../../shared/model-info.ts";
11
12
  import { intersectThinkingCeilings } from "../../shared/thinking-ceiling.ts";
12
13
  import {
13
14
  resolveChildDepth,
14
- type LaunchResolvedChildExtensionsV1,
15
+ type LaunchResolvedChildExtensions,
15
16
  type ResolvedToolBudget,
16
17
  type RunFanoutBudgetDescriptor,
17
18
  } from "../../shared/types.ts";
@@ -20,8 +21,7 @@ import type { McpRuntimeSnapshotHost } from "./mcp-direct-tool-allowlist.ts";
20
21
  import type { PermissionRules } from "./permissions.ts";
21
22
  import type { StructuredOutputRuntime } from "./structured-output.ts";
22
23
  import type { ChildToolDiagnostic } from "./tool-availability.ts";
23
- import type { RuntimeAcknowledgedChildExtensionsV1 } from "../../shared/types.ts";
24
- import { projectRuntimeAcknowledgedExtensions } from "./runtime-acknowledged-extensions.ts";
24
+ import type { RuntimeAcknowledgedChildExtensions } from "../../shared/types.ts";
25
25
  import { encodeExtensionBindings, PI_SUBAGENT_EXTENSION_BINDINGS_ENV, type ExtensionBindings } from "./extension-bindings.ts";
26
26
  import type { ResolvedSubagentCapabilityCeiling, SubagentCapabilityAudit } from "./capability-ceiling.ts";
27
27
  import {
@@ -32,7 +32,8 @@ import {
32
32
  type PiLaunchToolPlan,
33
33
  } from "./child-tool-plan.ts";
34
34
  import type { ChildRuntimeConfig } from "./child-runtime-config.ts";
35
- import { createChildHooks } from "./child-hooks.ts";
35
+ import { createCapturedChildHooks, withChildSessionErrorReporting } from "./child-hooks.ts";
36
+ import type { ChildTranscriptWriter } from "../../shared/child-transcript.ts";
36
37
  import type { ChildSessionLaunch, ChildSessionStorage } from "./child-session.ts";
37
38
 
38
39
  /** Environment variable pi-mcp-adapter reads for the tools a child may expose. */
@@ -114,9 +115,10 @@ export interface BuildInProcessChildLaunchInput {
114
115
  }
115
116
 
116
117
  export interface InProcessChildCapture {
118
+ completionIntentContext?(): Pick<ExtensionContext, "model" | "modelRegistry"> | undefined;
117
119
  structuredOutput(): { called: boolean; value?: unknown; acceptanceReport?: unknown; acceptanceReportProvided: boolean };
118
120
  toolDiagnostic(): ChildToolDiagnostic | undefined;
119
- runtimeAcknowledgedExtensions(): RuntimeAcknowledgedChildExtensionsV1 | undefined;
121
+ runtimeAcknowledgedExtensions(): RuntimeAcknowledgedChildExtensions | undefined;
120
122
  }
121
123
 
122
124
  export interface InProcessChildLaunch {
@@ -124,11 +126,16 @@ export interface InProcessChildLaunch {
124
126
  config: ChildRuntimeConfig;
125
127
  session: Omit<ChildSessionLaunch, "onExtensionError">;
126
128
  capture: InProcessChildCapture;
127
- launchResolvedExtensions: LaunchResolvedChildExtensionsV1;
129
+ launchResolvedExtensions: LaunchResolvedChildExtensions;
128
130
  warnings: string[];
129
131
  capabilityAudit?: SubagentCapabilityAudit;
130
132
  }
131
133
 
134
+ /** Actual host create-input boundary. Evidence remains explicitly opt-in and dormant in production. */
135
+ export function createReportedChildSessionInput(launch: InProcessChildLaunch, transcriptWriter?: ChildTranscriptWriter): ChildSessionLaunch {
136
+ return withChildSessionErrorReporting(launch.session, transcriptWriter);
137
+ }
138
+
132
139
  /** Escape XML-significant characters in a string for safe attribute interpolation. */
133
140
  function escapeXmlAttr(value: string): string {
134
141
  return value
@@ -214,8 +221,6 @@ export function buildInProcessChildLaunch(input: BuildInProcessChildLaunchInput)
214
221
  let structuredAcceptanceReport: unknown;
215
222
  let structuredCalled = false;
216
223
  let structuredAcceptanceProvided = false;
217
- let toolDiagnostic: ChildToolDiagnostic | undefined;
218
- let acknowledgedIds: string[] | undefined;
219
224
 
220
225
  const config: ChildRuntimeConfig = {
221
226
  ...(input.runId ? { runId: input.runId } : {}),
@@ -264,10 +269,9 @@ export function buildInProcessChildLaunch(input: BuildInProcessChildLaunchInput)
264
269
  : {}),
265
270
  ...(toolPlan.requiredChildTools.length > 0 ? { requiredTools: toolPlan.requiredChildTools } : {}),
266
271
  ...(toolPlan.effectiveMcpTools.length > 0 ? { mcpDirectTools: toolPlan.effectiveMcpTools } : {}),
267
- toolDiagnostic: (diagnostic) => { toolDiagnostic = diagnostic; },
268
- runtimeAcknowledgements: (ids) => { acknowledgedIds = ids; },
269
272
  fast: input.fast === true,
270
273
  };
274
+ const capturedHooks = createCapturedChildHooks(config, input.host === "runner");
271
275
 
272
276
  const extensionPaths = toolPlan.extensionArgs.filter((extensionPath) => !isSubagentRuntimeExtensionPath(extensionPath));
273
277
  const ambientExtensions = input.host === "runner" && !toolPlan.disableAmbientExtensions;
@@ -288,7 +292,7 @@ export function buildInProcessChildLaunch(input: BuildInProcessChildLaunchInput)
288
292
  ...(!toolPlan.explicitToolAllowlist && toolPlan.excludeTools.length > 0 ? { excludeTools: toolPlan.excludeTools } : {}),
289
293
  extensionPaths,
290
294
  ambientExtensions,
291
- hooks: createChildHooks(config),
295
+ hooks: capturedHooks.hooks,
292
296
  ...(input.host === "runner" ? { processEnv: childProcessEnv(input, toolPlan) } : {}),
293
297
  runtime: config,
294
298
  noSkills: !input.inheritSkills,
@@ -303,9 +307,10 @@ export function buildInProcessChildLaunch(input: BuildInProcessChildLaunchInput)
303
307
  config,
304
308
  session,
305
309
  capture: {
310
+ completionIntentContext: capturedHooks.completionIntentContext,
306
311
  structuredOutput: () => ({ called: structuredCalled, value: structuredValue, acceptanceReport: structuredAcceptanceReport, acceptanceReportProvided: structuredAcceptanceProvided }),
307
- toolDiagnostic: () => toolDiagnostic,
308
- runtimeAcknowledgedExtensions: () => (acknowledgedIds ? projectRuntimeAcknowledgedExtensions(acknowledgedIds) : undefined),
312
+ toolDiagnostic: capturedHooks.toolDiagnostic,
313
+ runtimeAcknowledgedExtensions: capturedHooks.runtimeAcknowledgedExtensions,
309
314
  },
310
315
  launchResolvedExtensions,
311
316
  warnings: toolPlan.warnings,
@@ -12,6 +12,14 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import { getAgentDir } from "../../shared/utils.ts";
14
14
  import type { ChildRuntimeConfig } from "./child-runtime-config.ts";
15
+ import { prepareReadonlySessionEvidence } from "./readonly-session-evidence.ts";
16
+ import { toModelInfo, type ModelInfo } from "../../shared/model-info.ts";
17
+
18
+ // Private runtime authority for host continuation planning; injected factories have none.
19
+ const readonlyModels = new WeakMap<ChildSession, { current: ModelInfo; resolve(reference: string): ModelInfo | undefined; requestBytes: number }>();
20
+ export function getReadonlyChildModels(child: ChildSession) {
21
+ return readonlyModels.get(child);
22
+ }
15
23
 
16
24
  export interface ChildSessionEvent {
17
25
  type: string;
@@ -185,6 +193,7 @@ export function createDefaultChildSessionFactory(options: DefaultChildSessionFac
185
193
  };
186
194
  return {
187
195
  async create(launch) {
196
+ const observeReadonly = prepareReadonlySessionEvidence(launch);
188
197
  const pi = await loadPiCodingAgent();
189
198
  const modelRuntime = await sharedRuntime(pi);
190
199
  const agentDir = getAgentDir();
@@ -208,8 +217,11 @@ export function createDefaultChildSessionFactory(options: DefaultChildSessionFac
208
217
  const open = async () => {
209
218
  applyProcessEnv(launch.processEnv);
210
219
  if (!resetExtensionCacheOnReload(loader) && (launch.ambientExtensions || launch.extensionPaths.length)) launch.onExtensionError?.({ extensionPath: "<loader>", event: "load", error: new Error("pi's extension cache reset is unavailable; extensions loaded into this child share module state with other sessions in this process.") });
211
- await loader.reload();
220
+ observeReadonly?.loadingHooks(true);
221
+ try { await loader.reload(); } finally { observeReadonly?.loadingHooks(false); }
212
222
  await flushQueuedProviderRegistrations(loader, modelRuntime, launch.onExtensionError);
223
+ // No await between receipt validation and the SDK's permissive file open.
224
+ observeReadonly?.beforeOpen();
213
225
  const sessionManager = launch.storage.kind === "file"
214
226
  ? pi.SessionManager.open(launch.storage.sessionFile, undefined, launch.cwd)
215
227
  : launch.storage.kind === "dir"
@@ -217,6 +229,7 @@ export function createDefaultChildSessionFactory(options: DefaultChildSessionFac
217
229
  : launch.storage.kind === "memory"
218
230
  ? pi.SessionManager.inMemory(launch.cwd)
219
231
  : pi.SessionManager.create(launch.cwd);
232
+ observeReadonly?.opened(sessionManager);
220
233
  const resolvedModel = launch.model
221
234
  ? pi.resolveCliModel({ cliModel: launch.model, modelRuntime })
222
235
  : undefined;
@@ -248,6 +261,9 @@ export function createDefaultChildSessionFactory(options: DefaultChildSessionFac
248
261
  const opened = loading.catch(() => {}).then(open);
249
262
  loading = opened;
250
263
  const session = await opened;
264
+ let evidence: ReturnType<NonNullable<typeof observeReadonly>["observe"]>;
265
+ try { evidence = observeReadonly?.observe(pi, modelRuntime, session); }
266
+ catch (error) { session.dispose(); throw error; }
251
267
  let pending: Promise<void> | undefined;
252
268
  // pi's own hosts emit `session_shutdown` before disposing a session so the
253
269
  // extensions loaded into it (ambient extensions included) release their
@@ -255,19 +271,29 @@ export function createDefaultChildSessionFactory(options: DefaultChildSessionFac
255
271
  const shutdown = async (): Promise<void> => {
256
272
  try {
257
273
  const runner = session.extensionRunner;
258
- if (runner.hasHandlers("session_shutdown")) await Promise.race([runner.emit({ type: "session_shutdown", reason: "quit" }), new Promise((resolve) => setTimeout(resolve, shutdownTimeoutMs).unref?.())]);
274
+ if (runner.hasHandlers("session_shutdown")) {
275
+ evidence?.beforeShutdown();
276
+ const settled = await Promise.race([runner.emit({ type: "session_shutdown", reason: "quit" }).then(() => true), new Promise<false>((resolve) => setTimeout(() => resolve(false), shutdownTimeoutMs).unref?.())]);
277
+ if (!settled) evidence?.invalidate();
278
+ }
259
279
  } catch (error) {
280
+ evidence?.invalidate();
260
281
  launch.onExtensionError?.({ extensionPath: "<session>", event: "session_shutdown", error });
261
282
  } finally {
262
283
  session.dispose();
284
+ evidence?.finish(child);
263
285
  }
264
286
  };
265
287
  const child: ChildSession = {
266
288
  subscribe: (listener) => session.subscribe((event) => listener(event as unknown as ChildSessionEvent)),
267
- prompt: (text) => session.prompt(text),
268
- steer: (text) => session.steer(text),
269
- followUp: (text) => session.followUp(text),
270
- abort: () => session.abort(),
289
+ prompt: (text) => {
290
+ if (!evidence) return session.prompt(text);
291
+ try { evidence.start(); } catch (error) { return Promise.reject(error); }
292
+ return session.prompt(text).then(() => evidence?.settled(), (error) => { evidence?.invalidate(); throw error; });
293
+ },
294
+ steer: (text) => { evidence?.invalidate(); return session.steer(text); },
295
+ followUp: (text) => { evidence?.invalidate(); return session.followUp(text); },
296
+ abort: () => { evidence?.invalidate(); return session.abort(); },
271
297
  dispose: () => {
272
298
  if (!pending) {
273
299
  live.delete(child);
@@ -283,6 +309,16 @@ export function createDefaultChildSessionFactory(options: DefaultChildSessionFac
283
309
  get sessionId() { return session.sessionId; },
284
310
  get modelId() { return session.model ? `${session.model.provider}/${session.model.id}` : undefined; },
285
311
  };
312
+ if (evidence && session.model) readonlyModels.set(child, {
313
+ current: toModelInfo(session.model),
314
+ requestBytes: Buffer.byteLength(session.systemPrompt) + Buffer.byteLength(JSON.stringify(session.agent.state.tools)),
315
+ resolve(reference) {
316
+ try {
317
+ const resolved = pi.resolveCliModel({ cliModel: reference, modelRuntime });
318
+ return !resolved.error && resolved.model ? toModelInfo(resolved.model) : undefined;
319
+ } catch { return undefined; }
320
+ },
321
+ });
286
322
  live.add(child);
287
323
  return child;
288
324
  },
@@ -16,7 +16,7 @@ import {
16
16
  import {
17
17
  TEMP_ROOT_DIR,
18
18
  type JsonSchemaObject,
19
- type LaunchResolvedChildExtensionsV1,
19
+ type LaunchResolvedChildExtensions,
20
20
  } from "../../shared/types.ts";
21
21
  import { THINKING_LEVELS } from "../../shared/model-info.ts";
22
22
  import { getAgentDir } from "../../shared/utils.ts";
@@ -214,7 +214,7 @@ export function projectLaunchResolvedChildExtensions(
214
214
  | "extensionArgs"
215
215
  | "disableAmbientExtensions"
216
216
  >,
217
- ): LaunchResolvedChildExtensionsV1 {
217
+ ): LaunchResolvedChildExtensions {
218
218
  const runtime = boundedExtensionIdentifiers(toolPlan.runtimeExtensions);
219
219
  const configured = boundedExtensionIdentifiers(toolPlan.configuredExtensions);
220
220
  const effective = boundedExtensionIdentifiers(toolPlan.extensionArgs);
@@ -26,7 +26,7 @@ export function planCompletionEvidence(input: {
26
26
  mutationAttemptObserved: boolean;
27
27
  mutationEvidence?: TrackedMutationEvidence;
28
28
  arbiterRescued?: boolean;
29
- agentContractV1: boolean;
29
+ agentContractEnabled: boolean;
30
30
  }): CompletionEvidencePlan {
31
31
  const guardBlocked = input.guard?.blocked === true;
32
32
  const guardTriggered = input.guardTriggered
@@ -59,7 +59,7 @@ export function planCompletionEvidence(input: {
59
59
  mutationExpected,
60
60
  mutationAttempted,
61
61
  fileMutation,
62
- legacyFailureError: guardTriggered && !input.agentContractV1
62
+ legacyFailureError: guardTriggered && !input.agentContractEnabled
63
63
  ? MISSING_IMPLEMENTATION_MUTATION_ERROR
64
64
  : undefined,
65
65
  };
@@ -13,6 +13,7 @@ const READ_ONLY_BUILTIN_TOOLS = new Set([
13
13
  "web_search",
14
14
  "fetch_content",
15
15
  "get_search_content",
16
+ "source_check",
16
17
  "intercom",
17
18
  "contact_supervisor",
18
19
  "structured_output",
@@ -1,6 +1,6 @@
1
1
  import type {
2
2
  AsyncStatus,
3
- HostStepNodeV1,
3
+ HostStepNode,
4
4
  HostStepState,
5
5
  HostStepVerdict,
6
6
  WorkflowGraphNode,
@@ -57,7 +57,7 @@ function assertTimestamp(value: unknown, field: string, source: string, required
57
57
  if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid host step '${source}': ${field} must be a non-negative safe integer.`);
58
58
  }
59
59
 
60
- function assertFreshness(value: unknown, source: string): asserts value is HostStepNodeV1["freshness"] {
60
+ function assertFreshness(value: unknown, source: string): asserts value is HostStepNode["freshness"] {
61
61
  if (!isRecord(value)) throw new Error(`Invalid host step '${source}': freshness must be an object.`);
62
62
  const unknownFields = Object.keys(value).filter((field) => !FRESHNESS_FIELDS.has(field));
63
63
  if (unknownFields.length > 0) throw new Error(`Invalid host step '${source}': freshness has unsupported fields: ${unknownFields.join(", ")}.`);
@@ -70,7 +70,7 @@ function assertFreshness(value: unknown, source: string): asserts value is HostS
70
70
  * Validate the persisted host-step contract. This deliberately rejects unknown
71
71
  * fields and unbounded values so status and receipt loaders fail closed.
72
72
  */
73
- export function assertHostStepNode(value: unknown, source = "status"): asserts value is HostStepNodeV1 {
73
+ export function assertHostStepNode(value: unknown, source = "status"): asserts value is HostStepNode {
74
74
  if (!isRecord(value)) throw new Error(`Invalid host step '${source}': expected an object.`);
75
75
  const unknownFields = Object.keys(value).filter((field) => !HOST_STEP_FIELDS.has(field));
76
76
  if (unknownFields.length > 0) throw new Error(`Invalid host step '${source}': unsupported fields: ${unknownFields.join(", ")}.`);
@@ -105,7 +105,7 @@ export function assertHostStepNode(value: unknown, source = "status"): asserts v
105
105
  assertTimestamp(value.deadlineAt, "deadlineAt", source);
106
106
  }
107
107
 
108
- export function parseHostStepNode(value: unknown, source = "status"): HostStepNodeV1 {
108
+ export function parseHostStepNode(value: unknown, source = "status"): HostStepNode {
109
109
  assertHostStepNode(value, source);
110
110
  return {
111
111
  ...value,
@@ -113,7 +113,7 @@ export function parseHostStepNode(value: unknown, source = "status"): HostStepNo
113
113
  };
114
114
  }
115
115
 
116
- export function assertUniqueHostStepIds(hostSteps: readonly HostStepNodeV1[], source = "status"): void {
116
+ export function assertUniqueHostStepIds(hostSteps: readonly HostStepNode[], source = "status"): void {
117
117
  const ids = new Set<string>();
118
118
  for (const hostStep of hostSteps) {
119
119
  if (ids.has(hostStep.id)) throw new Error(`Invalid host step '${source}': duplicate host step id '${hostStep.id}'.`);
@@ -122,11 +122,11 @@ export function assertUniqueHostStepIds(hostSteps: readonly HostStepNodeV1[], so
122
122
  }
123
123
 
124
124
  /** Return only valid host nodes so an untrusted in-memory projection fails closed. */
125
- export function validHostStepNodes(graph: WorkflowGraphSnapshot | undefined): HostStepNodeV1[] {
125
+ export function validHostStepNodes(graph: WorkflowGraphSnapshot | undefined): HostStepNode[] {
126
126
  const nodes = graph?.nodes ?? [];
127
127
  const nodeIdCounts = new Map<string, number>();
128
128
  for (const node of nodes) nodeIdCounts.set(node.id, (nodeIdCounts.get(node.id) ?? 0) + 1);
129
- const hostSteps: HostStepNodeV1[] = [];
129
+ const hostSteps: HostStepNode[] = [];
130
130
  for (const [index, node] of nodes.entries()) {
131
131
  if (node.kind !== "host-step") continue;
132
132
  try {
@@ -147,7 +147,7 @@ export function assertWorkflowGraphHostSteps(graph: WorkflowGraphSnapshot | unde
147
147
  if (!Array.isArray(graph.nodes)) throw new Error(`Invalid host step '${source}.workflowGraph': nodes must be an array.`);
148
148
  const hostStepCount = graph.nodes.filter((node) => node.kind === "host-step").length;
149
149
  if (hostStepCount > HOST_STEP_MAX_COUNT) throw new Error(`Invalid host step '${source}': workflowGraph contains more than ${HOST_STEP_MAX_COUNT} host steps.`);
150
- const hostSteps: HostStepNodeV1[] = [];
150
+ const hostSteps: HostStepNode[] = [];
151
151
  for (const [index, node] of graph.nodes.entries()) {
152
152
  if (node.kind !== "host-step") continue;
153
153
  const hostStep = parseHostStepNode(node.hostStep, `${source}.workflowGraph.nodes[${index}].hostStep`);
@@ -161,7 +161,7 @@ export function assertWorkflowGraphHostSteps(graph: WorkflowGraphSnapshot | unde
161
161
  }
162
162
  }
163
163
 
164
- export function hostStepWorkflowNode(hostStep: HostStepNodeV1): WorkflowGraphNode {
164
+ export function hostStepWorkflowNode(hostStep: HostStepNode): WorkflowGraphNode {
165
165
  assertHostStepNode(hostStep, hostStep.id);
166
166
  return {
167
167
  id: hostStep.id,
@@ -172,7 +172,7 @@ export function hostStepWorkflowNode(hostStep: HostStepNodeV1): WorkflowGraphNod
172
172
  };
173
173
  }
174
174
 
175
- function workflowNodeStatus(hostStep: HostStepNodeV1): WorkflowNodeStatus {
175
+ function workflowNodeStatus(hostStep: HostStepNode): WorkflowNodeStatus {
176
176
  if (hostStep.state === "pending") return "pending";
177
177
  if (hostStep.state === "running") return "running";
178
178
  if (hostStep.state === "cancelled") return "stopped";
@@ -187,7 +187,7 @@ function workflowNodeStatus(hostStep: HostStepNodeV1): WorkflowNodeStatus {
187
187
  */
188
188
  export function upsertHostStep(input: {
189
189
  status: AsyncStatus;
190
- hostStep: HostStepNodeV1;
190
+ hostStep: HostStepNode;
191
191
  persist: (status: AsyncStatus) => void;
192
192
  }): AsyncStatus {
193
193
  const hostStep = parseHostStepNode(input.hostStep, input.hostStep.id);
@@ -76,9 +76,10 @@ export interface TaskMutationArbiterOptions {
76
76
  const DEFAULT_ARBITER_TIMEOUT_MS = 10_000;
77
77
 
78
78
  type RegistryModel = ReturnType<NonNullable<ExtensionContext["modelRegistry"]["find"]>>;
79
+ type ArbiterModelContext = Pick<ExtensionContext, "model" | "modelRegistry">;
79
80
 
80
81
  function resolveArbiterModel(
81
- ctx: ExtensionContext,
82
+ ctx: ArbiterModelContext,
82
83
  options?: TaskMutationArbiterOptions,
83
84
  ): NonNullable<RegistryModel> | null {
84
85
  const registry = ctx.modelRegistry as {
@@ -98,7 +99,7 @@ function resolveArbiterModel(
98
99
  }
99
100
 
100
101
  function resolveArbiterRuntime(
101
- ctx: ExtensionContext,
102
+ ctx: ArbiterModelContext,
102
103
  options?: TaskMutationArbiterOptions,
103
104
  ): ArbiterRuntime | null {
104
105
  const model = resolveArbiterModel(ctx, options);
@@ -117,9 +118,9 @@ function resolveArbiterRuntime(
117
118
  }
118
119
 
119
120
  async function resolveArbiterAuth(
120
- ctx: ExtensionContext,
121
+ ctx: ArbiterModelContext,
121
122
  model: RegistryModel,
122
- ): Promise<ArbiterAuth> {
123
+ ): Promise<ArbiterAuth | undefined> {
123
124
  const registry = ctx.modelRegistry as {
124
125
  getApiKeyAndHeaders?: (m: RegistryModel) => Promise<{
125
126
  ok: boolean;
@@ -135,14 +136,14 @@ async function resolveArbiterAuth(
135
136
  if (!registry.getApiKeyAndHeaders) return {};
136
137
  try {
137
138
  const auth = await registry.getApiKeyAndHeaders(model);
138
- if (auth.ok === false) return {};
139
+ if (auth.ok === false) return undefined;
139
140
  return {
140
141
  ...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
141
142
  ...(auth.headers ? { headers: auth.headers } : {}),
142
143
  ...(auth.env ? { env: auth.env } : {}),
143
144
  };
144
145
  } catch {
145
- return {};
146
+ return undefined;
146
147
  }
147
148
  }
148
149
 
@@ -230,9 +231,9 @@ async function runArbitration(
230
231
  }
231
232
  }
232
233
 
233
- /** Create a memoized arbiter bound to the parent session's model, or undefined when disabled/unavailable. */
234
+ /** Create a memoized arbiter bound to the supplied model services, or undefined when disabled/unavailable. */
234
235
  export function createTaskMutationArbiter(
235
- ctx: ExtensionContext,
236
+ ctx: ArbiterModelContext,
236
237
  options?: TaskMutationArbiterOptions,
237
238
  ): TaskMutationArbiter | undefined {
238
239
  if (process.env.PI_SUBAGENTS_LLM_INTENT_ARBITER === "0") return undefined;
@@ -244,7 +245,7 @@ export function createTaskMutationArbiter(
244
245
  const cached = cache.get(key);
245
246
  if (cached) return cached;
246
247
  const auth = await resolveArbiterAuth(ctx, runtime.model);
247
- const verdict = await runArbitration(runtime, auth, task);
248
+ const verdict = auth ? await runArbitration(runtime, auth, task) : "unavailable";
248
249
  if (cache.size > 200) cache.clear();
249
250
  cache.set(key, verdict);
250
251
  return verdict;
@@ -1,4 +1,4 @@
1
- import { splitKnownThinkingSuffix, type ModelInfo as AvailableModelInfo } from "../../shared/model-info.ts";
1
+ import { splitKnownThinkingSuffix as splitThinkingSuffix, type ModelInfo as AvailableModelInfo } from "../../shared/model-info.ts";
2
2
  import type { Usage } from "../../shared/types.ts";
3
3
  import { filterFallbackCandidates, findModelExclusion, parseModelKey, recordModelFailure } from "./model-exclusions.ts";
4
4
  import { checkModelScope, type ModelScopeCheckRule, type ModelScopeViolation, type ModelSource } from "./model-scope.ts";
@@ -14,9 +14,7 @@ interface ModelAttemptSummary {
14
14
  usage?: Usage;
15
15
  }
16
16
 
17
- export function splitThinkingSuffix(model: string): { baseModel: string; thinkingSuffix: string } {
18
- return splitKnownThinkingSuffix(model);
19
- }
17
+ export { splitThinkingSuffix };
20
18
 
21
19
  /** Aliases apply only to the resolved launch candidate (without its thinking suffix) and the exact raw response ID. */
22
20
  export function formatSubagentModelVerificationError(
@@ -38,7 +36,7 @@ export function formatSubagentModelVerificationError(
38
36
  const expectedFullIdLeaf = expectedEntry.fullId.slice(expectedEntry.fullId.lastIndexOf("/") + 1);
39
37
  if (expectedIdLeaf === observedBase || expectedFullIdLeaf === observedBase) return undefined;
40
38
  }
41
- return `model_verification_failed: child reported a different model than the launch candidate. Expected '${expectedModel}' but observed '${observedModel}'.`;
39
+ return `model_verification_failed: native Pi child reported a different model than the launch candidate. Expected '${expectedModel}' but observed '${observedModel}'. If you have independently verified this response ID identifies the requested model, declare the exact mapping in modelResponseAliases in ~/.pi/agent/extensions/subagent/config.json (see docs/configuration.md#modelresponsealiases). Use the resolved provider/model ID without its thinking suffix as the key. This leaves the outgoing request unchanged. Configuration changes affect new independent native runs; resumed native runs retain their launch-time declaration. External CLI adapters do not use this setting.`;
42
40
  }
43
41
 
44
42
  /** Sentinel model value requesting that a subagent inherit the parent session's model. */
@@ -537,6 +535,7 @@ export function buildModelCandidates(
537
535
  }
538
536
 
539
537
  const RETRYABLE_MODEL_FAILURE_PATTERNS = [
538
+ /^REQUEST_LIMIT_EXCEEDED$/,
540
539
  /rate\s*limit/i,
541
540
  /usage\s*limit/i,
542
541
  /too many requests/i,
@@ -608,8 +607,13 @@ export function isRetryableModelFailureAttempt(input: { error: string | undefine
608
607
  return Boolean(error && input.messages?.some((message) => messageError(message)?.trim() === error));
609
608
  }
610
609
 
610
+ // Request-shape failures can match broad fallback signals such as "upstream",
611
+ // but do not establish that the model is unhealthy for subsequent requests.
612
+ const REQUEST_SHAPE_FAILURE_PATTERN = /\b(?:bad[ _]request|invalid[ _]argument|invalid_request_error)\b/i;
613
+
611
614
  export function recordRetryableModelFailure(model: string | undefined, error: string | undefined): void {
612
- if (!model || !isRetryableModelFailure(error)) return;
615
+ if (!model || !error || !isRetryableModelFailure(error) || isContextOverflow(error)) return;
616
+ if (REQUEST_SHAPE_FAILURE_PATTERN.test(error)) return;
613
617
  const { provider, modelId } = parseModelKey(model);
614
618
  recordModelFailure({ modelId, reason: error, ...(provider ? { provider } : {}) });
615
619
  }
@@ -6,8 +6,8 @@ import {
6
6
  TEMP_ROOT_DIR,
7
7
  type AsyncJobState,
8
8
  type AsyncStatus,
9
- type LaunchResolvedChildExtensionsV1,
10
- type RuntimeAcknowledgedChildExtensionsV1,
9
+ type LaunchResolvedChildExtensions,
10
+ type RuntimeAcknowledgedChildExtensions,
11
11
  type NestedRouteInfo,
12
12
  type TurnBudgetState,
13
13
  type NestedRunSummary,
@@ -217,7 +217,7 @@ function sanitizeCost(value: unknown): NestedRunSummary["totalCost"] | undefined
217
217
  : undefined;
218
218
  }
219
219
 
220
- function sanitizeLaunchResolvedExtensions(value: unknown): LaunchResolvedChildExtensionsV1 | undefined {
220
+ function sanitizeLaunchResolvedExtensions(value: unknown): LaunchResolvedChildExtensions | undefined {
221
221
  if (!value || typeof value !== "object") return undefined;
222
222
  const raw = value as Record<string, unknown>;
223
223
  if (raw.version !== 1 || raw.source !== "launch-resolved" || typeof raw.disableAmbientExtensions !== "boolean") return undefined;
@@ -241,7 +241,7 @@ function sanitizeLaunchResolvedExtensions(value: unknown): LaunchResolvedChildEx
241
241
  };
242
242
  }
243
243
 
244
- function sanitizeRuntimeAcknowledgedExtensions(value: unknown): RuntimeAcknowledgedChildExtensionsV1 | undefined {
244
+ function sanitizeRuntimeAcknowledgedExtensions(value: unknown): RuntimeAcknowledgedChildExtensions | undefined {
245
245
  if (!value || typeof value !== "object") return undefined;
246
246
  const raw = value as Record<string, unknown>;
247
247
  if (raw.version !== 1 || raw.source !== "child-runtime" || !Array.isArray(raw.ids)) return undefined;
@@ -261,7 +261,7 @@ function sanitizeRuntimeAcknowledgedExtensions(value: unknown): RuntimeAcknowled
261
261
  };
262
262
  }
263
263
 
264
- function runtimeAcknowledgedEntry(value: unknown): { runtimeAcknowledgedExtensions: RuntimeAcknowledgedChildExtensionsV1 } | Record<string, never> {
264
+ function runtimeAcknowledgedEntry(value: unknown): { runtimeAcknowledgedExtensions: RuntimeAcknowledgedChildExtensions } | Record<string, never> {
265
265
  const sanitized = sanitizeRuntimeAcknowledgedExtensions(value);
266
266
  return sanitized ? { runtimeAcknowledgedExtensions: sanitized } : {};
267
267
  }
@@ -53,6 +53,8 @@ const ORCA_CLEANUP_WATCHDOG_SCRIPT = [
53
53
  ].join("");
54
54
 
55
55
  export interface OrcaProgressTab {
56
+ /** Resolves when the terminal-create watchdog closes, after its final manifest/queue writes (success or failure). Not viewer completion. */
57
+ readonly creationSettled: Promise<void>;
56
58
  append(text: string): void;
57
59
  section(input: { agent: string; index: number; count: number }): void;
58
60
  event(event: { type?: string; message?: Message; toolName?: string; args?: unknown }): void;
@@ -402,6 +404,8 @@ export function createOrcaProgressTab(input: {
402
404
  }
403
405
  };
404
406
  let createSettled = false;
407
+ let resolveCreationSettled!: () => void;
408
+ const creationSettled = new Promise<void>((resolve) => { resolveCreationSettled = resolve; });
405
409
  let cleanupPaths: string[] | undefined;
406
410
  const scheduleDeferredCleanup = () => {
407
411
  if (!createSettled || cleanupPaths === undefined) return;
@@ -434,6 +438,7 @@ export function createOrcaProgressTab(input: {
434
438
  createSettled = true;
435
439
  if (code !== 0) failObserver();
436
440
  scheduleDeferredCleanup();
441
+ resolveCreationSettled();
437
442
  });
438
443
  watchdog.once("error", () => {
439
444
  markCreateReady();
@@ -450,6 +455,7 @@ export function createOrcaProgressTab(input: {
450
455
 
451
456
  let finished = false;
452
457
  return {
458
+ creationSettled,
453
459
  append(text) {
454
460
  if (finished) return;
455
461
  writeProgress(text);
@@ -17,6 +17,7 @@ import type {
17
17
  WorktreeCleanupReport,
18
18
  WorktreeDiff,
19
19
  WorktreeSetup,
20
+ WorktreeSetupProgress,
20
21
  WorktreeCleanupIntent,
21
22
  } from "./worktree.ts";
22
23
  import { cleanupWorktrees } from "./worktree.ts";
@@ -614,18 +615,62 @@ export function parallelHandoffPath(baseDir: string, runId?: string): string {
614
615
  return runId ? path.join(baseDir, "handoffs", `${runId}.json`) : path.join(baseDir, "handoff.json");
615
616
  }
616
617
 
617
- export function writePendingParallelHandoff(input: {
618
- manifestPath: string;
619
- runId: string;
620
- mode: "single" | "parallel" | "chain";
621
- source: "foreground" | "async";
622
- cwd: string;
623
- stepIndex: number;
624
- flatStartIndex: number;
625
- setup: WorktreeSetup;
626
- laneBindings?: ParallelHandoffLaneBinding[];
627
- }): ParallelHandoffReference {
628
- return writeParallelHandoffGroup({ ...input, diffs: [], results: [] });
618
+ /** Synchronous onProgress projection shared by setup owners; snapshots are cumulative. */
619
+ export function writeWorktreeSetupHandoff(input: Omit<Parameters<typeof writeParallelHandoffGroup>[0], "setup" | "diffs" | "results" | "cleanup" | "now"> & {
620
+ progress: WorktreeSetupProgress;
621
+ }): ParallelHandoffReference | undefined {
622
+ const { progress, ...handoff } = input;
623
+ const { setup, attempts, cleanup } = progress;
624
+ // Preflight has no allocation identity yet. Its original error belongs to the caller.
625
+ if (attempts.length === 0 && setup.worktrees.length === 0) return undefined;
626
+ if (!setup.cwd || !setup.baseCommit) throw new Error("Cannot publish worktree allocation evidence without repository and base commit.");
627
+ const diagnostic = (text: string): string => text.slice(0, 512);
628
+ // Never copy argv, environment, Error objects or captured stdout/stderr into artifacts.
629
+ const commandEvidence = (command: WorktreeSetupProgress["command"]) => command && ({
630
+ pid: command.pid,
631
+ processGroupId: command.processGroupId,
632
+ ...(command.result ? {
633
+ status: command.result.status,
634
+ signal: command.result.signal,
635
+ failed: Boolean(command.result.error),
636
+ outputIncomplete: command.result.outputIncomplete,
637
+ processTree: command.result.processTree?.state,
638
+ } : {}),
639
+ });
640
+ const errors = [
641
+ `Worktree setup ${JSON.stringify({ runId: input.runId, stepIndex: input.stepIndex, phase: progress.phase,
642
+ unknown: Boolean(progress.unknown), command: commandEvidence(progress.command) })}`,
643
+ ...attempts.map((attempt) => {
644
+ const task = cleanup?.tasks.find((candidate) => candidate.index === attempt.index);
645
+ return `Allocation attempt ${JSON.stringify({ index: attempt.index, branch: attempt.branch,
646
+ path: attempt.path ?? null, validated: attempt.validated,
647
+ command: commandEvidence(attempt.command), hookCommand: commandEvidence(attempt.hookCommand),
648
+ ...(task ? { worktreeRemoved: task.worktreeRemoved, branchRemoved: task.branchRemoved,
649
+ preserved: task.preserved, reason: task.reason && diagnostic(task.reason), errors: task.errors?.map(diagnostic) } : {}),
650
+ })}`;
651
+ }),
652
+ ...(cleanup?.errors?.map(diagnostic) ?? []),
653
+ ...(progress.unknown ? ["Setup settlement unknown; manual reconciliation required."] : []),
654
+ ];
655
+ return writeParallelHandoffGroup({
656
+ ...handoff, setup, diffs: [], results: [],
657
+ laneBindings: input.laneBindings?.filter((binding) => setup.worktrees.some((worktree) => worktree.index === binding.taskIndex)),
658
+ cleanup: {
659
+ state: cleanup?.state ?? "partial",
660
+ pruned: cleanup?.pruned ?? false,
661
+ errors,
662
+ // An attempted native path is not a validated recovery task, even during rollback.
663
+ tasks: setup.worktrees.map((worktree) => {
664
+ const task = cleanup?.tasks.find((candidate) => candidate.index === worktree.index);
665
+ return task ? { ...task, reason: task.reason && diagnostic(task.reason), errors: task.errors?.map(diagnostic) } : {
666
+ index: worktree.index, path: worktree.path, branch: worktree.branch,
667
+ provider: worktree.provider, naming: worktree.naming,
668
+ worktreeRemoved: false, branchRemoved: false, preserved: true,
669
+ reason: "setup pending durable handoff capture",
670
+ };
671
+ }),
672
+ },
673
+ });
629
674
  }
630
675
 
631
676
  export function formatParallelHandoffReference(reference: ParallelHandoffReference): string {
@@ -75,8 +75,8 @@ export interface RunnerSubagentStep {
75
75
  launchBindingTask?: string;
76
76
  launchContractDigest?: string;
77
77
  extensionBindings?: import("./extension-bindings.ts").ExtensionBindings;
78
- launchResolvedExtensions?: import("../../shared/types.ts").LaunchResolvedChildExtensionsV1;
79
- runtimeAcknowledgedExtensions?: import("../../shared/types.ts").RuntimeAcknowledgedChildExtensionsV1;
78
+ launchResolvedExtensions?: import("../../shared/types.ts").LaunchResolvedChildExtensions;
79
+ runtimeAcknowledgedExtensions?: import("../../shared/types.ts").RuntimeAcknowledgedChildExtensions;
80
80
  effectiveAcceptance?: import("../../shared/types.ts").ResolvedAcceptanceConfig;
81
81
  acceptanceInput?: import("../../shared/types.ts").AcceptanceInput;
82
82
  acceptanceRole?: import("../../shared/types.ts").AcceptanceRole;