pi-subagents 0.66.0 → 0.68.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 (172) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +5 -4
  3. package/agents/evidence-auditor.md +34 -0
  4. package/agents/reviewer.md +3 -2
  5. package/docs/agents.md +43 -15
  6. package/docs/configuration.md +67 -23
  7. package/docs/extension-api.md +38 -19
  8. package/docs/missions.md +10 -2
  9. package/docs/models.md +11 -79
  10. package/docs/observability.md +22 -12
  11. package/docs/standalone-background.md +59 -0
  12. package/docs/tool-reference.md +33 -20
  13. package/docs/watchdog.md +39 -10
  14. package/docs/workflows.md +37 -13
  15. package/index.ts +5 -2
  16. package/inspector-runner.mjs +2 -2
  17. package/package.json +4 -2
  18. package/prompts/parallel-review.md +1 -1
  19. package/runner-peer-loader.mjs +24 -0
  20. package/runner-peer-preload.mjs +32 -0
  21. package/skills/pi-subagents/SKILL.md +32 -21
  22. package/skills/pi-subagents/references/constraints-and-recipes.md +3 -2
  23. package/skills/pi-subagents/references/execution-controls.md +11 -9
  24. package/skills/pi-subagents/references/management-authoring-rpc.md +0 -1
  25. package/skills/pi-subagents/references/multi-lane-orchestration.md +1 -1
  26. package/skills/pi-subagents/references/prompting-and-roles.md +17 -13
  27. package/skills/pi-subagents/references/review-and-validation.md +3 -3
  28. package/src/agents/advertised-agent-prompt.ts +34 -3
  29. package/src/agents/agent-management.ts +57 -58
  30. package/src/agents/agent-serializer.ts +4 -3
  31. package/src/agents/agents.ts +190 -71
  32. package/src/agents/builtin-names.ts +1 -0
  33. package/src/agents/chain-serializer.ts +5 -0
  34. package/src/agents/runtime-agent-registry.ts +7 -6
  35. package/src/api/delegation.ts +4 -0
  36. package/src/api/preflight.ts +94 -59
  37. package/src/api/required-child-extensions.ts +6 -0
  38. package/src/api/shared-types.ts +2 -0
  39. package/src/extension/config.ts +10 -37
  40. package/src/extension/fanout-child.ts +66 -4
  41. package/src/extension/herdr-pi-bridge.ts +160 -0
  42. package/src/extension/index.ts +62 -39
  43. package/src/extension/public-execution.ts +7 -5
  44. package/src/extension/rpc.ts +4 -0
  45. package/src/extension/schemas.ts +81 -81
  46. package/src/extension/tool-description.ts +30 -82
  47. package/src/inspectors/actions.ts +148 -0
  48. package/src/inspectors/ghostty/actions.ts +74 -0
  49. package/src/inspectors/ghostty/plugin.ts +17 -0
  50. package/src/inspectors/herdr/actions.ts +99 -179
  51. package/src/inspectors/herdr/plugin.ts +20 -0
  52. package/src/inspectors/herdr/project-panes.ts +1 -1
  53. package/src/inspectors/{herdr/inspector-runner.ts → inspector-runner.ts} +12 -12
  54. package/src/inspectors/plugins.ts +8 -0
  55. package/src/inspectors/{herdr/session-roots-codec.ts → session-roots-codec.ts} +3 -14
  56. package/src/inspectors/types.ts +51 -0
  57. package/src/intercom/intercom-bridge.ts +50 -8
  58. package/src/intercom/native-supervisor-channel.ts +44 -31
  59. package/src/policy/authority.ts +4 -0
  60. package/src/profiles/profiles.ts +12 -6
  61. package/src/runs/background/active-async-capacity.ts +4 -0
  62. package/src/runs/background/active-run-index.ts +17 -1
  63. package/src/runs/background/async-execution.ts +348 -176
  64. package/src/runs/background/async-job-tracker.ts +8 -6
  65. package/src/runs/background/async-resume.ts +17 -12
  66. package/src/runs/background/async-status.ts +15 -4
  67. package/src/runs/background/auto-drain.ts +23 -10
  68. package/src/runs/background/binary-bootstrap.ts +38 -0
  69. package/src/runs/background/chain-append.ts +1 -1
  70. package/src/runs/background/chain-root-attachment.ts +14 -33
  71. package/src/runs/background/fleet-view.ts +30 -2
  72. package/src/runs/background/notify.ts +105 -7
  73. package/src/runs/background/owned-process-tree.ts +29 -2
  74. package/src/runs/background/result-files.ts +8 -4
  75. package/src/runs/background/result-watcher.ts +19 -2
  76. package/src/runs/background/run-child-session.ts +81 -33
  77. package/src/runs/background/run-status.ts +3 -0
  78. package/src/runs/background/runner-aliases.ts +12 -33
  79. package/src/runs/background/runner-child-launch.ts +6 -1
  80. package/src/runs/background/runner-child-sessions.ts +5 -4
  81. package/src/runs/background/runner-http-dispatcher.ts +119 -0
  82. package/src/runs/background/scheduled-runs.ts +51 -18
  83. package/src/runs/background/stale-run-reconciler.ts +35 -11
  84. package/src/runs/background/steering.ts +20 -2
  85. package/src/runs/background/subagent-runner.ts +441 -304
  86. package/src/runs/background/subagent-wait.ts +176 -28
  87. package/src/runs/background/wait-completions.ts +75 -27
  88. package/src/runs/background/wait-subscriptions.ts +9 -3
  89. package/src/runs/background/wait-tool.ts +5 -3
  90. package/src/runs/foreground/async-steering-action.ts +18 -7
  91. package/src/runs/foreground/async-stop-action.ts +93 -3
  92. package/src/runs/foreground/execution.ts +134 -248
  93. package/src/runs/foreground/foreground-history.ts +2 -1
  94. package/src/runs/foreground/prompt-audit.ts +3 -1
  95. package/src/runs/foreground/subagent-executor.ts +374 -178
  96. package/src/runs/foreground/workflow-detach-reconcile.ts +2 -0
  97. package/src/runs/foreground/workflow-foreground-steering.ts +2 -1
  98. package/src/runs/shared/acceptance.ts +38 -11
  99. package/src/runs/shared/async-status-projection.ts +127 -33
  100. package/src/runs/shared/capability-ceiling.ts +2 -0
  101. package/src/runs/shared/child-hooks.ts +25 -10
  102. package/src/runs/shared/child-launch-plan.ts +15 -3
  103. package/src/runs/shared/child-launch.ts +28 -5
  104. package/src/runs/shared/child-lifecycle.ts +6 -3
  105. package/src/runs/shared/child-runtime-config.ts +8 -1
  106. package/src/runs/shared/child-session.ts +127 -52
  107. package/src/runs/shared/child-tool-plan.ts +142 -11
  108. package/src/runs/shared/completion-guard.ts +5 -3
  109. package/src/runs/shared/dynamic-fanout.ts +2 -2
  110. package/src/runs/shared/effective-system-prompt.ts +33 -0
  111. package/src/runs/shared/external-cli-contract.ts +11 -1
  112. package/src/runs/shared/external-cli-preflight.ts +6 -2
  113. package/src/runs/shared/external-cli-runner.ts +9 -7
  114. package/src/runs/shared/herdr-connection.ts +134 -0
  115. package/src/runs/shared/herdr-external-adapters.ts +169 -0
  116. package/src/runs/shared/herdr-machine.ts +279 -0
  117. package/src/runs/shared/herdr-pi-protocol.ts +59 -0
  118. package/src/runs/shared/herdr-placed-run.ts +263 -0
  119. package/src/runs/shared/llm-intent-arbiter.ts +12 -3
  120. package/src/runs/shared/model-resolution-diagnostic.ts +76 -0
  121. package/src/runs/shared/{model-fallback.ts → model-resolution.ts} +22 -235
  122. package/src/runs/shared/model-scope.ts +1 -1
  123. package/src/runs/shared/nested-events.ts +11 -2
  124. package/src/runs/shared/orca-progress-tabs.ts +1 -1
  125. package/src/runs/shared/parallel-utils.ts +7 -2
  126. package/src/runs/shared/pi-spawn.ts +10 -0
  127. package/src/runs/shared/subagent-prompt-runtime.ts +12 -4
  128. package/src/runs/shared/task-intent.ts +46 -13
  129. package/src/runs/shared/workflow-async-child-guidance.ts +18 -0
  130. package/src/runs/shared/worktree-setup-command.ts +27 -4
  131. package/src/runs/shared/worktree.ts +45 -15
  132. package/src/shared/child-cache-retention.ts +43 -0
  133. package/src/shared/fork-context.ts +15 -72
  134. package/src/shared/launch-contract.ts +68 -8
  135. package/src/shared/opencode-session-headers.ts +30 -0
  136. package/src/shared/pruned-fork.ts +1 -1
  137. package/src/shared/required-child-extensions.ts +81 -0
  138. package/src/shared/settings.ts +5 -2
  139. package/src/shared/shortcuts.ts +0 -4
  140. package/src/shared/types.ts +74 -30
  141. package/src/slash/delegation-adapters.ts +3 -1
  142. package/src/slash/delegation-request.ts +14 -0
  143. package/src/slash/slash-commands.ts +2 -7
  144. package/src/slash/subagents-admin.ts +24 -13
  145. package/src/tui/fleet-status.ts +164 -19
  146. package/src/tui/fleet.ts +16 -14
  147. package/src/tui/render.ts +168 -37
  148. package/src/watchdog/child-status.ts +28 -28
  149. package/src/watchdog/lsp-diagnostics.ts +1 -1
  150. package/src/watchdog/model-selection.ts +21 -1
  151. package/src/watchdog/permission-arbiter.ts +3 -1
  152. package/src/watchdog/register-child.ts +10 -2
  153. package/src/watchdog/register-main.ts +39 -35
  154. package/src/watchdog/render.ts +1 -1
  155. package/src/watchdog/review.ts +123 -74
  156. package/src/watchdog/rules.ts +1 -1
  157. package/src/watchdog/runtime.ts +100 -27
  158. package/src/watchdog/scope.ts +1 -1
  159. package/src/watchdog/settings.ts +3 -0
  160. package/src/watchdog/tool-actions.ts +13 -12
  161. package/src/watchdog/turn-delta.ts +23 -0
  162. package/src/watchdog/types.ts +5 -3
  163. package/src/watchdog/warning-format.ts +1 -1
  164. package/src/workflows/scripted-workflow.ts +279 -10
  165. package/src/workflows/workflow-checklist.ts +2 -2
  166. package/src/workflows/workflow-receipt.ts +21 -3
  167. package/src/workflows/workflow-resources.ts +13 -2
  168. package/runner-server-preload.mjs +0 -13
  169. package/src/runs/shared/model-exclusions.ts +0 -374
  170. package/src/runs/shared/readonly-model-continuation.ts +0 -69
  171. package/src/runs/shared/readonly-session-evidence.ts +0 -307
  172. /package/src/inspectors/{herdr/shell-command.ts → shell-command.ts} +0 -0
