pi-subagents 0.65.1 → 0.67.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 (148) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +5 -4
  3. package/agents/evidence-auditor.md +34 -0
  4. package/agents/researcher.md +23 -13
  5. package/agents/reviewer.md +3 -2
  6. package/docs/agents.md +20 -3
  7. package/docs/configuration.md +25 -5
  8. package/docs/extension-api.md +124 -18
  9. package/docs/missions.md +8 -0
  10. package/docs/models.md +59 -2
  11. package/docs/observability.md +46 -6
  12. package/docs/standalone-background.md +49 -0
  13. package/docs/tool-reference.md +20 -10
  14. package/docs/watchdog.md +35 -4
  15. package/docs/workflows.md +40 -19
  16. package/inspector-runner.mjs +2 -2
  17. package/package.json +2 -1
  18. package/prompts/parallel-review.md +1 -1
  19. package/{runner-server-preload.mjs → runner-peer-preload.mjs} +8 -3
  20. package/skills/pi-subagents/SKILL.md +14 -0
  21. package/skills/pi-subagents/references/execution-controls.md +20 -5
  22. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
  23. package/skills/pi-subagents/references/prompting-and-roles.md +2 -2
  24. package/src/agents/advertised-agent-prompt.ts +94 -0
  25. package/src/agents/agent-management.ts +14 -1
  26. package/src/agents/agent-serializer.ts +2 -0
  27. package/src/agents/agents.ts +14 -0
  28. package/src/agents/builtin-names.ts +1 -0
  29. package/src/api/delegation.ts +4 -0
  30. package/src/api/preflight.ts +76 -45
  31. package/src/api/shared-types.ts +3 -1
  32. package/src/api/workflow-resources.ts +6 -0
  33. package/src/extension/fanout-child.ts +63 -4
  34. package/src/extension/index.ts +58 -8
  35. package/src/extension/public-execution.ts +4 -3
  36. package/src/extension/rpc.ts +8 -21
  37. package/src/extension/schemas.ts +71 -80
  38. package/src/extension/tool-description.ts +29 -81
  39. package/src/inspectors/actions.ts +148 -0
  40. package/src/inspectors/ghostty/actions.ts +74 -0
  41. package/src/inspectors/ghostty/plugin.ts +17 -0
  42. package/src/inspectors/herdr/actions.ts +99 -179
  43. package/src/inspectors/herdr/plugin.ts +20 -0
  44. package/src/inspectors/herdr/project-panes.ts +1 -1
  45. package/src/inspectors/{herdr/inspector-runner.ts → inspector-runner.ts} +12 -12
  46. package/src/inspectors/plugins.ts +8 -0
  47. package/src/inspectors/{herdr/session-roots-codec.ts → session-roots-codec.ts} +3 -14
  48. package/src/inspectors/types.ts +51 -0
  49. package/src/intercom/intercom-bridge.ts +50 -8
  50. package/src/intercom/native-supervisor-channel.ts +104 -67
  51. package/src/runs/background/active-async-capacity.ts +22 -18
  52. package/src/runs/background/async-execution.ts +45 -56
  53. package/src/runs/background/async-job-tracker.ts +35 -3
  54. package/src/runs/background/async-resume.ts +5 -9
  55. package/src/runs/background/async-status-snapshot.ts +10 -12
  56. package/src/runs/background/async-status.ts +17 -9
  57. package/src/runs/background/auto-drain.ts +44 -30
  58. package/src/runs/background/binary-bootstrap.ts +33 -0
  59. package/src/runs/background/chain-root-attachment.ts +8 -0
  60. package/src/runs/background/control-channel.ts +78 -44
  61. package/src/runs/background/fleet-view.ts +30 -2
  62. package/src/runs/background/notify.ts +117 -13
  63. package/src/runs/background/owned-process-tree.ts +35 -8
  64. package/src/runs/background/process-terminal.ts +23 -23
  65. package/src/runs/background/run-child-session.ts +121 -36
  66. package/src/runs/background/run-status.ts +78 -5
  67. package/src/runs/background/runner-aliases.ts +28 -9
  68. package/src/runs/background/runner-child-launch.ts +88 -0
  69. package/src/runs/background/runner-child-sessions.ts +5 -4
  70. package/src/runs/background/scheduled-runs.ts +40 -13
  71. package/src/runs/background/stale-run-reconciler.ts +3 -1
  72. package/src/runs/background/steering.ts +20 -2
  73. package/src/runs/background/subagent-runner.ts +458 -239
  74. package/src/runs/background/subagent-wait.ts +54 -8
  75. package/src/runs/background/wait-completions.ts +4 -0
  76. package/src/runs/background/wait-tool.ts +1 -1
  77. package/src/runs/foreground/async-steering-action.ts +37 -7
  78. package/src/runs/foreground/execution.ts +145 -56
  79. package/src/runs/foreground/prompt-audit.ts +3 -1
  80. package/src/runs/foreground/subagent-executor.ts +584 -297
  81. package/src/runs/foreground/workflow-detach-reconcile.ts +10 -5
  82. package/src/runs/foreground/workflow-foreground-steering.ts +57 -2
  83. package/src/runs/shared/acceptance.ts +7 -4
  84. package/src/runs/shared/agent-contract.ts +1 -1
  85. package/src/runs/shared/async-status-projection.ts +51 -47
  86. package/src/runs/shared/capability-ceiling.ts +2 -0
  87. package/src/runs/shared/child-hooks.ts +167 -3
  88. package/src/runs/shared/child-launch.ts +28 -13
  89. package/src/runs/shared/child-lifecycle.ts +6 -3
  90. package/src/runs/shared/child-runtime-config.ts +3 -1
  91. package/src/runs/shared/child-session.ts +75 -8
  92. package/src/runs/shared/child-tool-plan.ts +124 -5
  93. package/src/runs/shared/completion-evidence.ts +2 -2
  94. package/src/runs/shared/completion-guard.ts +6 -3
  95. package/src/runs/shared/effective-system-prompt.ts +33 -0
  96. package/src/runs/shared/external-cli-runner.ts +9 -7
  97. package/src/runs/shared/host-step-status.ts +11 -11
  98. package/src/runs/shared/llm-intent-arbiter.ts +21 -11
  99. package/src/runs/shared/model-fallback.ts +12 -6
  100. package/src/runs/shared/nested-events.ts +5 -5
  101. package/src/runs/shared/orca-progress-tabs.ts +7 -1
  102. package/src/runs/shared/parallel-handoff.ts +57 -12
  103. package/src/runs/shared/parallel-utils.ts +2 -2
  104. package/src/runs/shared/pi-spawn.ts +10 -0
  105. package/src/runs/shared/readonly-drain-observation.ts +42 -0
  106. package/src/runs/shared/readonly-model-continuation.ts +69 -0
  107. package/src/runs/shared/readonly-session-evidence.ts +307 -0
  108. package/src/runs/shared/run-fanout-budget.ts +8 -8
  109. package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
  110. package/src/runs/shared/subagent-prompt-runtime.ts +20 -4
  111. package/src/runs/shared/task-intent.ts +46 -13
  112. package/src/runs/shared/workflow-async-child-guidance.ts +18 -0
  113. package/src/runs/shared/worktree-setup-command.ts +190 -0
  114. package/src/runs/shared/worktree.ts +366 -208
  115. package/src/shared/fork-context.ts +15 -72
  116. package/src/shared/launch-contract.ts +65 -2
  117. package/src/shared/opencode-session-headers.ts +30 -0
  118. package/src/shared/types.ts +85 -61
  119. package/src/shared/utils.ts +7 -2
  120. package/src/shared/workflow-child-permit.ts +18 -13
  121. package/src/slash/delegation-adapters.ts +3 -1
  122. package/src/slash/delegation-request.ts +14 -0
  123. package/src/slash/slash-commands.ts +2 -1
  124. package/src/slash/subagents-admin.ts +11 -4
  125. package/src/tui/fleet-status.ts +164 -19
  126. package/src/tui/fleet.ts +27 -19
  127. package/src/tui/render.ts +172 -33
  128. package/src/watchdog/child-status.ts +8 -0
  129. package/src/watchdog/model-selection.ts +20 -0
  130. package/src/watchdog/permission-arbiter.ts +3 -1
  131. package/src/watchdog/register-child.ts +1 -0
  132. package/src/watchdog/register-main.ts +31 -27
  133. package/src/watchdog/review.ts +132 -67
  134. package/src/watchdog/runtime.ts +82 -20
  135. package/src/watchdog/scope.ts +1 -1
  136. package/src/watchdog/settings.ts +9 -3
  137. package/src/watchdog/tool-actions.ts +13 -12
  138. package/src/watchdog/turn-delta.ts +23 -0
  139. package/src/watchdog/types.ts +4 -0
  140. package/src/workflows/chat-progress.ts +3 -3
  141. package/src/workflows/scripted-workflow.ts +275 -17
  142. package/src/workflows/workflow-checklist.ts +13 -17
  143. package/src/workflows/workflow-child-summary.ts +57 -8
  144. package/src/workflows/workflow-preflight.ts +19 -19
  145. package/src/workflows/workflow-receipt.ts +3 -3
  146. package/src/workflows/workflow-resources.ts +96 -21
  147. package/src/workflows/workflow-settlement.ts +3 -0
  148. /package/src/inspectors/{herdr/shell-command.ts → shell-command.ts} +0 -0
