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
@@ -337,6 +337,9 @@ function completionUsage(completions: WaitCompletion[] | undefined): Usage | und
337
337
 
338
338
  function result(text: string, isError = false, completions?: WaitCompletion[]): AgentToolResult<Details> {
339
339
  const usage = completionUsage(completions);
340
+ for (const completion of completions ?? []) {
341
+ if (completion.workflowReceiptPath) text += `\nWorkflow receipt [${completion.runId}]: ${completion.workflowReceiptPath}`;
342
+ }
340
343
  return {
341
344
  content: [{ type: "text", text }],
342
345
  ...(isError ? { isError: true } : {}),
@@ -96,6 +96,9 @@ export function toWaitCompletion(data: Record<string, unknown>, runId: string):
96
96
  })
97
97
  : undefined;
98
98
  const agent = asNonEmptyString(data.agent);
99
+ const receipt = data.workflowReceipt;
100
+ const workflowReceiptPath = receipt && typeof receipt === "object" && !Array.isArray(receipt)
101
+ ? asNonEmptyString((receipt as Record<string, unknown>).path) : undefined;
99
102
  const mode = asNonEmptyString(data.mode);
100
103
  const state = asNonEmptyString(data.state);
101
104
  const workflowChildren = parseWorkflowChildSummary(data.workflowChildren);
@@ -104,6 +107,7 @@ export function toWaitCompletion(data: Record<string, unknown>, runId: string):
104
107
  runId,
105
108
  ...(agent ? { agent } : {}),
106
109
  ...(mode ? { mode } : {}),
110
+ ...(workflowReceiptPath ? { workflowReceiptPath } : {}),
107
111
  ...(state ? { state } : {}),
108
112
  ...(typeof data.success === "boolean" ? { success: data.success } : {}),
109
113
  ...(results && results.length > 0 ? { results } : {}),