@@ -52,11 +52,14 @@ const DEFAULT_LIVENESS_INTERVAL_MS = 5000;
52
52
  const EVENT_REFRESH_DEBOUNCE_MS = 25;
53
53
  const WATCH_ATTACHMENT_RETRY_MS = 100;
54
54
 
55
+ const isTerminalJobStatus = (status: AsyncJobState["status"]): boolean =>
56
+ status === "complete" || status === "failed" || status === "partial" || status === "paused" || status === "rejected" || status === "stopped";
57
+
55
58
  function rememberFleetJob(state: SubagentState, job: AsyncJobState): void {
56
59
  state.fleetJobs ??= new Map();
57
60
  state.fleetJobs.set(job.asyncId, job);
58
61
  const terminal = [...state.fleetJobs.values()]
59
- .filter((candidate) => candidate.status === "complete" || candidate.status === "failed" || candidate.status === "paused" || candidate.status === "stopped")
62
+ .filter((candidate) => isTerminalJobStatus(candidate.status))
60
63
  .sort((left, right) => (right.updatedAt ?? right.startedAt ?? 0) - (left.updatedAt ?? left.startedAt ?? 0));
61
64
  for (const stale of terminal.slice(MAX_RECENT_FLEET_JOBS)) state.fleetJobs.delete(stale.asyncId);
62
65
  }
@@ -100,7 +103,6 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
100
103
  let nextWidgetAnimationAt = Date.now() + WIDGET_ANIMATION_INTERVAL_MS;
101
104
  const watch = options.watch ?? fs.watch;
102
105
  const useNativeWatcher = () => shouldUseNativeFsWatch("async-job-tracker", options.platform);
