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
@@ -2,6 +2,26 @@ import { spawn } from "node:child_process";
2
2
  import { createOwnedProcessTreeController } from "../background/owned-process-tree.ts";
3
3
  import type { ProcessTreeTerminal } from "../../shared/types.ts";
4
4
 
5
+ function terminateCommandTree(pid: number, controller: ReturnType<typeof createOwnedProcessTreeController>): Promise<ProcessTreeTerminal> {
6
+ if (process.platform !== "win32") return controller.terminate();
7
+ return new Promise((resolve) => {
8
+ let settled = false;
9
+ const fallback = () => {
10
+ if (settled) return;
11
+ settled = true;
12
+ void controller.terminate().then(resolve);
13
+ };
14
+ const cleanup = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
15
+ cleanup.once("error", fallback);
16
+ cleanup.once("close", (status) => {
17
+ if (settled) return;
18
+ if (status !== 0) return fallback();
19
+ settled = true;
20
+ resolve({ state: "observed", mechanism: "windows-taskkill", pid, verifiedAt: Date.now() });
21
+ });
22
+ });
23
+ }
24
+
5
25
  export interface SetupCommandOptions {
6
26
  cwd?: string;
7
27
  env?: NodeJS.ProcessEnv;
@@ -19,6 +39,7 @@ export interface SetupCommandOptions {
19
39
 
20
40
  export interface SetupCommandResult {
21
41
  stdout: string;
42
+ stdoutBuffer: Buffer;
22
43
  stderr: string;
23
44
  status: number | null;
24
45
  signal: NodeJS.Signals | null;
@@ -46,7 +67,7 @@ export async function runSetupCommand(
46
67
  options: SetupCommandOptions,
47
68
  ): Promise<SetupCommandResult> {
48
69
  const result: SetupCommandResult = {
49
- stdout: "", stderr: "", status: null, signal: null, outputIncomplete: false,
70
+ stdout: "", stdoutBuffer: Buffer.alloc(0), stderr: "", status: null, signal: null, outputIncomplete: false,
50
71
  };
51
72
  const maxBuffer = options.maxBuffer ?? 1024 * 1024;
52
73
  if (!Number.isSafeInteger(maxBuffer) || maxBuffer <= 0) throw new Error("Invalid setup command maxBuffer");
@@ -84,7 +105,8 @@ export async function runSetupCommand(
84
105
  let directSettled = false;
85
106
  const releaseUnknownIO = () => {
86
107
  if (!directSettled || result.processTree?.state !== "unknown") return;
87
- result.error ??= commandError("Worktree setup process tree settlement is unverified", "PROCESS_TREE_UNVERIFIED");
108
+ const detail = result.processTree.diagnostic ? `: ${result.processTree.diagnostic}` : "";
109
+ result.error = commandError(`Worktree setup process tree settlement is unverified${detail}`, "PROCESS_TREE_UNVERIFIED");
88
110
  result.outputIncomplete = true;
89
111
  // Only local I/O is released; unknown descendant ownership remains retained.
90
112
  child.stdin.destroy();
@@ -101,7 +123,7 @@ export async function runSetupCommand(
101
123
  releaseUnknownIO();
102
124
  return;
103
125
  }
104
- termination = tree.terminate().then((proof) => {
126
+ termination = terminateCommandTree(result.pid!, tree).then((proof) => {
105
127
  result.processTree = proof;
106
128
  releaseUnknownIO();
107
129
  return proof;
@@ -177,7 +199,8 @@ export async function runSetupCommand(
177
199
  if (termination) result.processTree = await termination;
178
200
  const cancelled = cancellation();
179
201
  if (cancelled) fail(cancelled);
180
- result.stdout = Buffer.concat(stdout).toString("utf8");
202
+ result.stdoutBuffer = Buffer.concat(stdout);
203
+ result.stdout = result.stdoutBuffer.toString("utf8");
181
204
  result.stderr = Buffer.concat(stderr).toString("utf8");
182
205
  return result;
183
206
  } finally {
@@ -18,6 +18,8 @@ const WORKTREE_NAMING_COMPONENT_MAX_BYTES = 96;
18
18
  const WORKTREE_NAMING_LABEL_MAX_BYTES = 256;
19
19
  const WORKTREE_NAMING_BRANCH_MAX_BYTES = 256;
20
20
  const WORKTREE_COMMAND_OUTPUT_MAX_BYTES = 128 * 1024;
21
+ const WORKTRUNK_COMMAND = process.platform === "win32" ? "git" : "wt";
22
+ const WORKTRUNK_ARG_PREFIX = process.platform === "win32" ? ["wt"] : [];
21
23
  export const MACHINE_DIFF_OPTIONS = ["--no-color", "--no-ext-diff", "--no-textconv", "--default-prefix", "--line-prefix=", "--no-relative"] as const;
22
24
  const MACHINE_PATCH_OPTIONS = [...MACHINE_DIFF_OPTIONS, "--binary"] as const;
23
25
  const PATCH_VALIDATION_OPTIONS = ["apply", "--check", "--cached", "--reverse", "--binary", "--whitespace=nowarn"] as const;
@@ -178,7 +180,7 @@ export interface WorktreeSetupProgress {
178
180
  setup: WorktreeSetup;
179
181
  attempts: Array<{ index: number; branch: string; path?: string; validated: boolean; command?: WorktreeSetupProgress["command"]; hookCommand?: WorktreeSetupProgress["command"] }>;
180
182
  phase: string;
181
- command?: { command: string; args: string[]; pid?: number; processGroupId?: number; result?: Omit<SetupCommandResult, "stdout" | "stderr"> };
183
+ command?: { command: string; args: string[]; pid?: number; processGroupId?: number; result?: Omit<SetupCommandResult, "stdout" | "stdoutBuffer" | "stderr"> };
182
184
  unknown?: string;
183
185
  cleanup?: WorktreeCleanupReport;
184
186
  }
@@ -243,7 +245,7 @@ class SetupTransaction {
243
245
  ...options, signal: this.options.signal, deadlineAt: this.options.deadlineAt,
244
246
  onSpawn: (process) => { Object.assign(this.progress.command!, process); this.publish(); },
245
247
  });
246
- const { stdout: _stdout, stderr: _stderr, ...metadata } = result;
248
+ const { stdout: _stdout, stdoutBuffer: _stdoutBuffer, stderr: _stderr, ...metadata } = result;
247
249
  this.progress.command.result = metadata;
248
250
  if (result.processTree?.state === "unknown") this.unknown(result.error ?? "Command tree settlement unverified");
249
251
  this.publish();
@@ -339,11 +341,9 @@ export function validateWorktreePatchRepresentsCurrentWorktree(worktreePath: str
339
341
  return undefined;
340
342
  }
341
343
 
342
- async function resolveRepoState(tx: SetupTransaction, cwd: string, requestedBaseRef: string | undefined): Promise<RepoState> {
344
+ async function probeWorktreeSource(tx: Pick<SetupTransaction, "git" | "gitChecked">, cwd: string): Promise<string> {
343
345
  const repoCheck = await tx.git(cwd, ["rev-parse", "--is-inside-work-tree"], [0, 128]);
344
346
  if (repoCheck.status !== 0 || repoCheck.stdout.trim() !== "true") throw new Error("worktree isolation requires a git repository");
345
- const rawPrefix = (await tx.gitChecked(cwd, ["rev-parse", "--show-prefix"])).trim();
346
- const cwdRelative = rawPrefix ? path.normalize(rawPrefix.replace(/[\\/]+$/, "")) : "";
347
347
  const toplevel = (await tx.gitChecked(cwd, ["rev-parse", "--show-toplevel"])).trim();
348
348
 
349
349
  // pi-subagents writes durable runtime state under .pi/subagents/ by default;
@@ -352,6 +352,26 @@ async function resolveRepoState(tx: SetupTransaction, cwd: string, requestedBase
352
352
  if (status.trim().length > 0) {
353
353
  throw new Error("worktree isolation requires a clean git working tree. Commit or stash changes first.");
354
354
  }
355
+ return toplevel;
356
+ }
357
+
358
+ /** Read-only admission check; allocation repeats it because source state can change. */
359
+ export async function preflightWorktreeSource(cwd: string, options: Pick<SetupCommandOptions, "signal" | "deadlineAt"> = {}): Promise<void> {
360
+ const git = async (cwd: string, args: string[], acceptedExitCodes: readonly number[] = [0]): Promise<SetupCommandResult> => {
361
+ const result = await runSetupCommand("git", ["-C", cwd, ...args], { ...options, acceptedExitCodes });
362
+ if (result.error) throw result.error;
363
+ if (result.outputIncomplete || result.status === null || !acceptedExitCodes.includes(result.status)) {
364
+ throw new Error(result.stderr.trim().slice(0, 2000) || "Worktree source probe failed");
365
+ }
366
+ return result;
367
+ };
368
+ await probeWorktreeSource({ git, gitChecked: async (cwd, args) => (await git(cwd, args)).stdout }, cwd);
369
+ }
370
+
371
+ async function resolveRepoState(tx: SetupTransaction, cwd: string, requestedBaseRef: string | undefined): Promise<RepoState> {
372
+ const toplevel = await probeWorktreeSource(tx, cwd);
373
+ const rawPrefix = (await tx.gitChecked(cwd, ["rev-parse", "--show-prefix"])).trim();
374
+ const cwdRelative = rawPrefix ? path.normalize(rawPrefix.replace(/[\\/]+$/, "")) : "";
355
375
 
356
376
  const baseRef = normalizeWorktreeBaseRef(requestedBaseRef) ?? DEFAULT_WORKTREE_BASE_REF;
357
377
  let baseCommit: string;
@@ -526,6 +546,11 @@ function hasConfiguredWorktreeBaseDir(baseDir: string | undefined): boolean {
526
546
  : (process.env.PI_SUBAGENTS_WORKTREE_DIR?.trim().length ?? 0) > 0;
527
547
  }
528
548
 
549
+ function isInsidePiExtensionsDirectory(targetPath: string): boolean {
550
+ const extensionsDir = normalizeComparableCwd(path.join(getAgentDir(), "extensions"));
551
+ return isPathInside(extensionsDir, normalizeComparableCwd(targetPath));
552
+ }
553
+
529
554
  interface WorktrunkCapability {
530
555
  available: boolean;
531
556
  reason?: string;
@@ -533,7 +558,7 @@ interface WorktrunkCapability {
533
558
 
534
559
  function runWorktrunk(args: string[], cwd?: string): WorktreeCommandResult {
535
560
  try {
536
- const result = spawnSync("wt", args, {
561
+ const result = spawnSync(WORKTRUNK_COMMAND, [...WORKTRUNK_ARG_PREFIX, ...args], {
537
562
  cwd,
538
563
  encoding: "utf-8",
539
564
  windowsHide: true,
@@ -578,7 +603,7 @@ export function resolveWorktreeProvider(requested: WorktreeProvider | undefined,
578
603
  return "native";
579
604
  }
580
605
 
581
- async function resolveSetupProvider(tx: SetupTransaction, requested: WorktreeProvider | undefined, baseDir?: string): Promise<ManagedWorktreeProvider> {
606
+ async function resolveSetupProvider(tx: SetupTransaction, requested: WorktreeProvider | undefined, baseDir: string | undefined, repoRoot: string): Promise<ManagedWorktreeProvider> {
582
607
  const selection = requested ?? DEFAULT_WORKTREE_PROVIDER;
583
608
  if (selection !== "auto" && selection !== "native" && selection !== "worktrunk") throw new Error('worktree provider must be "auto", "native", or "worktrunk"');
584
609
  if (selection === "native") return "native";
@@ -586,13 +611,14 @@ async function resolveSetupProvider(tx: SetupTransaction, requested: WorktreePro
586
611
  if (selection === "worktrunk") throw new Error("worktreeProvider='worktrunk' cannot be combined with worktreeBaseDir or PI_SUBAGENTS_WORKTREE_DIR");
587
612
  return "native";
588
613
  }
614
+ if (selection === "auto" && isInsidePiExtensionsDirectory(repoRoot)) return "native";
589
615
  let reason: string | undefined;
590
616
  try {
591
617
  const probeExitCodes = Array.from({ length: 256 }, (_, code) => code);
592
- const version = await tx.command("wt", ["--version"], { maxBuffer: WORKTREE_COMMAND_OUTPUT_MAX_BYTES, acceptedExitCodes: probeExitCodes });
618
+ const version = await tx.command(WORKTRUNK_COMMAND, [...WORKTRUNK_ARG_PREFIX, "--version"], { maxBuffer: WORKTREE_COMMAND_OUTPUT_MAX_BYTES, acceptedExitCodes: probeExitCodes });
593
619
  if (version.status !== 0 || !/\b(?:wt\s+)?v?(\d+\.\d+(?:\.\d+)?)\b/i.test(version.stdout.trim())) reason = "Worktrunk is unavailable or returned an invalid version";
594
620
  else {
595
- const help = await tx.command("wt", ["switch", "--help"], { maxBuffer: WORKTREE_COMMAND_OUTPUT_MAX_BYTES, acceptedExitCodes: probeExitCodes });
621
+ const help = await tx.command(WORKTRUNK_COMMAND, [...WORKTRUNK_ARG_PREFIX, "switch", "--help"], { maxBuffer: WORKTREE_COMMAND_OUTPUT_MAX_BYTES, acceptedExitCodes: probeExitCodes });
596
622
  const missing = ["--create", "--base", "--no-cd", "--no-hooks", "--format"].filter((flag) => !`${help.stdout}\n${help.stderr}`.includes(flag));
597
623
  if (help.status !== 0 || missing.length) reason = `Worktrunk switch capability unavailable: ${missing.join(", ")}`;
598
624
  }
@@ -616,11 +642,13 @@ export function shouldDeferWorktreeCwd(requested: WorktreeProvider | undefined,
616
642
  * to the repository. Managed leaves always nest one level deeper under the
617
643
  * project folder (`basename(repoRoot)`).
618
644
  */
619
- function resolveWorktreeDedicatedRoot(configuredBaseDir: string | undefined, repoRoot: string): string {
645
+ function resolveWorktreeDedicatedRoot(configuredBaseDir: string | undefined, repoRoot: string, relocateExtensionRepo = true): string {
620
646
  const rawBaseDir = configuredBaseDir ?? process.env.PI_SUBAGENTS_WORKTREE_DIR;
621
647
  let expanded: string;
622
648
  if (rawBaseDir === undefined || (configuredBaseDir === undefined && !rawBaseDir.trim())) {
623
- expanded = path.join(path.dirname(repoRoot), "worktrees");
649
+ expanded = relocateExtensionRepo && isInsidePiExtensionsDirectory(repoRoot)
650
+ ? path.join(getAgentDir(), "worktrees")
651
+ : path.join(path.dirname(repoRoot), "worktrees");
624
652
  } else {
625
653
  const trimmed = rawBaseDir.trim();
626
654
  if (!trimmed) throw new Error("worktree base directory cannot be empty");
@@ -942,7 +970,7 @@ async function createWorktrunkWorktree(
942
970
  const naming = buildWorktreeNaming({ runId, index, agent: agents?.[index], label: labels?.[index], task: tasks?.[index], branchPrefix });
943
971
  const args = ["-C", toplevel, "switch", "--create", naming.requestedBranch, "--base", baseCommit, "--no-cd", "--no-hooks", "--format", "json"];
944
972
  tx.attempt(index, naming.requestedBranch);
945
- const result = await tx.command("wt", args, { cwd: toplevel, maxBuffer: WORKTREE_COMMAND_OUTPUT_MAX_BYTES });
973
+ const result = await tx.command(WORKTRUNK_COMMAND, [...WORKTRUNK_ARG_PREFIX, ...args], { cwd: toplevel, maxBuffer: WORKTREE_COMMAND_OUTPUT_MAX_BYTES });
946
974
  tx.progress.phase = "validation";
947
975
  try {
948
976
  const output = parseWorktrunkSwitchOutput(result.stdout);
@@ -1267,7 +1295,7 @@ async function compensateSetup(tx: SetupTransaction): Promise<WorktreeCleanupRep
1267
1295
  deadlineAt: tx.options.deadlineAt, acceptedExitCodes,
1268
1296
  onSpawn: (process) => { Object.assign(tx.progress.command!, process); tx.publish(); },
1269
1297
  });
1270
- const { stdout: _stdout, stderr: _stderr, ...metadata } = result;
1298
+ const { stdout: _stdout, stdoutBuffer: _stdoutBuffer, stderr: _stderr, ...metadata } = result;
1271
1299
  tx.progress.command.result = metadata;
1272
1300
  if (result.processTree?.state === "unknown") tx.unknown(result.error ?? "Rollback command settlement unverified");
1273
1301
  tx.publish();
@@ -1331,9 +1359,11 @@ async function allocateWorktrees(tx: SetupTransaction, cwd: string, runId: strin
1331
1359
  tx.progress.setup.cwd = repo.toplevel;
1332
1360
  tx.progress.setup.baseCommit = repo.baseCommit;
1333
1361
  const setupHook = resolveWorktreeSetupHook(repo.toplevel, options?.setupHook);
1334
- const provider = await resolveSetupProvider(tx, options?.provider, options?.baseDir);
1362
+ const provider = await resolveSetupProvider(tx, options?.provider, options?.baseDir, repo.toplevel);
1335
1363
  const branchPrefix = normalizeWorktreeBranchPrefix(options?.branchPrefix);
1336
- const dedicatedRoot = provider === "native" ? resolveWorktreeDedicatedRoot(options?.baseDir, repo.toplevel) : undefined;
1364
+ const dedicatedRoot = provider === "native"
1365
+ ? resolveWorktreeDedicatedRoot(options?.baseDir, repo.toplevel, (options?.provider ?? DEFAULT_WORKTREE_PROVIDER) === "auto")
1366
+ : undefined;
1337
1367
  const worktrees = tx.progress.setup.worktrees;
1338
1368
 
1339
1369
  try {
@@ -0,0 +1,43 @@
1
+ import type { StreamFn } from "@earendil-works/pi-agent-core";
2
+
3
+ /**
4
+ * Anthropic prices a cache write by the TTL it is asked for: 1.25x base input
5
+ * for the 5m window, 2x for the 1h one. Child sessions are short-lived and
6
+ * rarely idle, so a 1h window they never claim is a flat surcharge on every
7
+ * write. Setting this to `short` keeps that surcharge off children while the
8
+ * parent keeps `long`, the same split Claude Code makes between its main
9
+ * conversation and its subagents.
10
+ *
11
+ * Unset means children inherit the parent's retention, which is the behaviour
12
+ * before this setting existed.
13
+ */
14
+ export function childCacheRetention(env: NodeJS.ProcessEnv = process.env): string | undefined {
15
+ return env.PI_SUBAGENT_CACHE_RETENTION || undefined;
16
+ }
17
+
18
+ /**
19
+ * Launch-environment form for spawned children. Empty when unset, so the child
20
+ * inherits the parent's `PI_CACHE_RETENTION` rather than having it cleared.
21
+ */
22
+ export function childCacheRetentionEnv(env?: NodeJS.ProcessEnv): { PI_CACHE_RETENTION?: string } {
23
+ const retention = childCacheRetention(env);
24
+ return retention ? { PI_CACHE_RETENTION: retention } : {};
25
+ }
26
+
27
+ /**
28
+ * Pi resolves retention per request as `options.env?.[name] || process.env[name]`,
29
+ * so a per-call env beats the process-wide one. Wrapping the session's own
30
+ * stream function keeps this scoped to one child, with no shared-state race
31
+ * against a parent turn streaming concurrently.
32
+ */
33
+ export function pinChildCacheRetention(agent: { streamFunction: StreamFn } | undefined, env?: NodeJS.ProcessEnv): void {
34
+ if (!agent?.streamFunction) return;
35
+ const retention = childCacheRetention(env);
36
+ if (!retention) return;
37
+ const base = agent.streamFunction;
38
+ agent.streamFunction = (model, context, options) =>
39
+ base(model, context, {
40
+ ...options,
41
+ env: { ...(options?.env ?? {}), PI_CACHE_RETENTION: retention },
42
+ });
43
+ }
@@ -1,8 +1,12 @@
1
- import { randomUUID } from "node:crypto";
1
+ // Forking a parent transcript into a child must drop Anthropic's signed and redacted
2
+ // thinking blocks: a thinking signature is bound to the session that produced it and
3
+ // cannot be replayed into a branch. Stripping them is required; disabling the child's
4
+ // thinking is not. Pi >= 0.85.0 recovers from signed-thinking mismatches on the
5
+ // transport, so a sanitized transcript is safe to resume with thinking enabled and the
6
+ // child keeps the level it asked for, reasoning fresh from its first turn.
2
7
  import * as fs from "node:fs";
3
8
  import * as path from "node:path";
4
9
  import { SessionManager } from "@earendil-works/pi-coding-agent";
5
- import { findModelInfo, type ModelInfo } from "./model-info.ts";
6
10
 
7
11
  type SubagentExecutionContext = "fresh" | "fork";
8
12
 
@@ -19,7 +23,6 @@ interface BranchSessionEntry {
19
23
  api?: string;
20
24
  model?: string;
21
25
  };
22
- thinkingLevel?: string;
23
26
  }
24
27
 
25
28
  interface BranchSessionManager {
@@ -39,20 +42,11 @@ interface ForkContextResolverOptions {
39
42
  openSession?: (path: string, sessionDir?: string) => BranchSessionManager;
40
43
  /** Rewrite a created fork before its path can be used to spawn a child. */
41
44
  pruneSession?: (sessionFile: string) => Promise<void>;
42
- /** Decide per child index whether a sanitized transcript must also disable the child's
43
- * thinking. Defaults to true (the pre-existing conservative behavior) when omitted. */
44
- forceThinkingOffForIndex?: (index: number) => boolean;
45
- }
46
-
47
- interface ForkContextResolution {
48
- sessionFile: string;
49
- thinkingOverride?: "off";
50
45
  }
51
46
 
52
47
  interface ForkContextResolver {
53
48
  prepareSessionForIndex(index?: number): Promise<void>;
54
49
  sessionFileForIndex(index?: number): string | undefined;
55
- thinkingOverrideForIndex(index?: number): "off" | undefined;
56
50
  }
57
51
 
58
52
  export function resolveSubagentContext(value: unknown): SubagentExecutionContext {
@@ -101,20 +95,6 @@ export function canPreferForkFromSnapshot(input: PreferredForkSnapshot): boolean
101
95
  }
102
96
  }
103
97
 
104
- /** Decide whether a resolved child model uses Anthropic's provider or message API, which
105
- * requires the sanitized fork to disable thinking. Unknown models stay conservative. */
106
- export function forkedChildRequiresThinkingOff(
107
- model: string | undefined,
108
- availableModels?: ModelInfo[],
109
- preferredProvider?: string,
110
- ): boolean {
111
- if (!model) return true;
112
- const info = findModelInfo(model, availableModels, preferredProvider);
113
- if (!info) return true;
114
- return info.provider.toLowerCase() === "anthropic"
115
- || info.api?.toLowerCase() === "anthropic-messages";
116
- }
117
-
118
98
  function isUnsafeAnthropicThinkingBlock(message: BranchSessionEntry["message"], block: unknown): boolean {
119
99
  if (!message || !block || typeof block !== "object" || !("type" in block)) return false;
120
100
  const provider = typeof message.provider === "string" ? message.provider.toLowerCase() : "";
@@ -128,28 +108,6 @@ function isUnsafeAnthropicThinkingBlock(message: BranchSessionEntry["message"],
128
108
  return record.redacted === true || (typeof signature === "string" && signature.length > 0);
129
109
  }
130
110
 
131
- function createEntryId(entries: BranchSessionEntry[]): string {
132
- const ids = new Set(entries.map((entry) => entry.id).filter((id): id is string => typeof id === "string"));
133
- for (let attempt = 0; attempt < 100; attempt++) {
134
- const id = randomUUID().slice(0, 8);
135
- if (!ids.has(id)) return id;
136
- }
137
- return randomUUID();
138
- }
139
-
140
- function appendThinkingOffEntry(entries: BranchSessionEntry[]): void {
141
- const last = entries[entries.length - 1];
142
- if (last?.type === "thinking_level_change" && last.thinkingLevel === "off") return;
143
- const parent = [...entries].reverse().find((entry) => typeof entry.id === "string");
144
- entries.push({
145
- type: "thinking_level_change",
146
- id: createEntryId(entries),
147
- parentId: parent?.id ?? null,
148
- timestamp: new Date().toISOString(),
149
- thinkingLevel: "off",
150
- });
151
- }
152
-
153
111
  function sanitizeUnsafeThinkingBlocks(entries: BranchSessionEntry[]): boolean {
154
112
  let sanitized = false;
155
113
  for (const entry of entries) {
@@ -183,7 +141,6 @@ export function createForkContextResolver(
183
141
  return {
184
142
  prepareSessionForIndex: async () => {},
185
143
  sessionFileForIndex: () => undefined,
186
- thinkingOverrideForIndex: () => undefined,
187
144
  };
188
145
  }
189
146
 
@@ -217,12 +174,12 @@ export function createForkContextResolver(
217
174
  path.basename(parentSessionFile, ".jsonl"),
218
175
  "forks",
219
176
  );
220
- const cachedResolutions = new Map<number, ForkContextResolution>();
177
+ const cachedSessionFiles = new Map<number, string>();
221
178
  const preparedIndexes = new Set<number>();
222
179
  const preparationPromises = new Map<number, Promise<void>>();
223
180
 
224
- const resolveFork = (index = 0): ForkContextResolution => {
225
- const cached = cachedResolutions.get(index);
181
+ const resolveFork = (index = 0): string => {
182
+ const cached = cachedSessionFiles.get(index);
226
183
  if (cached) return cached;
227
184
  try {
228
185
  if (!fs.existsSync(parentSessionFile)) {
@@ -233,34 +190,23 @@ export function createForkContextResolver(
233
190
  if (!sessionFile) {
234
191
  throw new Error("Session manager did not return a forked session file.");
235
192
  }
236
- const forceThinkingOff = (sanitized: boolean): boolean =>
237
- sanitized && (options.forceThinkingOffForIndex?.(index) ?? true);
238
- let thinkingOverride: "off" | undefined;
239
193
  if (!fs.existsSync(sessionFile)) {
240
194
  const header = sourceManager.getHeader?.();
241
195
  const entries = sourceManager.getEntries?.();
242
196
  if (!header || !entries) {
243
197
  throw new Error(`Session manager returned a forked session file that does not exist and cannot be persisted by fallback: ${sessionFile}`);
244
198
  }
245
- if (forceThinkingOff(sanitizeUnsafeThinkingBlocks(entries))) {
246
- appendThinkingOffEntry(entries);
247
- thinkingOverride = "off";
248
- }
199
+ sanitizeUnsafeThinkingBlocks(entries);
249
200
  fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
250
201
  fs.writeFileSync(sessionFile, `${[header, ...entries].map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf-8");
251
202
  } else {
252
203
  const entries = readSessionEntries(sessionFile);
253
204
  if (sanitizeUnsafeThinkingBlocks(entries)) {
254
- if (forceThinkingOff(true)) {
255
- appendThinkingOffEntry(entries);
256
- thinkingOverride = "off";
257
- }
258
205
  fs.writeFileSync(sessionFile, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf-8");
259
206
  }
260
207
  }
261
- const resolution = { sessionFile, ...(thinkingOverride ? { thinkingOverride } : {}) };
262
- cachedResolutions.set(index, resolution);
263
- return resolution;
208
+ cachedSessionFiles.set(index, sessionFile);
209
+ return sessionFile;
264
210
  } catch (error) {
265
211
  const cause = error instanceof Error ? error : new Error(String(error));
266
212
  throw new Error(`Failed to create forked subagent session: ${cause.message}`, { cause });
@@ -269,11 +215,11 @@ export function createForkContextResolver(
269
215
 
270
216
  return {
271
217
  async prepareSessionForIndex(index = 0): Promise<void> {
272
- const resolution = resolveFork(index);
218
+ const sessionFile = resolveFork(index);
273
219
  if (!options.pruneSession || preparedIndexes.has(index)) return;
274
220
  let preparation = preparationPromises.get(index);
275
221
  if (!preparation) {
276
- preparation = options.pruneSession(resolution.sessionFile).then(() => {
222
+ preparation = options.pruneSession(sessionFile).then(() => {
277
223
  preparedIndexes.add(index);
278
224
  });
279
225
  preparationPromises.set(index, preparation);
@@ -284,10 +230,7 @@ export function createForkContextResolver(
284
230
  if (options.pruneSession && !preparedIndexes.has(index)) {
285
231
  throw new Error(`Pruned fork session ${index} was used before pruning completed.`);
286
232
  }
287
- return resolveFork(index).sessionFile;
288
- },
289
- thinkingOverrideForIndex(index = 0): "off" | undefined {
290
- return resolveFork(index).thinkingOverride;
233
+ return resolveFork(index);
291
234
  },
292
235
  };
293
236
  }
@@ -1,10 +1,13 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import type { AgentConfig } from "../agents/agents.ts";
4
+ import type { PiLaunchToolPlan } from "../runs/shared/child-tool-plan.ts";
4
5
  import type { ExtensionBindings } from "../runs/shared/extension-bindings.ts";
5
6
 
6
- export const AGENT_DEFINITION_PROJECTION_VERSION = 1 as const;
7
- export const LAUNCH_BINDING_PROJECTION_VERSION = 1 as const;
7
+ export const AGENT_DEFINITION_PROJECTION_VERSION = 2 as const;
8
+ // v2: the Intercom bridge prompt and tools are part of the binding on every
9
+ // path, and the bridge text no longer names the parent session.
10
+ export const LAUNCH_BINDING_PROJECTION_VERSION = 2 as const;
8
11
 
9
12
  function stableJson(value: unknown): string {
10
13
  if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
@@ -47,7 +50,6 @@ export function projectAgentDefinition(agent: AgentConfig): Record<string, unkno
47
50
  inheritSkills: agent.inheritSkills,
48
51
  model: agent.model,
49
52
  modelProvider: agent.modelProvider,
50
- fallbackModels: agent.fallbackModels,
51
53
  fast: agent.fast,
52
54
  thinking: agent.thinking,
53
55
  tools: agent.tools,
@@ -60,6 +62,7 @@ export function projectAgentDefinition(agent: AgentConfig): Record<string, unkno
60
62
  skills: agent.skills,
61
63
  skillPath: agent.skillPath,
62
64
  output: agent.output,
65
+ outputSchema: agent.outputSchema,
63
66
  defaultReads: agent.defaultReads,
64
67
  defaultProgress: agent.defaultProgress,
65
68
  defaultContext: agent.defaultContext,
@@ -75,8 +78,9 @@ export function projectAgentDefinition(agent: AgentConfig): Record<string, unkno
75
78
  };
76
79
  }
77
80
 
81
+ /** Digest of the parsed definition; a runtime overlay that already captured it wins over re-hashing the overlaid copy. */
78
82
  export function agentDefinitionDigest(agent: AgentConfig): string {
79
- return stableJsonDigest(projectAgentDefinition(agent));
83
+ return agent.definitionDigest ?? stableJsonDigest(projectAgentDefinition(agent));
80
84
  }
81
85
 
82
86
  export interface LaunchBindingInput {
@@ -84,7 +88,6 @@ export interface LaunchBindingInput {
84
88
  /** Caller task; runtime acceptance/output task annotations are explicitly outside the preflight-known subset. */
85
89
  task?: string;
86
90
  model?: string;
87
- modelCandidates?: string[];
88
91
  fast?: boolean;
89
92
  thinking?: string;
90
93
  systemPrompt?: string | null;
@@ -110,9 +113,7 @@ export function projectLaunchBinding(input: LaunchBindingInput): Record<string,
110
113
  version: LAUNCH_BINDING_PROJECTION_VERSION,
111
114
  definitionDigest: input.definitionDigest,
112
115
  taskDigest: input.task === undefined ? undefined : stableJsonDigest(input.task),
113
- // The ordered candidate set already contains each attempted model; keeping only
114
- // this set makes retries correlate to the same preflight binding.
115
- modelCandidates: input.modelCandidates,
116
+ model: input.model,
116
117
  fast: input.fast,
117
118
  thinking: input.thinking,
118
119
  systemPromptDigest: input.systemPrompt === undefined || input.systemPrompt === null ? undefined : stableJsonDigest(input.systemPrompt),
@@ -136,3 +137,62 @@ export function projectLaunchBinding(input: LaunchBindingInput): Record<string,
136
137
  export function launchBindingDigest(input: LaunchBindingInput): string {
137
138
  return stableJsonDigest(projectLaunchBinding(input));
138
139
  }
140
+
141
+ type LaunchBindingPromptMode = Pick<LaunchBindingInput, "systemPromptMode" | "inheritProjectContext" | "inheritGlobalContext" | "inheritSkills">;
142
+
143
+ /**
144
+ * Who the child is: the launched agent, or the identity a persisted step
145
+ * already captured when a runner recomputes the binding per model attempt.
146
+ */
147
+ export type LaunchBindingIdentity =
148
+ | { agent: AgentConfig }
149
+ | ({ definitionDigest: string } & LaunchBindingPromptMode);
150
+
151
+ export type LaunchBindingSource = LaunchBindingIdentity
152
+ & Pick<LaunchBindingInput, "fast" | "thinking" | "skills" | "outputPath" | "outputMode" | "structuredOutputSchema" | "extensionBindings">
153
+ & {
154
+ task: string;
155
+ model?: string;
156
+ /** Effective child system prompt before runtime acceptance prose. */
157
+ systemPrompt: string;
158
+ toolPlan: Pick<PiLaunchToolPlan, "effectiveToolAllowlist" | "excludeTools" | "extensionArgs" | "effectiveMcpTools">;
159
+ };
160
+
161
+ export interface LaunchBinding {
162
+ definitionDigest: string;
163
+ launchContractDigest: string;
164
+ }
165
+
166
+ /**
167
+ * Assemble launch identity from resolved preflight or execution inputs.
168
+ * The stable projection omits undefined optional fields.
169
+ */
170
+ export function resolveLaunchBinding(source: LaunchBindingSource): LaunchBinding {
171
+ const identity = "agent" in source ? {
172
+ definitionDigest: agentDefinitionDigest(source.agent),
173
+ systemPromptMode: source.agent.systemPromptMode,
174
+ inheritProjectContext: source.agent.inheritProjectContext,
175
+ inheritGlobalContext: source.agent.inheritGlobalContext,
176
+ inheritSkills: source.agent.inheritSkills,
177
+ } : source;
178
+ return {
179
+ definitionDigest: identity.definitionDigest,
180
+ launchContractDigest: launchBindingDigest({
181
+ ...identity,
182
+ task: source.task,
183
+ model: source.model,
184
+ fast: source.fast,
185
+ thinking: source.thinking || undefined,
186
+ systemPrompt: source.systemPrompt,
187
+ skills: source.skills,
188
+ tools: source.toolPlan.effectiveToolAllowlist,
189
+ excludeTools: source.toolPlan.excludeTools.length > 0 ? source.toolPlan.excludeTools : undefined,
190
+ extensions: source.toolPlan.extensionArgs,
191
+ mcpDirectTools: source.toolPlan.effectiveMcpTools,
192
+ outputPath: source.outputPath || undefined,
193
+ outputMode: source.outputMode,
194
+ structuredOutputSchema: source.structuredOutputSchema,
195
+ extensionBindings: source.extensionBindings || undefined,
196
+ }),
197
+ };
198
+ }
@@ -0,0 +1,30 @@
1
+ import type { Api, Model, ProviderHeaders } from "@earendil-works/pi-ai";
2
+
3
+ const OPENCODE_HOST = "opencode.ai";
4
+
5
+ function matchesOpenCodeHost(baseUrl: string): boolean {
6
+ try {
7
+ return new URL(baseUrl).hostname === OPENCODE_HOST;
8
+ } catch {
9
+ return false;
10
+ }
11
+ }
12
+
13
+ /**
14
+ * OpenCode session-routing headers for internal subagent model calls.
15
+ *
16
+ * Pi's own session path emits these from coding-agent's provider-attribution
17
+ * merge, but subagent-internal calls (watchdog review, permission arbiter,
18
+ * task-mutation arbiter, prompt audit) stream through bare Agents that bypass
19
+ * that path. Without them OpenCode falls back to client-IP affinity and loses
20
+ * prompt-cache routing (see pi issue #4847). Returns undefined for every other
21
+ * provider so non-OpenCode requests stay byte-identical.
22
+ */
23
+ export function opencodeSessionHeaders(
24
+ model: Pick<Model<Api>, "provider" | "baseUrl">,
25
+ sessionId: string | undefined,
26
+ ): ProviderHeaders | undefined {
27
+ if (!sessionId) return undefined;
28
+ if (model.provider !== "opencode" && model.provider !== "opencode-go" && !matchesOpenCodeHost(model.baseUrl)) return undefined;
29
+ return { "x-opencode-session": sessionId, "x-opencode-client": "pi" };
30
+ }
@@ -5,7 +5,7 @@ import * as path from "node:path";
5
5
  import type { TextContent } from "@earendil-works/pi-ai";
6
6
  import { completeSimple } from "@earendil-works/pi-ai/compat";
7
7
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
- import { resolveModelCandidate } from "../runs/shared/model-fallback.ts";
8
+ import { resolveModelCandidate } from "../runs/shared/model-resolution.ts";
9
9
  import { splitKnownThinkingSuffix, toModelInfo } from "./model-info.ts";
10
10
  import type { ForkContextConfig } from "./types.ts";
11
11