@@ -26,6 +26,7 @@ export async function steerAsyncRun(input: {
26
26
  message: string;
27
27
  mode?: SteerDeliveryMode;
28
28
  index?: number;
29
+ findPendingAsks?: (target: { runId: string; agent: string; childIndex: number }) => string[];
29
30
  kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
30
31
  location: { asyncDir: string | null };
31
32
  signal?: AbortSignal;
@@ -44,6 +45,24 @@ export async function steerAsyncRun(input: {
44
45
  };
45
46
  }
46
47
  const asyncDir = input.location.asyncDir;
48
+ const snapshot = input.findPendingAsks ? readStatus(asyncDir) : null;
49
+ if (snapshot?.mode === "single"
50
+ && (!input.state.currentSessionId || snapshot.sessionId === input.state.currentSessionId)
51
+ && (snapshot.state === "running" || snapshot.state === "queued")
52
+ && snapshot.steps?.length === 1 && (input.index === undefined || input.index === 0)
53
+ && (snapshot.steps[0]?.status === "running" || snapshot.steps[0]?.status === "pending")) {
54
+ const asks = input.findPendingAsks?.({ runId: snapshot.runId, agent: snapshot.steps[0].agent, childIndex: 0 }) ?? [];
55
+ if (asks.length > 0) {
56
+ const replies = asks.map(replyTo => `subagent_supervisor(${JSON.stringify({ action: "reply", replyTo, message: "<explicit answer>" })})`).join("\n");
57
+ return {
58
+ content: [{ type: "text", text: `Steering not delivered or queued: async run ${snapshot.runId} is blocked on ${asks.length === 1 ? "a pending supervisor ask" : "ambiguous pending supervisor asks"}. No reply or recovery was attempted. Reply explicitly${asks.length > 1 ? " to the intended request ID" : ""}:\n${replies}` }],
59
+ isError: true,
60
+ details: { mode: "management", results: [] },
61
+ };
62
+ }
63
+ }
64
+ // The read-only snapshot precedes even stale-run reconciliation. An ask arriving
65
+ // later does not make a runner acknowledgement proof of consumption or unblocking.
47
66
  const status = reconcileAsyncRun(asyncDir, { kill: input.kill }).status;
48
67
  if (input.state.currentSessionId && status?.sessionId !== input.state.currentSessionId) {
49
68
  return {
@@ -58,6 +58,9 @@ import { effectiveToolTimeoutMs, formatToolTimeoutMessage, resolveToolTimeoutMs,
58
58
  import { evaluateCompletionMutationGuard, expectsImplementationMutation, hasMutationToolCapability, validateImplementationToolContract } from "../shared/completion-guard.ts";
59
59
  import { planCompletionEvidence } from "../shared/completion-evidence.ts";
60
60
  import { planAbortRecovery } from "../shared/abort-recovery.ts";
61
+ import { planReadonlyModelContinuation, type LogicalRecoveryState } from "../shared/readonly-model-continuation.ts";
62
+ import { getReadonlySessionEvidence, requestReadonlySessionEvidence, type SettledReadonlyEvidence } from "../shared/readonly-session-evidence.ts";
63
+ import { getReadonlyChildModels } from "../shared/child-session.ts";
61
64
  import { arbitrateCompletionGuardRescue } from "../shared/llm-intent-arbiter.ts";
62
65
  import { preflightLaunchCwd } from "../shared/launch-cwd.ts";
63
66
  import { createJsonlWriter } from "../../shared/jsonl-writer.ts";
@@ -94,7 +97,7 @@ import {
94
97
  } from "../shared/long-running-guard.ts";
95
98
  import { acceptanceFailureMessage, buildSkippedAcceptanceLedger, evaluateAcceptance, formatAcceptancePrompt, resolveEffectiveAcceptance, stripAcceptanceReport, validateAcceptanceInput } from "../shared/acceptance.ts";
96
99
  import { PROMPT_REDACTED } from "../../shared/utils.ts";
97
- import { attachContractProjections, isAgentContractV1 } from "../shared/agent-contract.ts";
100
+ import { attachContractProjections, isAgentContract } from "../shared/agent-contract.ts";
98
101
  import { initialToolBudgetState, toolBudgetState } from "../shared/tool-budget.ts";
99
102
  import { resolveWatchdogConfig } from "../../watchdog/settings.ts";
100
103
  import { agentDefinitionDigest, launchBindingDigest } from "../../shared/launch-contract.ts";
@@ -109,7 +112,7 @@ import {
109
112
  type ChildWatchdogStateSnapshot,
110
113
  type ChildWatchdogStatusEvent,
111
114
  } from "../../watchdog/child-status.ts";
112
- import { buildInProcessChildLaunch } from "../shared/child-launch.ts";
115
+ import { buildInProcessChildLaunch, createReportedChildSessionInput } from "../shared/child-launch.ts";
113
116
  import { childSessionFactory, projectChildSessionEventForJson, type ChildSession, type ChildSessionEvent } from "../shared/child-session.ts";
114
117
 
115
118
  const artifactOutputByResult = new WeakMap<SingleResult, string>();
@@ -340,6 +343,7 @@ function structuredDelegationProgressChanged(
340
343
 
341
344
  const AFTER_COMPACTION_SETTLEMENT = Symbol("afterCompactionSettlement");
342
345
  type AbortRecoverySingleResult = SingleResult & { [AFTER_COMPACTION_SETTLEMENT]?: true };
346
+ const settledReadonlySource = new WeakMap<SingleResult, ChildSession>();
343
347
 
344
348
  const STOPPED_BEFORE_COMPLETION_ERROR = "Subagent stopped before completion.";
345
349
 
@@ -353,6 +357,7 @@ async function runSingleAttempt(
353
357
  shared: {
354
358
  sessionEnabled: boolean;
355
359
  systemPrompt: string;
360
+ acceptancePrompt: string;
356
361
  resolvedSkillNames?: string[];
357
362
  modelCandidates?: string[];
358
363
  skillsWarning?: string;
@@ -365,6 +370,9 @@ async function runSingleAttempt(
365
370
  orcaProgressTab?: OrcaProgressTab;
366
371
  launchWarnings: { emitted: boolean };
367
372
  verifyModel: boolean;
373
+ readonlyExpected?: SettledReadonlyEvidence;
374
+ readonlyModel?: string;
375
+ readonlyHandoffAllowed?: () => boolean;
368
376
  },
369
377
  ): Promise<SingleResult> {
370
378
  const effectiveThinking = options.thinkingOverride ?? agent.thinking;
@@ -405,7 +413,8 @@ async function runSingleAttempt(
405
413
  allowNestedSubagents: agent.allowNestedSubagents,
406
414
  extensions: agent.extensions,
407
415
  subagentOnlyExtensions: agent.subagentOnlyExtensions,
408
- systemPrompt: shared.systemPrompt,
416
+ // Exact terminal protocol belongs to session resources, not compactable history.
417
+ systemPrompt: shared.acceptancePrompt ? `${shared.systemPrompt}\n${shared.acceptancePrompt}` : shared.systemPrompt,
409
418
  mcpDirectTools: agent.mcpDirectTools,
410
419
  cwd: options.cwd ?? runtimeCwd,
411
420
  intercomSessionName: options.intercomSessionName,
@@ -425,7 +434,8 @@ async function runSingleAttempt(
425
434
  permissionRules,
426
435
  permissionAuditPath,
427
436
  childWatchdog,
428
- watchdogStatus: (event) => onWatchdogStatus?.(event),
437
+ // registerChildWatchdog returns before reading the sink when no watchdog exists.
438
+ watchdogStatus: childWatchdog ? (event) => onWatchdogStatus?.(event) : undefined,
429
439
  waitToolEnabled: options.waitToolEnabled,
430
440
  waitToolDefaultTimeoutMs: options.waitToolDefaultTimeoutMs,
431
441
  capabilityCeiling: options.capabilityCeiling,
@@ -780,7 +790,10 @@ async function runSingleAttempt(
780
790
  // JSONL artifact flush is best effort.
781
791
  });
782
792
  // Report the run only after the child's extensions have shut down.
783
- void Promise.resolve().then(() => session?.dispose()).catch(() => undefined).then(() => resolve(code));
793
+ void Promise.resolve().then(() => session?.dispose()).catch(() => undefined).then(() => {
794
+ if (session && getReadonlySessionEvidence(session)) settledReadonlySource.set(result, session);
795
+ resolve(code);
796
+ });
784
797
  };
785
798
 
786
799
  const drainPendingControlEvents = (): ControlEvent[] | undefined => {
@@ -1105,6 +1118,10 @@ async function runSingleAttempt(
1105
1118
  }
1106
1119
  }
1107
1120
  if (evt.message.errorMessage) assistantError = evt.message.errorMessage;
1121
+ else if (hasToolCall && evt.message.stopReason === "toolUse") {
1122
+ // A recovered request can finish via a terminating tool, without a text stop.
1123
+ assistantError = undefined;
1124
+ }
1108
1125
  const assistantText = extractTextFromContent(evt.message.content);
1109
1126
  appendRecentOutput(progress, assistantText.split("\n").slice(-10));
1110
1127
  // Final assistant message: start the settle drain window.
@@ -1352,12 +1369,10 @@ async function runSingleAttempt(
1352
1369
 
1353
1370
  void (async () => {
1354
1371
  try {
1355
- const created = await childSessions.create({
1356
- ...launch.session,
1357
- onExtensionError: (error) => {
1358
- shared.transcriptWriter?.writeStderrLine(`Extension error (${error.extensionPath}, ${error.event}): ${error.error instanceof Error ? error.error.message : String(error.error)}`);
1359
- },
1360
- });
1372
+ const input = createReportedChildSessionInput(launch, shared.transcriptWriter);
1373
+ requestReadonlySessionEvidence(input, shared.readonlyExpected);
1374
+ if (shared.readonlyHandoffAllowed && !shared.readonlyHandoffAllowed()) throw new Error("Read-only continuation handoff vetoed.");
1375
+ const created = await childSessions.create(input);
1361
1376
  if (lifecycleFinished) {
1362
1377
  void created.dispose();
1363
1378
  return;
@@ -1369,6 +1384,10 @@ async function runSingleAttempt(
1369
1384
  abortChild();
1370
1385
  }
1371
1386
  options.onChildSession?.({ steer: (text) => created.steer(text), followUp: (text) => created.followUp(text) });
1387
+ const actualReadonlyModel = shared.readonlyExpected && getReadonlyChildModels(created)?.current;
1388
+ if (shared.readonlyExpected && (!actualReadonlyModel || actualReadonlyModel.fullId !== shared.readonlyModel
1389
+ || actualReadonlyModel.api !== shared.readonlyExpected.api || created.modelId !== shared.readonlyModel || abortedBySignal || interruptedByControl || result.timedOut
1390
+ || !shared.readonlyHandoffAllowed?.())) throw new Error("Read-only continuation handoff vetoed.");
1372
1391
  await created.prompt(`Task: ${task}`);
1373
1392
  settle(undefined);
1374
1393
  } catch (error) {
@@ -1497,7 +1516,7 @@ async function runSingleAttempt(
1497
1516
  ? `${timeoutMessage}\n\n${result.timeoutRecovery.message}\n\nPartial output before timeout:\n${fullOutput}`
1498
1517
  : `${timeoutMessage}\n\n${result.timeoutRecovery.message}`;
1499
1518
  }
1500
- const completionGuardEnabled = isAgentContractV1(options.agentContract) ? agent.completionGuard === true : agent.completionGuard !== false;
1519
+ const completionGuardEnabled = isAgentContract(options.agentContract) ? agent.completionGuard === true : agent.completionGuard !== false;
1501
1520
  const completionGuard = ((result.exitCode === 0 && !result.error) || toolAvailabilityError) && completionGuardEnabled
1502
1521
  ? evaluateCompletionMutationGuard({
1503
1522
  agent: agent.name,
@@ -1537,7 +1556,7 @@ async function runSingleAttempt(
1537
1556
  mutationAttemptObserved,
1538
1557
  mutationEvidence,
1539
1558
  arbiterRescued,
1540
- agentContractV1: isAgentContractV1(options.agentContract),
1559
+ agentContractEnabled: isAgentContract(options.agentContract),
1541
1560
  });
1542
1561
  if (completionEvidence.fileMutation) {
1543
1562
  result.effects = {
@@ -1733,7 +1752,7 @@ async function runSyncCompletionInner(
1733
1752
  dynamicGroup: options.acceptanceContext?.dynamicGroup,
1734
1753
  agentContract: options.agentContract,
1735
1754
  });
1736
- const acceptancePrompt = formatAcceptancePrompt(effectiveAcceptance, { reportOptional: isAgentContractV1(options.agentContract), structuredOutput: Boolean(options.structuredOutput?.acceptanceReportPath) });
1755
+ const acceptancePrompt = formatAcceptancePrompt(effectiveAcceptance, { reportOptional: isAgentContract(options.agentContract), structuredOutput: Boolean(options.structuredOutput?.acceptanceReportPath) });
1737
1756
  const taskWithAcceptance = acceptancePrompt ? `${task}\n${acceptancePrompt}` : task;
1738
1757
  options.onEffectivePrompt?.(taskWithAcceptance);
1739
1758
  const sessionEnabled = Boolean(options.sessionFile || options.sessionDir) || shareEnabled;
@@ -1884,19 +1903,33 @@ async function runSyncCompletionInner(
1884
1903
  };
1885
1904
  let lastResult: SingleResult | undefined;
1886
1905
  const modelsToTry = candidates.length > 0 ? candidates : [undefined];
1887
- let abortRecoveryAttempted = false;
1888
- let nextAttemptTask = taskWithAcceptance;
1906
+ let recoveryState: LogicalRecoveryState = "unused";
1907
+ let readonlyExpected: SettledReadonlyEvidence | undefined;
1908
+ let readonlyModel: string | undefined;
1909
+ let readonlySource: ChildSession | undefined;
1910
+ // Ordinary startup retries retain their per-attempt timeout. Only retained
1911
+ // continuation uses the original logical deadline, never a renewed allowance.
1912
+ const continuationDeadline = options.deadlineAt ?? (options.timeoutMs === undefined ? undefined : Date.now() + options.timeoutMs);
1913
+ const readonlyHandoffAllowed = () => !options.signal?.aborted && !options.interruptSignal?.aborted
1914
+ && !intercomDetached && !detachedReason && !options.workflowChildPermitLaunch
1915
+ && options.usageBudget === undefined && options.toolBudget === undefined
1916
+ && (continuationDeadline === undefined || Date.now() < continuationDeadline)
1917
+ && (!readonlySource || getReadonlySessionEvidence(readonlySource) === readonlyExpected)
1918
+ && !readonlySource?.detached && !readonlySource?.shutDown;
1919
+ let nextAttemptTask = task;
1889
1920
  modelAttemptsLoop: for (let modelIndex = 0; modelIndex < modelsToTry.length; modelIndex++) {
1890
1921
  const candidate = modelsToTry[modelIndex];
1891
1922
  // The inner loop re-runs the same candidate at most once, for abort recovery.
1892
1923
  for (;;) {
1893
- const recoveringAbort = abortRecoveryAttempted;
1924
+ const recoveringAbort = recoveryState === "abort-recovery";
1894
1925
  const attemptTask = nextAttemptTask;
1895
1926
  const verifyModel = Boolean(candidate) && !(options.modelOverrideFromParent && modelIndex === 0);
1896
1927
  const outputSnapshot = captureSingleOutputSnapshot(options.outputPath);
1928
+ if (recoveryState === "readonly-continuation") attemptOptions.deadlineAt = continuationDeadline;
1897
1929
  const result = await runSingleAttempt(runtimeCwd, agent, attemptTask, candidate, attemptOptions, {
1898
1930
  sessionEnabled,
1899
1931
  systemPrompt,
1932
+ acceptancePrompt,
1900
1933
  resolvedSkillNames: resolvedSkills.length > 0 ? resolvedSkills.map((skill) => skill.name) : undefined,
1901
1934
  skillsWarning: missingSkills.length > 0 ? `Skills not found: ${missingSkills.join(", ")}` : undefined,
1902
1935
  jsonlPath,
@@ -1911,6 +1944,9 @@ async function runSyncCompletionInner(
1911
1944
  orcaProgressTab,
1912
1945
  launchWarnings,
1913
1946
  verifyModel,
1947
+ readonlyExpected,
1948
+ readonlyModel,
1949
+ readonlyHandoffAllowed: readonlyExpected ? readonlyHandoffAllowed : undefined,
1914
1950
  });
1915
1951
  lastResult = result;
1916
1952
  if (!recoveringAbort) {
@@ -1929,6 +1965,46 @@ async function runSyncCompletionInner(
1929
1965
  usage: { ...result.usage },
1930
1966
  };
1931
1967
  modelAttempts.push(attempt);
1968
+ // A consumed retained continuation is terminal even on a startup error or abort.
1969
+ if (recoveryState === "readonly-continuation") break modelAttemptsLoop;
1970
+ const source = settledReadonlySource.get(result);
1971
+ const evidence = source && getReadonlySessionEvidence(source);
1972
+ const models = source && getReadonlyChildModels(source);
1973
+ // Deny non-text retained inputs, including unknown blocks. Count bytes once,
1974
+ // not restored usage (the latter belongs to earlier attempts/runs).
1975
+ const textOnly = evidence && (JSON.parse(evidence.contextJson) as Array<{ role: string; content: unknown }>).every((message) =>
1976
+ typeof message.content === "string" || Array.isArray(message.content) && message.content.every((block) =>
1977
+ block?.type === "text" || message.role === "assistant" && block?.type === "toolCall"));
1978
+ const retainedBytes = evidence && models ? Buffer.byteLength(evidence.contextJson) + models.requestBytes : Infinity;
1979
+ const resolvedCandidates = modelsToTry.map((reference, index) => index === modelIndex ? models?.current : reference ? models?.resolve(reference) : undefined);
1980
+ const continuation = planReadonlyModelContinuation({
1981
+ source, recoveryState, currentIndex: modelIndex,
1982
+ candidates: resolvedCandidates.map((resolved, index) => {
1983
+ // A conservative byte ceiling includes serialized history, actual system
1984
+ // prompt/tools, framing/continuation headroom and full output allowance.
1985
+ const required = resolved?.maxTokens ? retainedBytes + 4096 + resolved.maxTokens : Infinity;
1986
+ return {
1987
+ resolved: resolved?.api ? { provider: resolved.provider, model: resolved.id, api: resolved.api } : undefined,
1988
+ tried: index <= modelIndex,
1989
+ compatibility: textOnly && resolved?.input?.includes("text") && (resolved.contextWindow ?? 0) >= required ? "compatible" as const : "unknown" as const,
1990
+ };
1991
+ }),
1992
+ lifecycleAllowsContinuation: !attemptSucceeded && readonlyHandoffAllowed() && !result.stopped && !result.detached && !result.interrupted && !result.timedOut,
1993
+ effectsAllowContinuation: !result.structuredOutputFailed && !result.toolBudgetBlocked && !result.progress?.currentTool
1994
+ && !result.outputSaveError && (!result.effects?.fileMutation || result.effects.fileMutation.status === "not-applicable"),
1995
+ budget: options.toolBudget ? "tool-budget-configured" : options.usageBudget ? "unknown" : "unconfigured",
1996
+ knownContextOverflow: Boolean(result.contextOverflow || isContextOverflow(result.error)),
1997
+ });
1998
+ if (continuation.kind === "continue") {
1999
+ recoveryState = continuation.recoveryState; // consume BEFORE any sibling creation
2000
+ readonlyExpected = continuation.expected;
2001
+ readonlySource = source;
2002
+ readonlyModel = resolvedCandidates[continuation.candidateIndex]?.fullId;
2003
+ nextAttemptTask = continuation.prompt;
2004
+ attemptNotes.push(`[readonly-continuation] ${attempt.model} failed with HTTP 429 after read-only progress; continuing retained session once with ${readonlyModel}.`);
2005
+ modelIndex = continuation.candidateIndex - 1;
2006
+ continue modelAttemptsLoop;
2007
+ }
1932
2008
  if (!attemptSucceeded) {
1933
2009
  const afterCompactionSettlement = (result as AbortRecoverySingleResult)[AFTER_COMPACTION_SETTLEMENT];
1934
2010
  const abortRecovery = planAbortRecovery({
@@ -1936,7 +2012,7 @@ async function runSyncCompletionInner(
1936
2012
  error: result.error,
1937
2013
  processSignal: result.processSignal,
1938
2014
  sessionAvailable: Boolean(options.sessionFile && existsSync(options.sessionFile)),
1939
- alreadyResumed: abortRecoveryAttempted,
2015
+ alreadyResumed: recoveryState !== "unused",
1940
2016
  stopped: result.stopped || result.detached || options.signal?.aborted,
1941
2017
  interrupted: result.interrupted || intercomDetached || options.interruptSignal?.aborted,
1942
2018
  timedOut: result.timedOut,
@@ -1948,7 +2024,7 @@ async function runSyncCompletionInner(
1948
2024
  afterCompactionSettlement,
1949
2025
  });
1950
2026
  if (abortRecovery.action === "resume") {
1951
- abortRecoveryAttempted = true;
2027
+ recoveryState = "abort-recovery";
1952
2028
  nextAttemptTask = abortRecovery.prompt;
1953
2029
  attemptNotes.push("[abort-recovery] provider/transport abort after useful progress; resuming the retained child session once.");
1954
2030
  continue;
@@ -2059,7 +2135,7 @@ async function runSyncCompletionInner(
2059
2135
  ? { content: childWrittenOutput, path: options.outputPath, authoritative: options.outputMode === "file-only", durable: result.savedOutputPath !== undefined }
2060
2136
  : undefined,
2061
2137
  cwd: options.cwd ?? runtimeCwd,
2062
- reportOptional: isAgentContractV1(options.agentContract),
2138
+ reportOptional: isAgentContract(options.agentContract),
2063
2139
  artifactsDir: options.artifactsDir,
2064
2140
  runId: options.runId,
2065
2141
  watchdog: result.watchdog,
@@ -2071,7 +2147,7 @@ async function runSyncCompletionInner(
2071
2147
  }
2072
2148
  const acceptanceFailure = acceptanceFailureMessage(result.acceptance);
2073
2149
  stripAcceptanceReportsFromMessages(result.messages);
2074
- if (acceptanceFailure && result.acceptance.explicit && result.exitCode === 0 && !result.interrupted && !result.timedOut && !isAgentContractV1(options.agentContract)) {
2150
+ if (acceptanceFailure && result.acceptance.explicit && result.exitCode === 0 && !result.interrupted && !result.timedOut && !isAgentContract(options.agentContract)) {
2075
2151
  result.exitCode = 1;
2076
2152
  if (result.savedOutputPath) {
2077
2153
  result.finalOutput = finalizeSingleOutput({
@@ -2104,7 +2180,7 @@ async function runSyncCompletionInner(
2104
2180
  result.progress.error = result.error;
2105
2181
  }
2106
2182
  }
2107
- if (isAgentContractV1(options.agentContract)) attachContractProjections(result);
2183
+ if (isAgentContract(options.agentContract)) attachContractProjections(result);
2108
2184
  redactResultPrompt(result);
2109
2185
  try {
2110
2186
  persistResultMetadata(result);
@@ -2149,7 +2225,7 @@ export async function runSync(
2149
2225
  ): Promise<SingleResult> {
2150
2226
  // Capture the strict contract before consumer-owned objects can be mutated
2151
2227
  // after a detached receipt is published.
2152
- const strictContract = isAgentContractV1(options.agentContract);
2228
+ const strictContract = isAgentContract(options.agentContract);
2153
2229
  let detachedReason: string | undefined;
2154
2230
  let publishedReceipt: SingleResult | undefined;
2155
2231
  let activeDetachAttempt: ((reason?: string) => boolean) | undefined;
@@ -2247,13 +2323,13 @@ export async function runSync(
2247
2323
  });
2248
2324
 
2249
2325
  let terminalCallbackInvoked = false;
2250
- void authoritativeCompletion.then((terminalResult) => {
2326
+ void authoritativeCompletion.then(async (terminalResult) => {
2251
2327
  if (!detachedReason || terminalCallbackInvoked) return;
2252
2328
  terminalCallbackInvoked = true;
2253
2329
  terminalResult.detached = undefined;
2254
2330
  terminalResult.detachedReason = detachedReason;
2255
2331
  try {
2256
- options.onDetachedExit?.(terminalResult);
2332
+ await options.onDetachedExit?.(terminalResult);
2257
2333
  } catch {
2258
2334
  // The authoritative result has settled. Consumer callback failures are
2259
2335
  // contained here; each consumer owns cleanup through its own finally block.