103
- const terminalStatus = (status: string) => status === "complete" || status === "failed" || status === "paused" || status === "stopped";
104
106
  const withLastUiContext = <T>(run: (ctx: ExtensionContext) => T): T | undefined => {
105
107
  const cached = state.lastUiContext;
106
108
  return withCachedUiContext(cached, () => {
@@ -445,10 +447,10 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
445
447
  if (status) {
446
448
  const previousStatus = job.status;
447
449
  job.status = status.state;
448
- if (!terminalStatus(job.status)) terminalPublications.delete(job.asyncId);
450
+ if (!isTerminalJobStatus(job.status)) terminalPublications.delete(job.asyncId);
449
451
  if (job.status === "running") runningJobIds.add(job.asyncId);
450
452
  else runningJobIds.delete(job.asyncId);
451
- if (job.status !== "complete" && job.status !== "failed" && job.status !== "paused" && job.status !== "stopped") cancelCleanup(job.asyncId);
453
+ if (!isTerminalJobStatus(job.status)) cancelCleanup(job.asyncId);
452
454
  job.sessionId = status.sessionId ?? job.sessionId;
453
455
  job.activityState = status.activityState;
454
456
  job.lastActivityAt = status.lastActivityAt ?? job.lastActivityAt;
@@ -502,7 +504,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
502
504
  job.turnBudgetExceeded = status.turnBudgetExceeded ?? job.turnBudgetExceeded;
503
505
  job.wrapUpRequested = status.wrapUpRequested ?? job.wrapUpRequested;
504
506
  job.sessionFile = status.sessionFile ?? job.sessionFile;
505
- if (terminalStatus(job.status)) {
507
+ if (isTerminalJobStatus(job.status)) {
506
508
  let publication = terminalPublications.get(job.asyncId);
507
509
  if (!publication && status.mode !== "workflow" && status.processTerminal?.state === "pending"
508
510
  && status.runId === job.asyncId && status.sessionId === state.currentSessionId
@@ -524,7 +526,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
524
526
  } else cancelCleanup(job.asyncId);
525
527
  }
526
528
  // Scan on close too: publication may have raced the payload check.
527
- if (!terminalStatus(previousStatus) || (wasPending && !publication?.pending)) options.onJobTerminal?.();
529
+ if (!isTerminalJobStatus(previousStatus) || (wasPending && !publication?.pending)) options.onJobTerminal?.();
528
530
  rememberFleetJob(state, job);
529
531
  if (!publication?.pending && !nestedRefreshFailed && !hasLiveNestedDescendants(job.nestedChildren) && (previousStatus !== job.status || !state.cleanupTimers.has(job.asyncId))) {
530
532
  scheduleCleanup(job.asyncId);
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import { DIRS, type AcceptanceInput, type AsyncStatus, type SteeringRecoveryDescriptor, type SubagentRunMode } from "../../shared/types.ts";
4
4
  import type { AgentConfig } from "../../agents/agents.ts";
5
5
  import { normalizeExtensionBindings } from "../shared/extension-bindings.ts";
6
+ import { snapshotRequiredChildExtensions } from "../../shared/required-child-extensions.ts";
6
7
  import { normalizeWorkflowLaneMetadata } from "../shared/lane-metadata.ts";
7
8
  import { validateAcceptanceInput } from "../shared/acceptance.ts";
8
9
  import { validateToolBudgetConfig } from "../shared/tool-budget.ts";
@@ -15,6 +16,7 @@ import { parallelHandoffPath, resolveRetainedWorktreeCwd } from "../shared/paral
15
16
  import { normalizeWorktreeBaseRef } from "../shared/worktree.ts";
16
17
  import { intersectThinkingCeilings, parseThinkingLevel, type ThinkingLevel } from "../../shared/thinking-ceiling.ts";
17
18
  import { assertWorkflowGraphHostSteps } from "../shared/host-step-status.ts";
19
+ import { validateIntercomBridgeConfig } from "../../intercom/intercom-bridge.ts";
18
20
  import { validateModelResponseAliases } from "../../shared/model-response-aliases.ts";
19
21
 
20
22
  export interface AsyncResumeParams {
@@ -318,12 +320,13 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
318
320
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': expected an object.`);
319
321
  const parsed = value as Record<string, unknown>;
320
322
  const allowedFields = new Set([
321
- "modelResponseAliases", "version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "modelOrigin", "fallbackModels", "thinking", "thinkingCeiling", "tools", "allowNestedSubagents", "extensions",
323
+ "modelResponseAliases", "version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "modelOrigin", "fast", "thinking", "thinkingCeiling", "tools", "allowNestedSubagents", "extensions",
322
324
  "subagentOnlyExtensions", "mcpDirectTools", "excludeTools", "mutationTools", "systemPrompt", "systemPromptMode", "inheritProjectContext", "inheritGlobalContext", "inheritSkills", "skills",
323
325
  "skillPath", "agentFilePath", "completionGuard", "memory", "outputPath", "outputMode", "structuredOutputSchema", "acceptance", "sessionDir", "artifactConfig",
324
326
  "artifactsDir", "maxOutput", "controlConfig", "context", "intercomBridge", "absoluteDeadlineAt", "initialTurnBudget", "initialToolBudget", "maxSubagentDepth", "share", "capabilityCeiling",
325
327
  "launchResolvedExtensions", "runFanoutBudget", "lane", "baseRef",
326
328
  "extensionBindings",
329
+ "requiredExtensions",
327
330
  ]);
328
331
  for (const field of Object.keys(parsed)) {
329
332
  if (!allowedFields.has(field)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': unknown field '${field}'.`);
@@ -342,6 +345,10 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
342
345
  if (parsed.capabilityCeiling !== undefined) parsed.capabilityCeiling = parseSubagentCapabilityCeiling(parsed.capabilityCeiling, `async recovery descriptor '${descriptorPath}' capabilityCeiling`);
343
346
  if (parsed.thinkingCeiling !== undefined) parsed.thinkingCeiling = parseThinkingLevel(parsed.thinkingCeiling, `async recovery descriptor '${descriptorPath}' thinkingCeiling`);
344
347
  if (parsed.extensionBindings !== undefined) parsed.extensionBindings = normalizeExtensionBindings(parsed.extensionBindings)!.value;
348
+ if (parsed.requiredExtensions !== undefined) {
349
+ try { parsed.requiredExtensions = snapshotRequiredChildExtensions(parsed.requiredExtensions, "requiredExtensions"); }
350
+ catch (error) { throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${error instanceof Error ? error.message : String(error)}`); }
351
+ }
345
352
  if (parsed.lane !== undefined) parsed.lane = normalizeWorkflowLaneMetadata(parsed.lane, `Invalid async recovery descriptor '${descriptorPath}': lane`);
346
353
  if (parsed.agentContract !== undefined) {
347
354
  if (!parsed.agentContract || typeof parsed.agentContract !== "object" || Array.isArray(parsed.agentContract)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': agentContract must be an object.`);
@@ -352,6 +359,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
352
359
  if (parsed.outputMode !== "inline" && parsed.outputMode !== "file-only") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': outputMode is invalid.`);
353
360
  if (parsed.context !== undefined && parsed.context !== "fresh" && parsed.context !== "fork") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': context is invalid.`);
354
361
  if (parsed.modelOverrideFromParent !== undefined && typeof parsed.modelOverrideFromParent !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': modelOverrideFromParent must be a boolean.`);
362
+ if (parsed.fast !== undefined && typeof parsed.fast !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': fast must be a boolean.`);
355
363
  if (parsed.modelOrigin !== undefined && parsed.modelOrigin !== "explicit" && parsed.modelOrigin !== "inherited" && parsed.modelOrigin !== "configured") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': modelOrigin must be 'explicit', 'inherited', or 'configured'.`);
356
364
  if (parsed.modelOrigin === undefined && parsed.model !== undefined) parsed.modelOrigin = parsed.modelOverrideFromParent ? "inherited" : "configured";
357
365
  for (const field of ["inheritProjectContext", "inheritSkills", "share"] as const) {
@@ -361,7 +369,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
361
369
  else if (typeof parsed.inheritGlobalContext !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': inheritGlobalContext must be a boolean.`);
362
370
  if (parsed.allowNestedSubagents !== undefined && typeof parsed.allowNestedSubagents !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': allowNestedSubagents must be a boolean.`);
363
371
  if (!Number.isInteger(parsed.maxSubagentDepth) || (parsed.maxSubagentDepth as number) < 0) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': maxSubagentDepth must be a non-negative integer.`);
364
- for (const field of ["fallbackModels", "tools", "excludeTools", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "mutationTools", "skills", "skillPath"] as const) {
372
+ for (const field of ["tools", "excludeTools", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "mutationTools", "skills", "skillPath"] as const) {
365
373
  const item = parsed[field];
366
374
  if (item !== undefined && (!Array.isArray(item) || item.some((entry) => typeof entry !== "string" || !entry.trim()))) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${field} must contain non-empty strings.`);
367
375
  }
@@ -411,14 +419,8 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
411
419
  if (!Number.isInteger(artifact.cleanupDays) || (artifact.cleanupDays as number) < 0) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': artifactConfig.cleanupDays must be a non-negative integer.`);
412
420
  }
413
421
  if (parsed.intercomBridge !== undefined) {
414
- if (!parsed.intercomBridge || typeof parsed.intercomBridge !== "object" || Array.isArray(parsed.intercomBridge)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': intercomBridge must be an object.`);
415
- const bridge = parsed.intercomBridge as Record<string, unknown>;
416
- for (const field of Object.keys(bridge)) {
417
- if (field !== "mode" && field !== "instructionFile" && field !== "resultDelivery") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': intercomBridge.${field} is not supported.`);
418
- }
419
- if (bridge.mode !== undefined && bridge.mode !== "off" && bridge.mode !== "fork-only" && bridge.mode !== "always") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': intercomBridge.mode is invalid.`);
420
- if (bridge.instructionFile !== undefined && typeof bridge.instructionFile !== "string") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': intercomBridge.instructionFile must be a string.`);
421
- if (bridge.resultDelivery !== undefined && typeof bridge.resultDelivery !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': intercomBridge.resultDelivery must be a boolean.`);
422
+ const bridge = validateIntercomBridgeConfig({ value: parsed.intercomBridge, label: "intercomBridge" });
423
+ if (!bridge.ok) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${bridge.error}`);
422
424
  }
423
425
  if (parsed.controlConfig !== undefined) {
424
426
  if (!parsed.controlConfig || typeof parsed.controlConfig !== "object" || Array.isArray(parsed.controlConfig)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': controlConfig must be an object.`);
@@ -555,7 +557,11 @@ export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncR
555
557
  const index = requestedIndex ?? 0;
556
558
  if (!Number.isInteger(index)) throw new Error(`Async run '${runId}' index must be an integer.`);
557
559
  if (index < 0 || index >= stepCount) throw new Error(`Async run '${runId}' has ${stepCount} children. Index ${index} is out of range.`);
558
- const agent = statusSteps[index]?.agent ?? resultSteps[index]?.agent ?? result?.agent;
560
+ const selectedStatusStep = statusSteps[index];
561
+ if (selectedStatusStep?.status === "stopped" || selectedStatusStep?.stopped === true) {
562
+ throw new Error(`Async run '${runId}' child ${index} was stopped and cannot be resumed. Start a new run instead.`);
563
+ }
564
+ const agent = selectedStatusStep?.agent ?? resultSteps[index]?.agent ?? result?.agent;
559
565
  if (!agent) throw new Error(`Could not determine child agent for async run '${runId}'.`);
560
566
  if (recoveryDescriptor && recoveryDescriptor.agent !== agent) throw new Error(`Async run '${runId}' has a recovery descriptor for '${recoveryDescriptor.agent}', not '${agent}'.`);
561
567
  const sessionFile = statusSteps[index]?.sessionFile
@@ -600,7 +606,6 @@ export function applySteeringRecoveryAgentConfig(agentConfig: AgentConfig, descr
600
606
  ...agentConfig,
601
607
  model: descriptor.model,
602
608
  modelProvider: descriptor.modelProvider,
603
- fallbackModels: descriptor.fallbackModels ? [...descriptor.fallbackModels] : undefined,
604
609
  thinking: descriptor.thinking,
605
610
  maxThinking: intersectThinkingCeilings(descriptor.thinkingCeiling, agentConfig.maxThinking),
606
611
  tools: descriptor.tools ? [...descriptor.tools] : undefined,
@@ -26,6 +26,7 @@ import { workflowGraphStageNodes } from "../shared/workflow-graph.ts";
26
26
  import { formatTimeoutRecoveryLines, projectTimeoutRecovery } from "../shared/mutation-evidence.ts";
27
27
  import { formatWorkflowChecklistText, projectWorkflowChecklist } from "../../workflows/workflow-checklist.ts";
28
28
  import type { RawDrainStatusObserver } from "../shared/readonly-drain-observation.ts";
29
+ import { childWatchdogProgressForModel } from "../../watchdog/child-status.ts";
29
30
 
30
31
  interface AsyncRunStepSummary {
31
32
  index: number;
@@ -66,7 +67,7 @@ interface AsyncRunStepSummary {
66
67
  model?: string;
67
68
  contextLimit?: number;
68
69
  thinking?: string;
69
- attemptedModels?: string[];
70
+ requestedModel?: string;
70
71
  sessionFile?: string;
71
72
  transcriptPath?: string;
72
73
  error?: string;
@@ -367,7 +368,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
367
368
  ...(step.contextLimit !== undefined ? { contextLimit: step.contextLimit } : {}),
368
369
  ...(step.thinking ? { thinking: step.thinking } : {}),
369
370
  ...(step.thinkingCeiling ? { thinkingCeiling: step.thinkingCeiling } : {}),
370
- ...(step.attemptedModels ? { attemptedModels: step.attemptedModels } : {}),
371
+ ...(step.requestedModel ? { requestedModel: step.requestedModel } : {}),
371
372
  ...(step.sessionFile ? { sessionFile: step.sessionFile } : {}),
372
373
  ...(step.transcriptPath ? { transcriptPath: step.transcriptPath } : {}),
373
374
  ...(step.error ? { error: step.error } : {}),
@@ -387,7 +388,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
387
388
  ...(step.execution ? { execution: step.execution } : {}),
388
389
  ...(step.review ? { review: step.review } : {}),
389
390
  ...(step.effects ? { effects: step.effects } : {}),
390
- ...(step.watchdog ? { watchdog: step.watchdog } : {}),
391
+ ...(step.watchdog ? { watchdog: childWatchdogProgressForModel(step.watchdog) } : {}),
391
392
  ...(step.processTerminal ? { processTerminal: sanitizeProcessTerminal(step.processTerminal, { runId: status.runId, runnerProcessInstanceId: step.processTerminal.runnerProcessInstanceId }, `${path.join(asyncDir, "status.json")} step ${index}`) } : {}),
392
393
  ...(timeoutRecovery ? { timeoutRecovery } : {}),
393
394
  ...(step.capabilityCeiling ? { capabilityCeiling: step.capabilityCeiling } : {}),
@@ -545,6 +546,14 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
545
546
  const asyncDir = path.join(asyncDirRoot, entry);
546
547
  let status: (AsyncStatus & { cwd?: string }) | null;
547
548
  try {
549
+ // Reconciliation can rewrite state; session-scoped discovery does not own foreign runs.
550
+ if (options.sessionId !== undefined) {
551
+ const stored = readStatus(asyncDir);
552
+ if (stored && stored.sessionId !== options.sessionId) {
553
+ observeStatus?.(stored);
554
+ continue;
555
+ }
556
+ }
548
557
  const reconciliation = options.reconcile === false
549
558
  ? undefined
550
559
  : reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now }, observeStatus);
@@ -563,7 +572,9 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
563
572
  }
564
573
  if (activeEntries.has(entry) && !isActiveAsyncState(status.state)) {
565
574
  const processTerminal = readProcessTerminal(asyncDir, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId });
566
- if (processTerminal?.state === "observed" || (activeRunMarkerAgeMs(asyncDir, options.now?.()) ?? 0) > DEFAULT_STALE_TERMINAL_ACTIVE_MARKER_MS) releaseActiveRunIndex(asyncDir);
575
+ if (processTerminal?.state === "observed" || (activeRunMarkerAgeMs(asyncDir, options.now?.()) ?? 0) > DEFAULT_STALE_TERMINAL_ACTIVE_MARKER_MS) {
576
+ updateActiveRunIndex(asyncDir, status.state, status.toolCallId, { terminalIndexBeforeRelease: true });
577
+ }
567
578
  }
568
579
  if (status.displayDismissedAt !== undefined) continue;
569
580
  // Filter before the nested-route lookup: the lookup builds an index over
@@ -1,13 +1,13 @@
1
1
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
2
  import { snapshotBackgroundWork } from "../../api/background-work.ts";
3
- import { DIRS, type Details, type SubagentState } from "../../shared/types.ts";
3
+ import type { Details, SubagentState } from "../../shared/types.ts";
4
4
  import { listAsyncRuns } from "./async-status.ts";
5
5
  import type { ReadonlyDrainObservation } from "../shared/readonly-drain-observation.ts";
6
- import { waitForSubagents, type SubagentWaitDeps, type SubagentWaitParams, type WaitEventBus } from "./subagent-wait.ts";
6
+ import { waitForSubagents, waitRunScopes, type SubagentWaitDeps, type SubagentWaitParams, type WaitEventBus } from "./subagent-wait.ts";
7
7
 
8
8
  export const DEFAULT_AUTO_DRAIN_TIMEOUT_MS = 30 * 60 * 1000;
9
9
 
10
- export interface AutoDrainDeps {
10
+ export interface AutoDrainDeps extends Pick<SubagentWaitDeps, "asyncDirRoot" | "resultsDir" | "nestedRootRunId"> {
11
11
  state: SubagentState;
12
12
  events?: WaitEventBus;
13
13
  timeoutMs?: number;
@@ -18,36 +18,43 @@ export interface AutoDrainDeps {
18
18
  deps: SubagentWaitDeps,
19
19
  ) => Promise<AgentToolResult<Details>>;
20
20
  hasWork?: (sessionId: string, nowMs: number) => boolean;
21
+ hasPendingSupervisorRequest?: () => boolean;
21
22
  }
22
23
 
23
24
  function resultText(value: AgentToolResult<Details>): string {
24
25
  return value.content.map((part) => part.type === "text" ? part.text : "").join(" ").trim();
25
26
  }
26
27
 
27
- function hasOutstandingWork(sessionId: string, nowMs: number, observation?: ReadonlyDrainObservation): boolean {
28
- const asyncRuns = listAsyncRuns(DIRS.async, {
28
+ function hasOutstandingWork(deps: AutoDrainDeps, sessionId: string, nowMs: number, observation?: ReadonlyDrainObservation): boolean {
29
+ const asyncRuns = waitRunScopes(deps).flatMap(({ asyncDirRoot, resultsDir }) => listAsyncRuns(asyncDirRoot, {
29
30
  states: ["queued", "running"],
30
31
  sessionId,
31
- resultsDir: DIRS.results,
32
+ resultsDir,
32
33
  now: () => nowMs,
33
- }, observation?.status);
34
- return asyncRuns.length > 0 || snapshotBackgroundWork(sessionId, nowMs).items.length > 0;
34
+ includeNested: false,
35
+ }, observation?.status));
36
+ const detachedForeground = [...(deps.state.foregroundRuns?.values() ?? [])].some((run) =>
37
+ run.sessionId === sessionId && run.children.some((child) => child.status === "detached")
38
+ );
39
+ return asyncRuns.length > 0 || snapshotBackgroundWork(sessionId, nowMs).items.length > 0 || detachedForeground;
35
40
  }
36
41
 
37
42
  /** Drain all work owned by the current headless session, including work added while draining. */
38
43
  export async function drainOutstandingWork(deps: AutoDrainDeps, observation?: ReadonlyDrainObservation): Promise<void> {
39
44
  const sessionId = deps.state.currentSessionId;
40
- observation?.begin(sessionId, !deps.hasWork && !deps.wait && !deps.now);
45
+ observation?.begin(sessionId, !deps.hasWork && !deps.wait && !deps.now && !deps.hasPendingSupervisorRequest);
41
46
  try {
42
47
  if (!sessionId) throw new Error("Cannot auto-drain background work without an active session identity.");
43
48
  const now = deps.now ?? Date.now;
44
49
  const timeoutMs = deps.timeoutMs ?? DEFAULT_AUTO_DRAIN_TIMEOUT_MS;
45
50
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("Auto-drain timeoutMs must be a positive finite number.");
46
51
  const deadlineAt = now() + timeoutMs;
47
- const hasWork = deps.hasWork ?? (observation ? (id: string, time: number) => hasOutstandingWork(id, time, observation) : hasOutstandingWork);
52
+ const hasWork = deps.hasWork ?? ((id: string, time: number) => hasOutstandingWork(deps, id, time, observation));
48
53
  const wait = deps.wait ?? waitForSubagents;
49
54
 
50
55
  while (true) {
56
+ if (deps.state.currentSessionId !== sessionId) throw new Error("Auto-drain stopped because the active session changed.");
57
+ if (deps.hasPendingSupervisorRequest?.()) break;
51
58
  const work = hasWork(sessionId, now());
52
59
  observation?.predicate(work);
53
60
  if (!work) break;
@@ -60,16 +67,22 @@ export async function drainOutstandingWork(deps: AutoDrainDeps, observation?: Re
60
67
  undefined,
61
68
  {
62
69
  state: deps.state,
70
+ asyncDirRoot: deps.asyncDirRoot,
71
+ resultsDir: deps.resultsDir,
72
+ nestedRootRunId: deps.nestedRootRunId,
63
73
  events: deps.events,
64
74
  now,
65
75
  stopOnAttention: false,
66
76
  failOnFailedRuns: true,
67
77
  failOnAttention: true,
78
+ hasPendingSupervisorRequest: deps.hasPendingSupervisorRequest,
68
79
  },
69
80
  );
70
81
  if (waitResult.isError) {
71
82
  throw new Error(`Auto-drain failed for session '${sessionId}': ${resultText(waitResult) || "bg_wait returned an error without details"}.`);
72
83
  }
84
+ if (waitResult.details.wait?.reason === "supervisor_request") break;
85
+ if (deps.hasPendingSupervisorRequest?.()) break;
73
86
  }
74
87
  observation?.complete();
75
88
  } catch (error) {
@@ -0,0 +1,38 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as sdk from "@earendil-works/pi-coding-agent";
4
+ import { installRunnerHttpDispatcher } from "./runner-http-dispatcher.ts";
5
+ import { runConfiguredSubagent, type SubagentRunConfig } from "./subagent-runner.ts";
6
+ import { getAgentDir } from "../../shared/utils.ts";
7
+
8
+ /**
9
+ * Pi's extension loader supplies the embedded SDK. Bare Bun/Node cannot replace
10
+ * it: an apparent import success can be an auto-installed, different SDK.
11
+ * Await the configured runner inside extension initialization so the outer Pi
12
+ * session/RPC loop never starts. Exit only after shared disposal/lease release.
13
+ * Derived from xz-dev's PR #2049, commit 910807bfefcf9ee41d73fa25ec86dcd75ab8f4b2.
14
+ */
15
+ export default async function runBinaryBootstrap(): Promise<never> {
16
+ const configPath = process.env.PI_SUBAGENT_RUNNER_CONFIG;
17
+ delete process.env.PI_SUBAGENT_RUNNER_CONFIG;
18
+ try {
19
+ if (!configPath || !path.isAbsolute(configPath)) throw new Error("Missing absolute PI_SUBAGENT_RUNNER_CONFIG path");
20
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8")) as SubagentRunConfig;
21
+ if (!config || typeof config.id !== "string" || typeof config.asyncDir !== "string" || !Array.isArray(config.steps)) {
22
+ throw new Error("Invalid binary runner configuration");
23
+ }
24
+ try {
25
+ fs.unlinkSync(configPath);
26
+ } catch {
27
+ // Temp-config cleanup is best effort, as in the Node entrypoint.
28
+ }
29
+ // Pi applies httpIdleTimeoutMs to its dispatcher only after extension
30
+ // factories return; this factory never does, so install the runner's own.
31
+ installRunnerHttpDispatcher({ agentDir: getAgentDir(), cwd: process.cwd() });
32
+ await runConfiguredSubagent(config, { loadPiCodingAgent: async () => sdk });
33
+ process.exit(0);
34
+ } catch (error) {
35
+ console.error("Subagent binary runner error:", error);
36
+ process.exit(1);
37
+ }
38
+ }
@@ -177,7 +177,7 @@ function statusStepForTask(task: RunnerSubagentStep): StatusStep {
177
177
  model: task.model,
178
178
  ...(task.contextLimit !== undefined ? { contextLimit: task.contextLimit } : {}),
179
179
  thinking: task.thinking,
180
- attemptedModels: task.modelCandidates && task.modelCandidates.length > 0 ? task.modelCandidates : task.model ? [task.model] : undefined,
180
+ requestedModel: task.requestedModel,
181
181
  recentTools: [],
182
182
  recentOutput: [],
183
183
  };
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { resultFilePath, resultPayloadPathForSessionRun } from "./result-files.ts";
4
- import type { AcceptanceLedger, ArtifactPaths, AsyncStatus, CostSummary, EffectsProjection, ExecutionProjection, ModelAttempt, Usage } from "../../shared/types.ts";
4
+ import type { AcceptanceLedger, ArtifactPaths, AsyncStatus, CostSummary, EffectsProjection, ExecutionProjection, Usage } from "../../shared/types.ts";
5
5
  import { readStatus } from "../../shared/utils.ts";
6
6
 
7
7
  export interface ImportedAsyncRoot {
@@ -13,6 +13,7 @@ export interface ImportedAsyncRoot {
13
13
 
14
14
  export interface ImportedAsyncRootResult {
15
15
  agent: string;
16
+ importedPublication?: { sessionId?: string; toolCallId?: string };
16
17
  /** Human-readable display name for the child session, when derived at launch. */
17
18
  sessionName?: string;
18
19
  output: string;
@@ -22,8 +23,7 @@ export interface ImportedAsyncRootResult {
22
23
  sessionFile?: string;
23
24
  intercomTarget?: string;
24
25
  model?: string;
25
- attemptedModels?: string[];
26
- modelAttempts?: ModelAttempt[];
26
+ requestedModel?: string;
27
27
  contextOverflow?: boolean;
28
28
  totalCost?: CostSummary;
29
29
  usage?: Usage;
@@ -43,6 +43,8 @@ export interface ImportedAsyncRootResult {
43
43
  }
44
44
 
45
45
  interface AsyncResultFile {
46
+ sessionId?: string;
47
+ toolCallId?: string;
46
48
  state?: string;
47
49
  success?: boolean;
48
50
  summary?: string;
@@ -62,8 +64,7 @@ interface AsyncResultFile {
62
64
  sessionFile?: string;
63
65
  intercomTarget?: string;
64
66
  model?: string;
65
- attemptedModels?: string[];
66
- modelAttempts?: ModelAttempt[];
67
+ requestedModel?: string;
67
68
  contextOverflow?: boolean;
68
69
  totalCost?: CostSummary;
69
70
  usage?: Usage;
@@ -82,23 +83,6 @@ interface AsyncResultFile {
82
83
  const TERMINAL_STATES = new Set(["complete", "failed", "partial", "paused", "stopped"]);
83
84
  const TERMINAL_STEP_STATUSES = new Set(["complete", "completed", "failed", "partial", "paused", "stopped"]);
84
85
 
85
- function usageFromAttempts(attempts: ModelAttempt[] | undefined): Usage | undefined {
86
- if (!attempts?.length) return undefined;
87
- const usage: Usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
88
- for (const attempt of attempts) {
89
- if (!attempt.usage) continue;
90
- usage.input += attempt.usage.input;
91
- usage.output += attempt.usage.output;
92
- usage.cacheRead += attempt.usage.cacheRead;
93
- usage.cacheWrite += attempt.usage.cacheWrite;
94
- usage.cost += attempt.usage.cost;
95
- usage.turns += attempt.usage.turns;
96
- }
97
- return usage.input !== 0 || usage.output !== 0 || usage.cacheRead !== 0 || usage.cacheWrite !== 0 || usage.cost !== 0 || usage.turns !== 0
98
- ? usage
99
- : undefined;
100
- }
101
-
102
86
  function readResultFile(resultPath: string): AsyncResultFile | undefined {
103
87
  try {
104
88
  return JSON.parse(fs.readFileSync(resultPath, "utf-8")) as AsyncResultFile;
@@ -153,7 +137,6 @@ function outputFromTerminalStatus(root: ImportedAsyncRoot, status: AsyncStatus,
153
137
  const execution = step?.execution ?? (partialStatus
154
138
  ? { status: "partial" as const, success: false, exitCode: 1, error: message }
155
139
  : undefined);
156
- const usage = usageFromAttempts(step?.modelAttempts);
157
140
  return {
158
141
  agent,
159
142
  output: message,
@@ -165,11 +148,9 @@ function outputFromTerminalStatus(root: ImportedAsyncRoot, status: AsyncStatus,
165
148
  ...(step?.sessionName ? { sessionName: step.sessionName } : {}),
166
149
  ...(step?.sessionFile ?? status.sessionFile ? { sessionFile: step?.sessionFile ?? status.sessionFile } : {}),
167
150
  ...(step?.model ? { model: step.model } : {}),
168
- ...(step?.attemptedModels ? { attemptedModels: step.attemptedModels } : {}),
169
- ...(step?.modelAttempts ? { modelAttempts: step.modelAttempts } : {}),
151
+ ...(step?.requestedModel ? { requestedModel: step.requestedModel } : {}),
170
152
  ...(step?.contextOverflow ? { contextOverflow: true } : {}),
171
153
  ...(step?.totalCost ? { totalCost: step.totalCost } : {}),
172
- ...(usage ? { usage } : {}),
173
154
  ...(step?.structuredOutput !== undefined ? { structuredOutput: step.structuredOutput } : {}),
174
155
  ...(step?.structuredOutputPath ? { structuredOutputPath: step.structuredOutputPath } : {}),
175
156
  ...(step?.structuredOutputSchemaPath ? { structuredOutputSchemaPath: step.structuredOutputSchemaPath } : {}),
@@ -182,7 +163,6 @@ function outputFromTerminalStatus(root: ImportedAsyncRoot, status: AsyncStatus,
182
163
 
183
164
  function outputFromTimeout(root: ImportedAsyncRoot, status: AsyncStatus | null, message: string): ImportedAsyncRootResult {
184
165
  const step = selectedStatusStep(status, root.index);
185
- const usage = usageFromAttempts(step?.modelAttempts);
186
166
  return {
187
167
  agent: step?.agent ?? status?.steps?.[root.index]?.agent ?? "subagent",
188
168
  output: message,
@@ -193,11 +173,9 @@ function outputFromTimeout(root: ImportedAsyncRoot, status: AsyncStatus | null,
193
173
  ...(step?.sessionName ? { sessionName: step.sessionName } : {}),
194
174
  ...(step?.sessionFile ?? status?.sessionFile ? { sessionFile: step?.sessionFile ?? status?.sessionFile } : {}),
195
175
  ...(step?.model ? { model: step.model } : {}),
196
- ...(step?.attemptedModels ? { attemptedModels: step.attemptedModels } : {}),
197
- ...(step?.modelAttempts ? { modelAttempts: step.modelAttempts } : {}),
176
+ ...(step?.requestedModel ? { requestedModel: step.requestedModel } : {}),
198
177
  ...(step?.contextOverflow ? { contextOverflow: true } : {}),
199
178
  ...(step?.totalCost ? { totalCost: step.totalCost } : {}),
200
- ...(usage ? { usage } : {}),
201
179
  ...(step?.transcriptPath ? { transcriptPath: step.transcriptPath } : {}),
202
180
  };
203
181
  }
@@ -213,9 +191,13 @@ function buildImportedResult(root: ImportedAsyncRoot, status: AsyncStatus | null
213
191
  const success = state === "complete" && !timedOut && !stopped;
214
192
  const error = child?.error ?? (success ? undefined : stopped ? "Subagent stopped by user." : result.error ?? result.summary ?? status?.error ?? `Attached async root ${root.runId} did not complete successfully.`);
215
193
  const execution = state ? { status: state === "complete" ? "completed" as const : state, success, exitCode: success ? 0 : 1, ...(error ? { error } : {}) } : undefined;
216
- const usage = child?.usage ?? usageFromAttempts(step?.modelAttempts);
194
+ const usage = child?.usage;
217
195
  return {
218
196
  agent,
197
+ importedPublication: {
198
+ ...(typeof result.sessionId === "string" ? { sessionId: result.sessionId } : {}),
199
+ ...(typeof result.toolCallId === "string" ? { toolCallId: result.toolCallId } : {}),
200
+ },
219
201
  output: success ? output : (output || error || ""),
220
202
  success,
221
203
  exitCode: success ? 0 : 1,
@@ -226,8 +208,7 @@ function buildImportedResult(root: ImportedAsyncRoot, status: AsyncStatus | null
226
208
  ...(child?.sessionFile ?? step?.sessionFile ?? status?.sessionFile ? { sessionFile: child?.sessionFile ?? step?.sessionFile ?? status?.sessionFile } : {}),
227
209
  ...(child?.intercomTarget ? { intercomTarget: child.intercomTarget } : {}),
228
210
  ...(child?.model ?? step?.model ? { model: child?.model ?? step?.model } : {}),
229
- ...(child?.attemptedModels ?? step?.attemptedModels ? { attemptedModels: child?.attemptedModels ?? step?.attemptedModels } : {}),
230
- ...(child?.modelAttempts ?? step?.modelAttempts ? { modelAttempts: child?.modelAttempts ?? step?.modelAttempts } : {}),
211
+ ...(child?.requestedModel ?? step?.requestedModel ? { requestedModel: child?.requestedModel ?? step?.requestedModel } : {}),
231
212
  ...(child?.contextOverflow || step?.contextOverflow ? { contextOverflow: true } : {}),
232
213
  ...(child?.totalCost ?? step?.totalCost ? { totalCost: child?.totalCost ?? step?.totalCost } : {}),
233
214
  ...(usage ? { usage } : {}),
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
4
- import { safeTerminalText } from "../../shared/display-text.ts";
4
+ import { safeTerminalText, truncateDisplayText } from "../../shared/display-text.ts";
5
5
  import { formatDuration, formatModelThinking, formatTokens, shortenPath } from "../../shared/formatters.ts";
6
6
  import { formatActivityLabel } from "../../shared/status-format.ts";
7
7
  import {
@@ -23,6 +23,10 @@ import { isTrustedRecordedSessionFile } from "../../shared/session-file-trust.ts
23
23
  const DEFAULT_TRANSCRIPT_LINES = 80;
24
24
  const MAX_TRANSCRIPT_LINES = 500;
25
25
  const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
26
+ const MAX_TRANSCRIPT_LINE_CHARS = 2000;
27
+ const MAX_TRANSCRIPT_BODY_BYTES = 32 * 1024;
28
+ const TRANSCRIPT_LINE_OMISSION = "… [content omitted]";
29
+ const TRANSCRIPT_BODY_OMISSION = " … [earlier lines omitted]";
26
30
 
27
31
  type ForegroundControl = SubagentState["foregroundControls"] extends Map<string, infer T> ? T : never;
28
32
  type ForegroundRun = NonNullable<SubagentState["foregroundRuns"]> extends Map<string, infer T> ? T : never;
@@ -494,7 +498,31 @@ function appendTranscriptBody(lines: string[], sourceLabel: string, sourceLines:
494
498
  lines.push(" (no transcript lines available yet)");
495
499
  return;
496
500
  }
497
- for (const line of sourceLines) lines.push(` ${line}`);
501
+ // Bound the rendered body, not the raw input: terminal escaping can expand it.
502
+ // Keep line prefixes (roles/tool names), then prefer whole recent lines. The
503
+ // byte budget includes indentation, separators and omission markers, but not
504
+ // the source label, run metadata, warnings or artifact paths above the body.
505
+ const body: string[] = [];
506
+ let bytes = 0;
507
+ for (let index = sourceLines.length - 1; index >= 0; index--) {
508
+ // Preserve assembled-line binary detection (including indentation). A
509
+ // binary placeholder replaces the whole line and must not be reindented.
510
+ const safe = safeTerminalText(` ${sourceLines[index]!}`);
511
+ const lineLimit = MAX_TRANSCRIPT_LINE_CHARS + 2;
512
+ const line = safe.length <= lineLimit
513
+ ? safe
514
+ : truncateDisplayText(safe, lineLimit - TRANSCRIPT_LINE_OMISSION.length) + TRANSCRIPT_LINE_OMISSION;
515
+ const size = Buffer.byteLength(line) + 1;
516
+ if (bytes + size > MAX_TRANSCRIPT_BODY_BYTES) {
517
+ const markerBytes = Buffer.byteLength(TRANSCRIPT_BODY_OMISSION) + 1;
518
+ while (bytes + markerBytes > MAX_TRANSCRIPT_BODY_BYTES) bytes -= Buffer.byteLength(body.pop()!) + 1;
519
+ body.push(TRANSCRIPT_BODY_OMISSION);
520
+ break;
521
+ }
522
+ body.push(line);
523
+ bytes += size;
524
+ }
525
+ lines.push(...body.reverse());
498
526
  }
499
527
 
500
528
  export function formatAsyncRunTranscript(status: AsyncStatus, asyncDir: string, options: TranscriptOptions = {}): string {