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
@@ -1,7 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- import { Agent, type AgentTool, type StreamFn } from "@earendil-works/pi-agent-core";
3
- import { convertToLlm, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
- import { streamSimple } from "@earendil-works/pi-ai/compat";
2
+ import type { Agent, AgentTool, StreamFn } from "@earendil-works/pi-agent-core";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
4
  import type { ProviderHeaders } from "@earendil-works/pi-ai";
6
5
  import { Type, type Static } from "typebox";
7
6
  import { agentStreamOptions } from "../../shared/agent-stream-options.ts";
@@ -52,7 +51,11 @@ export function mapArbiterDecision(
52
51
 
53
52
  interface ArbiterRuntime {
54
53
  model: NonNullable<RegistryModel>;
55
- baseStreamFn: StreamFn;
54
+ /** Explicit override; it always wins over a registered provider stream. */
55
+ explicitStreamFn?: StreamFn;
56
+ /** Registered provider stream, usable only when its api matches the model. */
57
+ registeredStreamFn?: StreamFn;
58
+ registeredApi?: string;
56
59
  timeoutMs: number;
57
60
  }
58
61
 
@@ -73,9 +76,10 @@ export interface TaskMutationArbiterOptions {
73
76
  const DEFAULT_ARBITER_TIMEOUT_MS = 10_000;
74
77
 
75
78
  type RegistryModel = ReturnType<NonNullable<ExtensionContext["modelRegistry"]["find"]>>;
79
+ type ArbiterModelContext = Pick<ExtensionContext, "model" | "modelRegistry">;
76
80
 
77
81
  function resolveArbiterModel(
78
- ctx: ExtensionContext,
82
+ ctx: ArbiterModelContext,
79
83
  options?: TaskMutationArbiterOptions,
80
84
  ): NonNullable<RegistryModel> | null {
81
85
  const registry = ctx.modelRegistry as {
@@ -95,7 +99,7 @@ function resolveArbiterModel(
95
99
  }
96
100
 
97
101
  function resolveArbiterRuntime(
98
- ctx: ExtensionContext,
102
+ ctx: ArbiterModelContext,
99
103
  options?: TaskMutationArbiterOptions,
100
104
  ): ArbiterRuntime | null {
101
105
  const model = resolveArbiterModel(ctx, options);
@@ -103,23 +107,20 @@ function resolveArbiterRuntime(
103
107
  const registry = ctx.modelRegistry as {
104
108
  getRegisteredProviderConfig?: (provider: string) => { api?: string; streamSimple?: StreamFn } | undefined;
105
109
  };
106
- const modelApi = (model as { api?: string }).api;
107
110
  const registered = registry.getRegisteredProviderConfig?.(model.provider);
108
- const baseStreamFn = options?.streamFn
109
- ?? (registered?.streamSimple && registered.api === modelApi
110
- ? registered.streamSimple
111
- : streamSimple);
112
111
  return {
113
112
  model,
114
- baseStreamFn,
113
+ explicitStreamFn: options?.streamFn,
114
+ registeredStreamFn: registered?.streamSimple,
115
+ registeredApi: registered?.api,
115
116
  timeoutMs: options?.timeoutMs ?? DEFAULT_ARBITER_TIMEOUT_MS,
116
117
  };
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
 
@@ -164,6 +165,15 @@ async function runArbitration(
164
165
  auth: ArbiterAuth,
165
166
  task: string,
166
167
  ): Promise<TaskMutationVerdict> {
168
+ // Keep optional Pi peers out of the detached runner's static import graph.
169
+ const [{ Agent }, { convertToLlm }, { streamSimple }] = await Promise.all([
170
+ import("@earendil-works/pi-agent-core"),
171
+ import("@earendil-works/pi-coding-agent"),
172
+ import("@earendil-works/pi-ai/compat"),
173
+ ]);
174
+ const streamFn: StreamFn = runtime.explicitStreamFn
175
+ ?? (runtime.registeredApi !== undefined && runtime.registeredApi === runtime.model.api ? runtime.registeredStreamFn : undefined)
176
+ ?? streamSimple;
167
177
  let decision: DecisionParams | undefined;
168
178
  const tool: AgentTool<typeof DecisionParams, { recorded: boolean }> = {
169
179
  name: "task_mutation_decision",
@@ -190,7 +200,7 @@ async function runArbitration(
190
200
  tools: [tool],
191
201
  },
192
202
  convertToLlm,
193
- ...agentStreamOptions(authWrappedStreamFn(runtime.baseStreamFn, auth)),
203
+ ...agentStreamOptions(authWrappedStreamFn(streamFn, auth)),
194
204
  getApiKey: (providerName) =>
195
205
  providerName === runtime.model.provider ? auth.apiKey : undefined,
196
206
  beforeToolCall: async ({ toolCall }) =>
@@ -221,9 +231,9 @@ async function runArbitration(
221
231
  }
222
232
  }
223
233
 
224
- /** 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. */
225
235
  export function createTaskMutationArbiter(
226
- ctx: ExtensionContext,
236
+ ctx: ArbiterModelContext,
227
237
  options?: TaskMutationArbiterOptions,
228
238
  ): TaskMutationArbiter | undefined {
229
239
  if (process.env.PI_SUBAGENTS_LLM_INTENT_ARBITER === "0") return undefined;
@@ -235,7 +245,7 @@ export function createTaskMutationArbiter(
235
245
  const cached = cache.get(key);
236
246
  if (cached) return cached;
237
247
  const auth = await resolveArbiterAuth(ctx, runtime.model);
238
- const verdict = await runArbitration(runtime, auth, task);
248
+ const verdict = auth ? await runArbitration(runtime, auth, task) : "unavailable";
239
249
  if (cache.size > 200) cache.clear();
240
250
  cache.set(key, verdict);
241
251
  return verdict;
@@ -317,6 +317,7 @@ export function parseModelKey(fullId: string): { provider?: string; modelId: str
317
317
  export function filterFallbackCandidates(candidates: string[], opts?: {
318
318
  now?: number;
319
319
  onExcluded?: (candidate: string, exclusion: Readonly<ModelExclusion>) => void;
320
+ ignoreExclusion?: (candidate: string, exclusion: Readonly<ModelExclusion>) => boolean;
320
321
  }): string[] {
321
322
  ensureLoaded();
322
323
  invalidateAuthExclusions();
@@ -326,7 +327,7 @@ export function filterFallbackCandidates(candidates: string[], opts?: {
326
327
  for (const raw of candidates) {
327
328
  if (!raw || seen.has(raw)) continue;
328
329
  const { provider: candidateProvider, modelId: candidateModelId } = parseModelKey(raw);
329
- const exclusion = exclusions.find((entry) => entryMatches(entry, candidateModelId, candidateProvider, timestamp));
330
+ const exclusion = exclusions.find((entry) => entryMatches(entry, candidateModelId, candidateProvider, timestamp) && opts?.ignoreExclusion?.(raw, entry) !== true);
330
331
  if (exclusion) {
331
332
  opts?.onExcluded?.(raw, exclusion);
332
333
  continue;
@@ -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,13 +14,19 @@ 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
- export function formatSubagentModelVerificationError(expectedModel: string, observedModel: string, availableModels: AvailableModelInfo[] | undefined): string | undefined {
19
+ /** Aliases apply only to the resolved launch candidate (without its thinking suffix) and the exact raw response ID. */
20
+ export function formatSubagentModelVerificationError(
21
+ expectedModel: string,
22
+ observedModel: string,
23
+ availableModels: AvailableModelInfo[] | undefined,
24
+ modelResponseAliases?: Record<string, string[]>,
25
+ ): string | undefined {
22
26
  if (!availableModels || availableModels.length === 0) return undefined;
23
27
  const expectedBase = splitThinkingSuffix(expectedModel).baseModel;
28
+ if (modelResponseAliases && Object.hasOwn(modelResponseAliases, expectedBase)
29
+ && modelResponseAliases[expectedBase]?.includes(observedModel)) return undefined;
24
30
  const observedBase = splitThinkingSuffix(observedModel).baseModel;
25
31
  if (expectedBase === observedBase) return undefined;
26
32
  const expectedEntry = availableModels.find((entry) => entry.fullId === expectedBase);
@@ -30,7 +36,7 @@ export function formatSubagentModelVerificationError(expectedModel: string, obse
30
36
  const expectedFullIdLeaf = expectedEntry.fullId.slice(expectedEntry.fullId.lastIndexOf("/") + 1);
31
37
  if (expectedIdLeaf === observedBase || expectedFullIdLeaf === observedBase) return undefined;
32
38
  }
33
- 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.`;
34
40
  }
35
41
 
36
42
  /** Sentinel model value requesting that a subagent inherit the parent session's model. */
@@ -309,6 +315,24 @@ function formatExcludedCandidateEvidence(candidate: string, exclusion: NonNullab
309
315
  return `${displayCandidate} — model: ${displayModel}; provider: ${displayProvider}; reason: ${reason}; expires: ${formatModelExclusionExpiry(exclusion.expiresAt)}`;
310
316
  }
311
317
 
318
+ const MODEL_UNAVAILABLE_EXCLUSION_PATTERNS = [
319
+ /model.*not found/i,
320
+ /unknown model/i,
321
+ /model.*unavailable/i,
322
+ /model.*disabled/i,
323
+ ];
324
+
325
+ function isCurrentRegistryModel(candidate: string, availableModels: AvailableModelInfo[] | undefined): boolean {
326
+ if (!availableModels || availableModels.length === 0) return false;
327
+ const { baseModel } = splitThinkingSuffix(candidate);
328
+ return availableModels.some((entry) => entry.fullId === baseModel);
329
+ }
330
+
331
+ function ignoreStaleModelUnavailableExclusion(candidate: string, exclusion: NonNullable<ReturnType<typeof findModelExclusion>>, availableModels: AvailableModelInfo[] | undefined): boolean {
332
+ const reason = exclusion.reason ?? "";
333
+ return MODEL_UNAVAILABLE_EXCLUSION_PATTERNS.some((pattern) => pattern.test(reason)) && isCurrentRegistryModel(candidate, availableModels);
334
+ }
335
+
312
336
  function throwForExplicitModelExclusion(model: string): void {
313
337
  const exclusion = findModelExclusion(model);
314
338
  if (!exclusion) return;
@@ -487,7 +511,10 @@ export function buildModelCandidates(
487
511
  seen.add(normalized);
488
512
  candidates.push(normalized);
489
513
  }
490
- const resolved = filterFallbackCandidates(candidates, { onExcluded: warnCachedExclusion });
514
+ const resolved = filterFallbackCandidates(candidates, {
515
+ onExcluded: warnCachedExclusion,
516
+ ignoreExclusion: (candidate, exclusion) => ignoreStaleModelUnavailableExclusion(candidate, exclusion, availableModels),
517
+ });
491
518
  if (resolved.length === 0) {
492
519
  if (skippedPrimary) resolveRequiredSubagentModelCandidate(skippedPrimary, availableModels, preferredProvider);
493
520
  if (candidates.length === 0 && skippedFallback) resolveRequiredSubagentModelCandidate(skippedFallback, availableModels, preferredProvider);
@@ -508,6 +535,7 @@ export function buildModelCandidates(
508
535
  }
509
536
 
510
537
  const RETRYABLE_MODEL_FAILURE_PATTERNS = [
538
+ /^REQUEST_LIMIT_EXCEEDED$/,
511
539
  /rate\s*limit/i,
512
540
  /usage\s*limit/i,
513
541
  /too many requests/i,
@@ -579,8 +607,13 @@ export function isRetryableModelFailureAttempt(input: { error: string | undefine
579
607
  return Boolean(error && input.messages?.some((message) => messageError(message)?.trim() === error));
580
608
  }
581
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
+
582
614
  export function recordRetryableModelFailure(model: string | undefined, error: string | undefined): void {
583
- if (!model || !isRetryableModelFailure(error)) return;
615
+ if (!model || !error || !isRetryableModelFailure(error) || isContextOverflow(error)) return;
616
+ if (REQUEST_SHAPE_FAILURE_PATTERN.test(error)) return;
584
617
  const { provider, modelId } = parseModelKey(model);
585
618
  recordModelFailure({ modelId, reason: error, ...(provider ? { provider } : {}) });
586
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
  }
@@ -286,7 +286,7 @@ function sanitizeTurnBudget(value: unknown): TurnBudgetState | undefined {
286
286
  }
287
287
 
288
288
  function sanitizeState(value: unknown, fallback: NestedRunState): NestedRunState {
289
- return value === "queued" || value === "running" || value === "complete" || value === "failed" || value === "partial" || value === "paused" || value === "stopped"
289
+ return value === "queued" || value === "running" || value === "complete" || value === "failed" || value === "partial" || value === "paused" || value === "stopped" || value === "rejected"
290
290
  ? value
291
291
  : fallback;
292
292
  }
@@ -296,7 +296,7 @@ function sanitizeStep(input: unknown, depth: number): NestedStepSummary | undefi
296
296
  const raw = input as Record<string, unknown>;
297
297
  const agent = stringValue(raw.agent, 128);
298
298
  if (!agent) return undefined;
299
- const status = raw.status === "pending" || raw.status === "running" || raw.status === "complete" || raw.status === "completed" || raw.status === "failed" || raw.status === "paused" || raw.status === "stopped"
299
+ const status = raw.status === "pending" || raw.status === "running" || raw.status === "complete" || raw.status === "completed" || raw.status === "failed" || raw.status === "partial" || raw.status === "paused" || raw.status === "stopped" || raw.status === "rejected"
300
300
  ? raw.status
301
301
  : "pending";
302
302
  const model = stringValue(raw.model);
@@ -438,7 +438,7 @@ export function parseNestedEventRecords(content: string, route: NestedRoute): Ne
438
438
  }
439
439
 
440
440
  function terminal(state: NestedRunState): boolean {
441
- return state === "complete" || state === "failed" || state === "partial" || state === "paused" || state === "stopped";
441
+ return state === "complete" || state === "failed" || state === "partial" || state === "paused" || state === "rejected" || state === "stopped";
442
442
  }
443
443
 
444
444
  function mergeBoundedChildren(existing: NestedRunSummary[] | undefined, incoming: NestedRunSummary[] | undefined): NestedRunSummary[] | undefined {
@@ -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 {
@@ -41,6 +41,7 @@ export interface RunnerSubagentStep {
41
41
  /** The primary model is inherited from the parent session and should not be verified against the child-reported active registry model. */
42
42
  skipPrimaryModelVerification?: boolean;
43
43
  modelVerificationRegistry?: Array<{ provider: string; id: string; fullId: string; contextWindow?: number }>;
44
+ modelResponseAliases?: Record<string, string[]>;
44
45
  tools?: string[];
45
46
  excludeTools?: string[];
46
47
  allowNestedSubagents?: boolean;
@@ -74,8 +75,8 @@ export interface RunnerSubagentStep {
74
75
  launchBindingTask?: string;
75
76
  launchContractDigest?: string;
76
77
  extensionBindings?: import("./extension-bindings.ts").ExtensionBindings;
77
- launchResolvedExtensions?: import("../../shared/types.ts").LaunchResolvedChildExtensionsV1;
78
- runtimeAcknowledgedExtensions?: import("../../shared/types.ts").RuntimeAcknowledgedChildExtensionsV1;
78
+ launchResolvedExtensions?: import("../../shared/types.ts").LaunchResolvedChildExtensions;
79
+ runtimeAcknowledgedExtensions?: import("../../shared/types.ts").RuntimeAcknowledgedChildExtensions;
79
80
  effectiveAcceptance?: import("../../shared/types.ts").ResolvedAcceptanceConfig;
80
81
  acceptanceInput?: import("../../shared/types.ts").AcceptanceInput;
81
82
  acceptanceRole?: import("../../shared/types.ts").AcceptanceRole;
@@ -0,0 +1,42 @@
1
+ const knownStates = new Set(["queued", "running", "complete", "failed", "partial", "paused", "stopped", "rejected"]);
2
+
3
+ /**
4
+ * Private, installation-owned evidence. Never changes drain/query behavior or retains statuses.
5
+ * Observes the ordinary indexed drain, not historical ownership or outside work appearing later.
6
+ */
7
+ export class ReadonlyDrainObservation {
8
+ private state: "pending" | "empty" | "denied" = "pending";
9
+ private started = false;
10
+ private first = false;
11
+ private readonly file: string;
12
+ private readonly guard: () => boolean;
13
+ constructor(file: string, guard: () => boolean) { this.file = file; this.guard = guard; }
14
+ deny(): void { this.state = "denied"; }
15
+ check(): boolean {
16
+ try { if (!this.guard()) this.deny(); } catch { this.deny(); }
17
+ return this.state !== "denied";
18
+ }
19
+ begin(file: string | null, native: boolean): void {
20
+ if (this.started || file !== this.file || !native) this.deny();
21
+ this.started = true;
22
+ this.check();
23
+ }
24
+ /** Called at the existing initial read, before reconciliation or filtering. */
25
+ readonly status: RawDrainStatusObserver = (status) => {
26
+ if (!status || typeof status.sessionId !== "string" || !status.sessionId
27
+ || !knownStates.has(status.state as string)) this.deny();
28
+ else if (status.sessionId === this.file && (status.state === "queued" || status.state === "running")) this.deny();
29
+ };
30
+ predicate(hasWork: boolean): void {
31
+ if (this.first) return;
32
+ this.first = true;
33
+ if (hasWork) this.deny();
34
+ }
35
+ complete(): void {
36
+ if (this.started && this.first && this.check()) this.state = "empty";
37
+ }
38
+ settled(): boolean { return this.check() && this.state === "empty"; }
39
+ }
40
+
41
+ /** Internal synchronous sink; null means an existing query encountered uncertainty. */
42
+ export type RawDrainStatusObserver = (status: { sessionId?: unknown; state?: unknown } | null) => void;
@@ -0,0 +1,69 @@
1
+ import type { ChildSession } from "./child-session.ts";
2
+ import { getReadonlySessionEvidence, type SettledReadonlyEvidence } from "./readonly-session-evidence.ts";
3
+
4
+ /** Owned by the logical host run, shared with abort recovery; never reset per attempt. */
5
+ export type LogicalRecoveryState = "unused" | "abort-recovery" | "readonly-continuation";
6
+
7
+ export interface ReadonlyContinuationCandidate {
8
+ /** Actual resolved identity, not an alias or a parsed display reference. */
9
+ readonly resolved: { readonly provider: string; readonly model: string; readonly api: string } | undefined;
10
+ readonly tried: boolean;
11
+ /** Host assessment of retained input support AND context capacity, including prompt overhead. */
12
+ readonly compatibility: "compatible" | "incompatible" | "unknown";
13
+ }
14
+
15
+ export interface ReadonlyContinuationInput {
16
+ /** Retain the source child privately: its live accessor detects revoked receipts. */
17
+ readonly source: ChildSession | undefined;
18
+ readonly recoveryState: LogicalRecoveryState;
19
+ /** Ordered, already authorized and exclusion-filtered. This planner never resolves models. */
20
+ readonly candidates: readonly ReadonlyContinuationCandidate[];
21
+ readonly currentIndex: number;
22
+ /** False includes success, stop/interrupt/detach/handoff, deadline, or workflow-permit veto. */
23
+ readonly lifecycleAllowsContinuation: boolean;
24
+ /** False includes completion/structured/acceptance failures, pending input or other effects. */
25
+ readonly effectsAllowContinuation: boolean;
26
+ /** Configured tool budgets are unsupported; unknown authoritative usage allowance denies. */
27
+ readonly budget: "unconfigured" | "available" | "exhausted" | "unknown" | "tool-budget-configured";
28
+ readonly knownContextOverflow: boolean;
29
+ }
30
+
31
+ export const READONLY_CONTINUATION_PROMPT = "The previous provider request failed with HTTP 429 after read-only progress. Continue from the retained transcript and completed tool results. Do not restart or repeat completed work. Use only the existing read-only tools and finish the requested response.";
32
+
33
+ export type ReadonlyContinuationPlan =
34
+ | { readonly kind: "deny"; readonly reason: "recovery-consumed" | "veto" | "no-evidence" | "unresolved-identity" | "incompatible" | "no-sibling" }
35
+ | { readonly kind: "continue"; readonly candidateIndex: number; readonly expected: SettledReadonlyEvidence;
36
+ readonly prompt: typeof READONLY_CONTINUATION_PROMPT; readonly recoveryState: "readonly-continuation" };
37
+
38
+ /**
39
+ * Pure decision: no disk reads, dispatch, receipt minting, or state mutation.
40
+ * The host MUST recheck live source proof and lifecycle/budget at handoff, then store the
41
+ * returned consumed state BEFORE creation (even if creation subsequently fails),
42
+ * and pass expected to requestReadonlySessionEvidence on the exact-file sibling launch.
43
+ * That factory guard owns checkpoint/configured-provider revalidation before open/prompt/dispatch.
44
+ * The host must also verify the actual sibling model matches the selected resolved identity;
45
+ * a plan is not a dispatch authorization and any create/guard failure terminates recovery.
46
+ */
47
+ export function planReadonlyModelContinuation(input: ReadonlyContinuationInput): ReadonlyContinuationPlan {
48
+ if (input.recoveryState !== "unused") return { kind: "deny", reason: "recovery-consumed" };
49
+ if (!input.lifecycleAllowsContinuation || !input.effectsAllowContinuation || input.knownContextOverflow
50
+ || (input.budget !== "unconfigured" && input.budget !== "available")) return { kind: "deny", reason: "veto" };
51
+ const expected = input.source && getReadonlySessionEvidence(input.source);
52
+ if (!expected || input.source?.detached || input.source?.shutDown) return { kind: "deny", reason: "no-evidence" };
53
+ const current = Number.isInteger(input.currentIndex) && input.currentIndex >= 0 ? input.candidates[input.currentIndex]?.resolved : undefined;
54
+ if (!current || current.provider !== expected.provider || current.model !== expected.model || current.api !== expected.api) {
55
+ return { kind: "deny", reason: "unresolved-identity" };
56
+ }
57
+ for (let index = input.currentIndex + 1; index < input.candidates.length; index++) {
58
+ const candidate = input.candidates[index];
59
+ if (!candidate) return { kind: "deny", reason: "unresolved-identity" };
60
+ if (candidate.tried) continue;
61
+ const resolved = candidate.resolved;
62
+ if (!resolved?.provider || !resolved.model || !resolved.api) return { kind: "deny", reason: "unresolved-identity" };
63
+ if (resolved.provider !== expected.provider || resolved.model === expected.model) continue;
64
+ if (input.candidates.some((other) => other.tried && other.resolved?.provider === resolved.provider && other.resolved.model === resolved.model)) continue;
65
+ if (resolved.api !== expected.api || candidate.compatibility !== "compatible") return { kind: "deny", reason: "incompatible" };
66
+ return { kind: "continue", candidateIndex: index, expected, prompt: READONLY_CONTINUATION_PROMPT, recoveryState: "readonly-continuation" };
67
+ }
68
+ return { kind: "deny", reason: "no-sibling" };
69
+ }