@@ -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
  }
@@ -29,7 +29,7 @@ const ORCA_CREATE_WATCHDOG_SCRIPT = [
29
29
  "function exists(file){try{return fs.existsSync(file)}catch{return false}}",
30
30
  "function keepQueued(){try{const now=new Date();fs.utimesSync(done,now,now)}catch{}}",
31
31
  "function predecessorReady(){if(previous==='-')return true;if(exists(previous.replace(/\\.pending$/,'.ready')))return true;try{return Date.now()-fs.statSync(previous).mtimeMs>=waitTimeout}catch{return true}}",
32
- "function updateManifest(state,stdout=''){if(manifest==='-')return;try{const payload=JSON.parse(fs.readFileSync(manifest,'utf8'));payload.state=state;payload.updatedAt=new Date().toISOString();const raw=stdout.trim().split(/\\r?\\n/).filter(Boolean).at(-1);if(raw){try{payload.orca=JSON.parse(raw)}catch{payload.orcaRaw=raw.slice(0,4096)}}fs.writeFileSync(manifest,JSON.stringify(payload,null,2)+'\\n')}catch{}}",
32
+ "function updateManifest(state,stdout=''){if(manifest==='-')return;try{const payload=JSON.parse(fs.readFileSync(manifest,'utf8'));payload.state=state;payload.updatedAt=new Date().toISOString();const raw=stdout.trim();if(raw){try{payload.orca=JSON.parse(raw)}catch{payload.orcaRaw=raw.slice(0,4096)}}fs.writeFileSync(manifest,JSON.stringify(payload,null,2)+'\\n')}catch{}}",
33
33
  "function start(){",
34
34
  " try{",
35
35
  " const child=spawn(command,args,{stdio:['ignore','pipe','ignore'],windowsHide:true});",
@@ -53,6 +53,8 @@ const ORCA_CLEANUP_WATCHDOG_SCRIPT = [
53
53
  ].join("");
54
54
 
55
55
  export interface OrcaProgressTab {
56
+ /** Resolves when the terminal-create watchdog closes, after its final manifest/queue writes (success or failure). Not viewer completion. */
57
+ readonly creationSettled: Promise<void>;
56
58
  append(text: string): void;
57
59
  section(input: { agent: string; index: number; count: number }): void;
58
60
  event(event: { type?: string; message?: Message; toolName?: string; args?: unknown }): void;
@@ -402,6 +404,8 @@ export function createOrcaProgressTab(input: {
402
404
  }
403
405
  };
404
406
  let createSettled = false;
407
+ let resolveCreationSettled!: () => void;
408
+ const creationSettled = new Promise<void>((resolve) => { resolveCreationSettled = resolve; });
405
409
  let cleanupPaths: string[] | undefined;
406
410
  const scheduleDeferredCleanup = () => {
407
411
  if (!createSettled || cleanupPaths === undefined) return;
@@ -434,6 +438,7 @@ export function createOrcaProgressTab(input: {
434
438
  createSettled = true;
435
439
  if (code !== 0) failObserver();
436
440
  scheduleDeferredCleanup();
441
+ resolveCreationSettled();
437
442
  });
438
443
  watchdog.once("error", () => {
439
444
  markCreateReady();
@@ -450,6 +455,7 @@ export function createOrcaProgressTab(input: {
450
455
 
451
456
  let finished = false;
452
457
  return {
458
+ creationSettled,
453
459
  append(text) {
454
460
  if (finished) return;
455
461
  writeProgress(text);
@@ -17,6 +17,7 @@ import type {
17
17
  WorktreeCleanupReport,
18
18
  WorktreeDiff,
19
19
  WorktreeSetup,
20
+ WorktreeSetupProgress,
20
21
  WorktreeCleanupIntent,
21
22
  } from "./worktree.ts";
22
23
  import { cleanupWorktrees } from "./worktree.ts";
@@ -614,18 +615,62 @@ export function parallelHandoffPath(baseDir: string, runId?: string): string {
614
615
  return runId ? path.join(baseDir, "handoffs", `${runId}.json`) : path.join(baseDir, "handoff.json");
615
616
  }
616
617
 
617
- export function writePendingParallelHandoff(input: {
618
- manifestPath: string;
619
- runId: string;
620
- mode: "single" | "parallel" | "chain";
621
- source: "foreground" | "async";
622
- cwd: string;
623
- stepIndex: number;
624
- flatStartIndex: number;
625
- setup: WorktreeSetup;
626
- laneBindings?: ParallelHandoffLaneBinding[];
627
- }): ParallelHandoffReference {
628
- return writeParallelHandoffGroup({ ...input, diffs: [], results: [] });
618
+ /** Synchronous onProgress projection shared by setup owners; snapshots are cumulative. */
619
+ export function writeWorktreeSetupHandoff(input: Omit<Parameters<typeof writeParallelHandoffGroup>[0], "setup" | "diffs" | "results" | "cleanup" | "now"> & {
620
+ progress: WorktreeSetupProgress;
621
+ }): ParallelHandoffReference | undefined {
622
+ const { progress, ...handoff } = input;
623
+ const { setup, attempts, cleanup } = progress;
624
+ // Preflight has no allocation identity yet. Its original error belongs to the caller.
625
+ if (attempts.length === 0 && setup.worktrees.length === 0) return undefined;
626
+ if (!setup.cwd || !setup.baseCommit) throw new Error("Cannot publish worktree allocation evidence without repository and base commit.");
627
+ const diagnostic = (text: string): string => text.slice(0, 512);
628
+ // Never copy argv, environment, Error objects or captured stdout/stderr into artifacts.
629
+ const commandEvidence = (command: WorktreeSetupProgress["command"]) => command && ({
630
+ pid: command.pid,
631
+ processGroupId: command.processGroupId,
632
+ ...(command.result ? {
633
+ status: command.result.status,
634
+ signal: command.result.signal,
635
+ failed: Boolean(command.result.error),
636
+ outputIncomplete: command.result.outputIncomplete,
637
+ processTree: command.result.processTree?.state,
638
+ } : {}),
639
+ });
640
+ const errors = [
641
+ `Worktree setup ${JSON.stringify({ runId: input.runId, stepIndex: input.stepIndex, phase: progress.phase,
642
+ unknown: Boolean(progress.unknown), command: commandEvidence(progress.command) })}`,
643
+ ...attempts.map((attempt) => {
644
+ const task = cleanup?.tasks.find((candidate) => candidate.index === attempt.index);
645
+ return `Allocation attempt ${JSON.stringify({ index: attempt.index, branch: attempt.branch,
646
+ path: attempt.path ?? null, validated: attempt.validated,
647
+ command: commandEvidence(attempt.command), hookCommand: commandEvidence(attempt.hookCommand),
648
+ ...(task ? { worktreeRemoved: task.worktreeRemoved, branchRemoved: task.branchRemoved,
649
+ preserved: task.preserved, reason: task.reason && diagnostic(task.reason), errors: task.errors?.map(diagnostic) } : {}),
650
+ })}`;
651
+ }),
652
+ ...(cleanup?.errors?.map(diagnostic) ?? []),
653
+ ...(progress.unknown ? ["Setup settlement unknown; manual reconciliation required."] : []),
654
+ ];
655
+ return writeParallelHandoffGroup({
656
+ ...handoff, setup, diffs: [], results: [],
657
+ laneBindings: input.laneBindings?.filter((binding) => setup.worktrees.some((worktree) => worktree.index === binding.taskIndex)),
658
+ cleanup: {
659
+ state: cleanup?.state ?? "partial",
660
+ pruned: cleanup?.pruned ?? false,
661
+ errors,
662
+ // An attempted native path is not a validated recovery task, even during rollback.
663
+ tasks: setup.worktrees.map((worktree) => {
664
+ const task = cleanup?.tasks.find((candidate) => candidate.index === worktree.index);
665
+ return task ? { ...task, reason: task.reason && diagnostic(task.reason), errors: task.errors?.map(diagnostic) } : {
666
+ index: worktree.index, path: worktree.path, branch: worktree.branch,
667
+ provider: worktree.provider, naming: worktree.naming,
668
+ worktreeRemoved: false, branchRemoved: false, preserved: true,
669
+ reason: "setup pending durable handoff capture",
670
+ };
671
+ }),
672
+ },
673
+ });
629
674
  }
630
675
 
631
676
  export function formatParallelHandoffReference(reference: ParallelHandoffReference): string {
@@ -75,8 +75,8 @@ export interface RunnerSubagentStep {
75
75
  launchBindingTask?: string;
76
76
  launchContractDigest?: string;
77
77
  extensionBindings?: import("./extension-bindings.ts").ExtensionBindings;
78
- launchResolvedExtensions?: import("../../shared/types.ts").LaunchResolvedChildExtensionsV1;
79
- runtimeAcknowledgedExtensions?: import("../../shared/types.ts").RuntimeAcknowledgedChildExtensionsV1;
78
+ launchResolvedExtensions?: import("../../shared/types.ts").LaunchResolvedChildExtensions;
79
+ runtimeAcknowledgedExtensions?: import("../../shared/types.ts").RuntimeAcknowledgedChildExtensions;
80
80
  effectiveAcceptance?: import("../../shared/types.ts").ResolvedAcceptanceConfig;
81
81
  acceptanceInput?: import("../../shared/types.ts").AcceptanceInput;
82
82
  acceptanceRole?: import("../../shared/types.ts").AcceptanceRole;
@@ -49,6 +49,7 @@ export interface PiSpawnDeps {
49
49
  platform?: NodeJS.Platform;
50
50
  execPath?: string;
51
51
  argv1?: string;
52
+ bunVersion?: string;
52
53
  existsSync?: (filePath: string) => boolean;
53
54
  realpathSync?: (filePath: string) => string;
54
55
  readFileSync?: (filePath: string, encoding: "utf-8") => string;
@@ -58,6 +59,15 @@ export interface PiSpawnDeps {
58
59
  env?: NodeJS.ProcessEnv;
59
60
  }
60
61
 
62
+ /** Compiled Pi's entrypoint is virtual; execPath is the real (possibly renamed) image. */
63
+ export function resolveBunPiExecutable(deps: PiSpawnDeps = {}): string | undefined {
64
+ const bunVersion = deps.bunVersion ?? process.versions.bun;
65
+ const entry = deps.argv1 ?? process.argv[1];
66
+ if (!bunVersion || !entry?.startsWith("/$bunfs/")) return undefined;
67
+ const env = deps.env ?? process.env;
68
+ return env[PI_SUBAGENT_PI_BINARY_ENV]?.trim() || (deps.execPath ?? process.execPath);
69
+ }
70
+
61
71
  interface PiSpawnCommand {
62
72
  command: string;
63
73
  args: string[];
@@ -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
+ }
@@ -0,0 +1,307 @@
1
+ /** Internal, opt-in factory evidence. No runner enables continuation through this module. */
2
+ import { createHash } from "node:crypto";
3
+ import { lstatSync, readFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { isDeepStrictEqual } from "node:util";
6
+ import type { AgentSession, SessionEntry, SessionHeader } from "@earendil-works/pi-coding-agent";
7
+ import type { ChildSession, ChildSessionLaunch, PiCodingAgentModule } from "./child-session.ts";
8
+ import { captureReadonlyChildDrain, isReadonlyChildHookProfile, isReadonlyChildSessionReporting, observeReadonlyChildHookDrain } from "./child-hooks.ts";
9
+
10
+ type Runtime = Awaited<ReturnType<PiCodingAgentModule["ModelRuntime"]["create"]>>;
11
+ type Model = NonNullable<AgentSession["model"]>;
12
+
13
+ export interface SettledReadonlyEvidence {
14
+ readonly sessionFile: string;
15
+ readonly sessionId: string;
16
+ readonly leafId: string;
17
+ readonly provider: string;
18
+ readonly model: string;
19
+ readonly api: string;
20
+ readonly status: 429;
21
+ /** Complete active context, not the host's attempt-only event projection. */
22
+ readonly contextJson: string;
23
+ readonly completedToolResults: number;
24
+ readonly fileDigest: string;
25
+ }
26
+
27
+ const requested = new WeakMap<ChildSessionLaunch, SettledReadonlyEvidence | null>();
28
+ const receipts = new WeakMap<ChildSession, SettledReadonlyEvidence>();
29
+ // Opaque configured-provider continuity, never an implementation-origin attestation.
30
+ const providers = new WeakMap<SettledReadonlyEvidence, object>();
31
+
32
+ /** Internal integration seam: opt in before factory creation. Ordinary launches do no evidence I/O. */
33
+ export function requestReadonlySessionEvidence(launch: ChildSessionLaunch, expected?: SettledReadonlyEvidence): void {
34
+ requested.set(launch, expected ?? null);
35
+ }
36
+
37
+ /** Only the default factory can produce a receipt; duck-typed fake child properties are ignored. */
38
+ export function getReadonlySessionEvidence(child: ChildSession): SettledReadonlyEvidence | undefined {
39
+ return receipts.get(child);
40
+ }
41
+
42
+ function digest(bytes: string): string {
43
+ return createHash("sha256").update(bytes).digest("hex");
44
+ }
45
+
46
+ function absent(file: string): boolean {
47
+ try { lstatSync(file); return false; }
48
+ catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; }
49
+ }
50
+
51
+ function sameStored(disk: unknown, live: unknown): boolean {
52
+ // The SDK keeps optional undefined fields in memory which JSONL intentionally omits.
53
+ return isDeepStrictEqual(disk, JSON.parse(JSON.stringify(live)));
54
+ }
55
+
56
+ /** Must run before opening a retained file. Never repairs, truncates, or creates it. */
57
+ export function validateReadonlySessionCheckpoint(evidence: SettledReadonlyEvidence): boolean {
58
+ if (!providers.has(evidence)) return false;
59
+ try { return digest(readFileSync(evidence.sessionFile, "utf8")) === evidence.fileDigest; }
60
+ catch { return false; }
61
+ }
62
+
63
+ function readHistory(file: string): { header: SessionHeader; entries: SessionEntry[]; bytes: string } {
64
+ const bytes = readFileSync(file, "utf8");
65
+ if (!bytes.endsWith("\n")) throw new Error("Incomplete session file");
66
+ const rows = bytes.slice(0, -1).split("\n").map((line) => JSON.parse(line));
67
+ const [header, ...entries] = rows;
68
+ if (header?.type !== "session" || header.version !== 3 || typeof header.id !== "string" || !header.id || typeof header.cwd !== "string" || !entries.length) throw new Error("Unsupported session header");
69
+ const ids = new Set<string>();
70
+ for (const entry of entries) {
71
+ if (!entry || typeof entry.id !== "string" || !entry.id || ids.has(entry.id) || typeof entry.type !== "string" || typeof entry.timestamp !== "string" || (entry.parentId !== null && !ids.has(entry.parentId))) throw new Error("Broken session chain");
72
+ ids.add(entry.id);
73
+ }
74
+ return { header, entries, bytes };
75
+ }
76
+
77
+ function activeMessages(branch: SessionEntry[]): AgentSession["messages"] {
78
+ const messages = branch.flatMap((entry) => {
79
+ if (entry.type === "message") return [entry.message];
80
+ // Compaction/branch summaries and custom context need their own proof; do not reconstruct them.
81
+ if (!["model_change", "thinking_level_change", "session_info", "label"].includes(entry.type)) throw new Error("Unsupported active context");
82
+ return [];
83
+ });
84
+ // Snapshot the persisted representation, not mutable SDK message references.
85
+ return JSON.parse(JSON.stringify(messages));
86
+ }
87
+
88
+ function completedResults(messages: AgentSession["messages"]): number {
89
+ const pending = new Map<string, string>();
90
+ const seen = new Set<string>();
91
+ let completed = 0;
92
+ for (const message of messages) {
93
+ if (message.role === "toolResult") {
94
+ if (typeof message.toolCallId !== "string" || !pending.has(message.toolCallId) || pending.get(message.toolCallId) !== message.toolName || !Array.isArray(message.content)) throw new Error("Unmatched tool result");
95
+ pending.delete(message.toolCallId);
96
+ if (!message.isError && message.content.length) completed++;
97
+ } else {
98
+ if (pending.size) throw new Error("Unresolved tool call");
99
+ if (message.role === "assistant") {
100
+ if (!Array.isArray(message.content)) throw new Error("Invalid assistant content");
101
+ for (const block of message.content) {
102
+ if (block.type !== "toolCall") continue;
103
+ if (!block.id || typeof block.id !== "string" || seen.has(block.id) || !["read", "ls"].includes(block.name)) throw new Error("Uncertified tool call");
104
+ seen.add(block.id);
105
+ pending.set(block.id, block.name);
106
+ }
107
+ } else if (message.role !== "user") throw new Error("Unsupported context message");
108
+ }
109
+ }
110
+ if (pending.size) throw new Error("Unresolved tool call");
111
+ return completed;
112
+ }
113
+
114
+ function eligibleLaunch(launch: ChildSessionLaunch): boolean {
115
+ const r = launch.runtime;
116
+ return launch.ambientExtensions === false && !launch.extensionPaths.length
117
+ && isReadonlyChildSessionReporting(launch)
118
+ && (!launch.hooks.length || (launch.storage.kind === "file" && isReadonlyChildHookProfile(launch.hooks, r)))
119
+ && launch.tools !== undefined && launch.tools.every((tool) => tool === "read" || tool === "ls")
120
+ && !r.toolBudget && !r.permissions && !r.childWatchdog && !r.watchdogStatus && !r.structuredOutput
121
+ && !r.waitTool.enabled && !r.fanoutChild && !r.fast && !r.nestedRoute && !r.nestedParent && !r.runFanoutBudget
122
+ && !r.supervisorChannelDir && !r.mcpDirectTools?.length;
123
+ }
124
+
125
+ export interface ReadonlyEvidenceObserver {
126
+ start(): void;
127
+ settled(): void;
128
+ invalidate(): void;
129
+ beforeShutdown(): void;
130
+ finish(child: ChildSession): void;
131
+ }
132
+
133
+ /** Called only on the explicit opt-in path, before SessionManager.open can permissively repair input. */
134
+ export function prepareReadonlySessionEvidence(launch: ChildSessionLaunch): { loadingHooks(enabled: boolean): void; beforeOpen(): void; opened(manager: AgentSession["sessionManager"]): void; observe(pi: PiCodingAgentModule, runtime: Runtime, session: AgentSession): ReadonlyEvidenceObserver | undefined } | undefined {
135
+ if (!requested.has(launch)) return undefined;
136
+ const expected = requested.get(launch);
137
+ requested.delete(launch);
138
+ const deny = (): undefined => {
139
+ if (expected) throw new Error("Unsupported read-only continuation session");
140
+ return undefined;
141
+ };
142
+ const checkExpected = (): void => {
143
+ if (expected && (launch.storage.kind !== "file" || resolve(launch.storage.sessionFile) !== expected.sessionFile || !validateReadonlySessionCheckpoint(expected))) throw new Error("Read-only continuation checkpoint changed");
144
+ };
145
+ checkExpected();
146
+ if (!eligibleLaunch(launch) || launch.storage.kind !== "file") return deny();
147
+ const file = resolve(launch.storage.sessionFile);
148
+ // Only an initially absent *assigned exact file* may use SDK initialization.
149
+ // Empty files, dangling symlinks, inaccessible and corrupt inputs are not fresh.
150
+ let fresh = !expected && absent(file);
151
+ let initialHeader: SessionHeader | undefined;
152
+ try { if (!fresh) initialHeader = readHistory(file).header; }
153
+ catch { return deny(); }
154
+ const beforeOpen = (): void => {
155
+ checkExpected();
156
+ if (fresh) fresh = absent(file);
157
+ };
158
+ const opened = (manager: AgentSession["sessionManager"]): void => {
159
+ if (fresh && manager.getSessionFile() === file && manager.getEntries().length === 0 && manager.getLeafId() === null) {
160
+ // Capture the real SDK-generated identity immediately after open, before hooks.
161
+ initialHeader = JSON.parse(JSON.stringify(manager.getHeader()));
162
+ }
163
+ };
164
+ const observe = (pi: PiCodingAgentModule, runtime: Runtime, session: AgentSession): ReadonlyEvidenceObserver | undefined => {
165
+ const drainSettled = captureReadonlyChildDrain(launch.hooks);
166
+ // A deliberately tested capability floor, not an attestation of installed code origin.
167
+ if (!initialHeader || pi.VERSION !== "0.85.1" || !session.model || !session.agent.streamFunction) return deny();
168
+ const header = initialHeader;
169
+ const model = { ...session.model };
170
+ const provider = runtime.getProvider(model.provider);
171
+ if (expected && provider !== providers.get(expected)) return deny();
172
+ // Coverage is namespace/API plus the observed POST topology below, not builtin provider origin.
173
+ const supported = (m: Model): boolean => m.provider === "baseten" && m.api === "openai-completions"
174
+ && runtime.getProvider(m.provider) === provider
175
+ && !runtime.getRegisteredNativeProvider(m.provider) && !runtime.getRegisteredProviderConfig(m.provider);
176
+ const allowed = launch.tools!.filter((name) => !launch.excludeTools?.includes(name)).sort();
177
+ const toolsMatch = (): boolean => {
178
+ const tools = session.getAllTools();
179
+ return isDeepStrictEqual(session.getActiveToolNames().sort(), allowed)
180
+ && isDeepStrictEqual(tools.map((tool) => tool.name).sort(), allowed)
181
+ && tools.every((tool) => tool.sourceInfo?.source === "builtin");
182
+ };
183
+ const idle = (): boolean => session.isIdle && !session.isRetrying && !session.agent.hasQueuedMessages()
184
+ && session.agent.state.pendingToolCalls.size === 0 && !session.hasPendingBashMessages;
185
+ const continuity = (): boolean => eligibleLaunch(launch) && toolsMatch() && session.model !== undefined
186
+ && session.model.id === model.id && session.model.provider === model.provider && session.model.api === model.api && supported(session.model);
187
+ const history = () => {
188
+ const disk = readHistory(file);
189
+ const manager = session.sessionManager;
190
+ const leaf = manager.getLeafId();
191
+ if (session.sessionFile !== file || session.sessionId !== header.id || !leaf || leaf !== disk.entries.at(-1)?.id
192
+ || !isDeepStrictEqual(disk.header, header) || !sameStored(disk.header, manager.getHeader())
193
+ || !sameStored(disk.entries, manager.getEntries())) throw new Error("Session checkpoint mismatch");
194
+ // The disk chain and live entries already agree; use the SDK's raw branch walk.
195
+ const messages = activeMessages(manager.getBranch());
196
+ if (!sameStored(messages, session.messages)) throw new Error("Incomplete active context");
197
+ return { ...disk, messages, leaf, completed: completedResults(messages) };
198
+ };
199
+ const startingHistory = () => {
200
+ // SDK defers persistence until its first assistant response. This exception
201
+ // is for the empty source baseline only; settlement always reads strict disk history.
202
+ if (fresh && absent(file)) {
203
+ const manager = session.sessionManager;
204
+ if (session.sessionFile !== file || session.sessionId !== header.id || !sameStored(header, manager.getHeader())
205
+ || session.messages.length
206
+ || manager.getEntries().some((entry) => !["model_change", "thinking_level_change", "session_info", "label"].includes(entry.type))) throw new Error("Fresh session initialization changed");
207
+ return { messages: [] as AgentSession["messages"], completed: 0 };
208
+ }
209
+ return history();
210
+ };
211
+ if (!provider || !eligibleLaunch(launch) || !supported(model) || !toolsMatch() || !idle()) return deny();
212
+ try {
213
+ const restored = startingHistory();
214
+ if (expected && (model.provider !== expected.provider || JSON.stringify(restored.messages) !== expected.contextJson)) throw new Error("Restored continuation context changed");
215
+ } catch (error) {
216
+ if (expected) throw error;
217
+ return undefined;
218
+ }
219
+ let invalid = false;
220
+ let phase: "new" | "running" | "settled" = "new";
221
+ let owner: ChildSession | undefined;
222
+ let baseline = 0;
223
+ let before: AgentSession["messages"] = [];
224
+ let inFlight = 0;
225
+ type Invocation = { status?: number; pending: number; ambiguous: boolean; result?: Awaited<ReturnType<Awaited<ReturnType<typeof session.agent.streamFunction>>["result"]>> };
226
+ let latest: Invocation | undefined;
227
+ const original = session.agent.streamFunction;
228
+ const wrapped: typeof original = async (m, context, options) => {
229
+ const invocation: Invocation = { pending: 0, ambiguous: false };
230
+ latest = invocation;
231
+ if (inFlight++ || phase !== "running" || !eligibleLaunch(launch) || !supported(m) || m.id !== model.id || !toolsMatch()) invalid = true;
232
+ if (expected && (!continuity() || session.agent.streamFunction !== wrapped)) invalid = true;
233
+ // The repository's older agent-core types omit this documented 0.85.1 request option.
234
+ const transport = (options as (typeof options & { fetch?: typeof fetch }))?.fetch ?? globalThis.fetch;
235
+ const observedFetch: typeof fetch = async (...args) => {
236
+ invocation.status = undefined; // Starting even a rejected retry invalidates any earlier 429.
237
+ if (invocation.pending++ || invocation.result) invocation.ambiguous = true;
238
+ try {
239
+ try {
240
+ const [input, init] = args;
241
+ const url = new URL(input instanceof Request ? input.url : String(input));
242
+ // Only the tested completions POST topology. Radius /messages and auxiliary requests deny.
243
+ if (init?.method !== "POST" || !url.pathname.endsWith("/chat/completions") || typeof init.body !== "string") invocation.ambiguous = true;
244
+ } catch { invocation.ambiguous = true; }
245
+ const response = await transport(...args);
246
+ invocation.status = response.status;
247
+ return response;
248
+ } catch (error) {
249
+ invocation.status = undefined;
250
+ throw error;
251
+ } finally { invocation.pending--; }
252
+ };
253
+ try {
254
+ // Guarded continuations must veto known changes before entering configured SDK dispatch.
255
+ if (expected && invalid) throw new Error("Read-only continuation changed before dispatch");
256
+ const stream = await original.call(session.agent, m, context, { ...options, fetch: observedFetch });
257
+ // Observe result without consuming or replacing the event stream.
258
+ void stream.result().then((result) => {
259
+ invocation.result = result;
260
+ inFlight--;
261
+ }, () => { invalid = true; inFlight--; });
262
+ return stream;
263
+ } catch (error) { invalid = true; inFlight--; throw error; }
264
+ };
265
+ session.agent.streamFunction = wrapped;
266
+ return {
267
+ start() {
268
+ if (owner) receipts.delete(owner);
269
+ if (phase !== "new" || !idle()) invalid = true;
270
+ if (expected && (!continuity() || session.agent.streamFunction !== wrapped)) invalid = true;
271
+ phase = "running";
272
+ latest = undefined;
273
+ try {
274
+ const h = startingHistory(); baseline = h.completed; before = h.messages;
275
+ if (expected && JSON.stringify(h.messages) !== expected.contextJson) invalid = true;
276
+ } catch { invalid = true; }
277
+ if (expected && invalid) throw new Error("Read-only continuation changed before prompt");
278
+ },
279
+ settled() { phase = "settled"; },
280
+ invalidate() { invalid = true; if (owner) receipts.delete(owner); },
281
+ beforeShutdown() {
282
+ // Only the identity-certified prompt runtime may finalize owned acknowledgements.
283
+ if (!launch.hooks.length || !continuity()) invalid = true;
284
+ },
285
+ finish(child) {
286
+ owner = child;
287
+ if (!drainSettled()) return;
288
+ if (invalid || child.detached || child.shutDown || phase !== "settled" || inFlight || !eligibleLaunch(launch) || !idle() || !supported(model) || !toolsMatch() || session.agent.streamFunction !== wrapped
289
+ || session.model?.id !== model.id || session.model.provider !== model.provider || session.model.api !== model.api) return;
290
+ const invocation = latest;
291
+ const terminal = invocation?.result;
292
+ if (!invocation || invocation.ambiguous || invocation.pending || invocation.status !== 429 || terminal?.stopReason !== "error"
293
+ || terminal.content.length || terminal.provider !== model.provider || terminal.model !== model.id || terminal.api !== model.api) return;
294
+ try {
295
+ const h = history();
296
+ if (h.completed <= baseline || !isDeepStrictEqual(h.messages.slice(0, before.length), before) || !sameStored(h.messages.at(-1), terminal)) return;
297
+ const receipt: SettledReadonlyEvidence = Object.freeze({ sessionFile: file, sessionId: session.sessionId, leafId: h.leaf,
298
+ provider: model.provider, model: model.id, api: model.api, status: 429, contextJson: JSON.stringify(h.messages),
299
+ completedToolResults: h.completed, fileDigest: digest(h.bytes) });
300
+ providers.set(receipt, provider);
301
+ receipts.set(child, receipt);
302
+ } catch { /* Strict checkpoint failures only deny evidence; never repair storage. */ }
303
+ },
304
+ };
305
+ };
306
+ return { beforeOpen, opened, observe, loadingHooks: (enabled) => observeReadonlyChildHookDrain(launch.hooks, enabled, file) };
307
+ }