pi-subagents 0.64.0 → 0.65.1

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 (106) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +2 -2
  3. package/agents/reviewer.md +1 -1
  4. package/agents/scout.md +1 -1
  5. package/docs/agents.md +19 -17
  6. package/docs/configuration.md +23 -15
  7. package/docs/extension-api.md +18 -6
  8. package/docs/missions.md +2 -0
  9. package/docs/observability.md +10 -12
  10. package/docs/tool-reference.md +15 -8
  11. package/docs/watchdog.md +1 -1
  12. package/docs/workflows.md +13 -3
  13. package/package.json +5 -4
  14. package/runner-server-preload.mjs +13 -0
  15. package/skills/pi-subagents/SKILL.md +2 -1
  16. package/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
  17. package/skills/pi-subagents/references/execution-controls.md +7 -3
  18. package/skills/pi-subagents/references/multi-lane-orchestration.md +2 -0
  19. package/src/agents/agent-management.ts +47 -15
  20. package/src/api/capability-ceiling.ts +0 -1
  21. package/src/api/{pi-args.ts → child-tool-plan.ts} +1 -1
  22. package/src/api/preflight.ts +7 -4
  23. package/src/extension/config.ts +4 -2
  24. package/src/extension/doctor.ts +2 -10
  25. package/src/extension/fanout-child.ts +9 -11
  26. package/src/extension/index.ts +58 -7
  27. package/src/extension/public-execution.ts +14 -0
  28. package/src/extension/rpc.ts +3 -2
  29. package/src/extension/schemas.ts +3 -2
  30. package/src/extension/tool-description.ts +13 -8
  31. package/src/integrations/pi-web-session-liveness.ts +73 -0
  32. package/src/intercom/native-supervisor-channel.ts +159 -95
  33. package/src/intercom/supervisor-ui.ts +244 -0
  34. package/src/missions/workflow-state.ts +37 -16
  35. package/src/runs/background/async-execution.ts +51 -25
  36. package/src/runs/background/async-job-tracker.ts +11 -0
  37. package/src/runs/background/async-resume.ts +14 -3
  38. package/src/runs/background/async-retention.ts +9 -0
  39. package/src/runs/background/control-channel.ts +2 -204
  40. package/src/runs/background/notify.ts +2 -0
  41. package/src/runs/background/process-terminal.ts +1 -1
  42. package/src/runs/background/retained-nested-route-tracker.ts +96 -0
  43. package/src/runs/background/run-child-session.ts +614 -0
  44. package/src/runs/background/run-status.ts +0 -1
  45. package/src/runs/background/runner-aliases.ts +152 -0
  46. package/src/runs/background/runner-child-sessions.ts +31 -0
  47. package/src/runs/background/scheduled-runs.ts +18 -4
  48. package/src/runs/background/subagent-runner.ts +203 -901
  49. package/src/runs/foreground/async-steering-action.ts +1 -17
  50. package/src/runs/foreground/execution.ts +204 -378
  51. package/src/runs/foreground/foreground-control.ts +4 -0
  52. package/src/runs/foreground/foreground-history.ts +3 -1
  53. package/src/runs/foreground/prompt-audit.ts +9 -5
  54. package/src/runs/foreground/subagent-executor.ts +161 -91
  55. package/src/runs/foreground/workflow-foreground-steering.ts +24 -98
  56. package/src/runs/shared/abort-recovery.ts +3 -3
  57. package/src/runs/shared/acceptance.ts +14 -1
  58. package/src/runs/shared/capability-ceiling.ts +1 -2
  59. package/src/runs/shared/child-hooks.ts +25 -0
  60. package/src/runs/shared/child-identity.ts +13 -2
  61. package/src/runs/shared/child-launch.ts +314 -0
  62. package/src/runs/shared/child-lifecycle.ts +25 -0
  63. package/src/runs/shared/child-runtime-config.ts +126 -0
  64. package/src/runs/shared/child-session.ts +337 -0
  65. package/src/runs/shared/child-tool-plan.ts +530 -0
  66. package/src/runs/shared/claude-code-adapter.ts +5 -1
  67. package/src/runs/shared/completion-guard.ts +1 -1
  68. package/src/runs/shared/external-cli-preflight.ts +16 -0
  69. package/src/runs/shared/llm-intent-arbiter.ts +20 -11
  70. package/src/runs/shared/mcp-direct-tool-allowlist.ts +5 -4
  71. package/src/runs/shared/model-exclusions.ts +84 -15
  72. package/src/runs/shared/model-fallback.ts +78 -6
  73. package/src/runs/shared/nested-events.ts +32 -48
  74. package/src/runs/shared/nested-path.ts +0 -14
  75. package/src/runs/shared/orca-progress-tabs.ts +12 -7
  76. package/src/runs/shared/parallel-utils.ts +1 -2
  77. package/src/runs/shared/permissions.ts +0 -13
  78. package/src/runs/shared/process-signal.ts +4 -1
  79. package/src/runs/shared/run-fanout-budget.ts +0 -13
  80. package/src/runs/shared/runtime-acknowledged-extensions.ts +0 -27
  81. package/src/runs/shared/structured-output.ts +17 -4
  82. package/src/runs/shared/subagent-control.ts +2 -0
  83. package/src/runs/shared/subagent-prompt-runtime.ts +87 -384
  84. package/src/runs/shared/tool-availability.ts +18 -62
  85. package/src/runs/shared/tool-budget.ts +0 -14
  86. package/src/runs/shared/worktree-cleanup-plan.ts +31 -9
  87. package/src/runs/shared/worktree.ts +192 -40
  88. package/src/shared/child-session-name.ts +1 -1
  89. package/src/shared/jsonl-writer.ts +11 -0
  90. package/src/shared/model-response-aliases.ts +13 -0
  91. package/src/shared/thinking-ceiling.ts +0 -6
  92. package/src/shared/types.ts +63 -28
  93. package/src/shared/utils.ts +6 -4
  94. package/src/shared/watch-strategy.ts +2 -0
  95. package/src/slash/slash-commands.ts +0 -6
  96. package/src/tui/fleet-status.ts +1 -1
  97. package/src/tui/fleet.ts +0 -1
  98. package/src/tui/render.ts +254 -41
  99. package/src/watchdog/child-status.ts +0 -1
  100. package/src/watchdog/register-child.ts +12 -12
  101. package/src/workflows/scripted-workflow.ts +80 -7
  102. package/src/workflows/workflow-checklist.ts +4 -3
  103. package/src/runs/shared/child-protocol.ts +0 -415
  104. package/src/runs/shared/pi-args.ts +0 -1059
  105. package/src/runs/shared/subagent-startup-retry.ts +0 -116
  106. package/src/shared/post-exit-stdio-guard.ts +0 -85
@@ -1,10 +1,3 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
-
4
- export const REQUIRED_CHILD_TOOLS_ENV = "PI_SUBAGENT_REQUIRED_TOOLS";
5
- export const MCP_DIRECT_CHILD_TOOLS_ENV = "PI_SUBAGENT_MCP_DIRECT_TOOLS";
6
- export const CHILD_TOOL_DIAGNOSTIC_PATH_ENV = "PI_SUBAGENT_TOOL_DIAGNOSTIC_PATH";
7
-
8
1
  export interface ChildToolDiagnostic {
9
2
  agent?: string;
10
3
  required: string[];
@@ -13,53 +6,25 @@ export interface ChildToolDiagnostic {
13
6
  missingMcpDirectTools?: string[];
14
7
  }
15
8
 
16
- export function writeChildToolDiagnostic(
17
- filePath: string,
18
- required: string[],
19
- available: string[],
20
- agent?: string,
21
- mcpDirectTools?: string[],
22
- ): ChildToolDiagnostic | undefined {
23
- const availableNames = new Set(available);
24
- const missing = required.filter((name) => !availableNames.has(name));
25
- if (missing.length === 0) {
26
- fs.rmSync(filePath, { force: true });
27
- return undefined;
28
- }
29
-
30
- const missingMcpDirectTools = mcpDirectTools?.length
31
- ? missing.filter((name) => mcpDirectTools.includes(name))
32
- : [];
33
- const diagnostic: ChildToolDiagnostic = {
34
- agent,
35
- required,
36
- available,
37
- missing,
38
- ...(missingMcpDirectTools.length > 0 ? { missingMcpDirectTools } : {}),
39
- };
40
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
41
- fs.writeFileSync(filePath, JSON.stringify(diagnostic), { mode: 0o600 });
42
- return diagnostic;
43
- }
44
-
45
- export function readChildToolDiagnostic(filePath: string | undefined): ChildToolDiagnostic | undefined {
46
- if (!filePath || !fs.existsSync(filePath)) return undefined;
47
- const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Partial<ChildToolDiagnostic>;
48
- const stringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every((entry) => typeof entry === "string" && entry.length > 0);
49
- if (!stringArray(parsed.required) || !stringArray(parsed.available) || !stringArray(parsed.missing) || (parsed.agent !== undefined && typeof parsed.agent !== "string") || (parsed.missingMcpDirectTools !== undefined && !stringArray(parsed.missingMcpDirectTools))) {
50
- throw new Error(`Malformed child tool diagnostic at '${filePath}'.`);
51
- }
52
- return {
53
- ...(parsed.agent ? { agent: parsed.agent } : {}),
54
- required: parsed.required,
55
- available: parsed.available,
56
- missing: parsed.missing,
57
- ...(parsed.missingMcpDirectTools ? { missingMcpDirectTools: parsed.missingMcpDirectTools } : {}),
58
- };
59
- }
60
-
61
- export function formatChildToolDiagnostic(diagnostic: ChildToolDiagnostic): string {
9
+ /**
10
+ * Explain missing child tools. Foreground children run inside the parent
11
+ * process and never load the parent's ambient extensions, so tools an ambient
12
+ * extension registers (MCP tools, provider tools) only exist for background
13
+ * children; the diagnostic says so instead of reporting a generic gap.
14
+ */
15
+ export function formatChildToolDiagnostic(diagnostic: ChildToolDiagnostic, options: { host?: "parent" | "runner" } = {}): string {
62
16
  const subject = diagnostic.agent ? `Agent '${diagnostic.agent}'` : "Subagent";
17
+ if (options.host === "parent") {
18
+ return [
19
+ `${subject} ran as a foreground child, which never loads the parent's ambient extensions, and these child tools were unavailable: ${diagnostic.missing.join(", ")}.`,
20
+ "The `tools` field is a strict allowlist; it does not load extension code.",
21
+ ...(diagnostic.missingMcpDirectTools?.length
22
+ ? [`MCP direct tools missing from the child registry: ${diagnostic.missingMcpDirectTools.join(", ")}.`]
23
+ : []),
24
+ "Agents that need MCP tools (`mcpDirectTools`, or MCP tools from an ambient adapter such as pi-mcp-adapter) or models from a provider extension must run as background children (`async: true`), which load the ambient extensions.",
25
+ "For extension tools a foreground child can load, add the provider path to `subagentOnlyExtensions` (child-only), `extensions`, or as a path-like entry in `tools`, while keeping each registered tool name in `tools`.",
26
+ ].join("\n");
27
+ }
63
28
  return [
64
29
  `${subject} requested unavailable child tools: ${diagnostic.missing.join(", ")}.`,
65
30
  "The `tools` field is a strict allowlist; it does not load extension code.",
@@ -70,12 +35,3 @@ export function formatChildToolDiagnostic(diagnostic: ChildToolDiagnostic): stri
70
35
  "For MCP tools, verify the MCP adapter configuration and selected tool names. For builtin tools, verify the name against the installed Pi version.",
71
36
  ].join("\n");
72
37
  }
73
-
74
- export function readChildToolDiagnosticError(filePath: string | undefined): string | undefined {
75
- try {
76
- const diagnostic = readChildToolDiagnostic(filePath);
77
- return diagnostic ? formatChildToolDiagnostic(diagnostic) : undefined;
78
- } catch (error) {
79
- return `Failed to read child tool availability diagnostic: ${error instanceof Error ? error.message : String(error)}`;
80
- }
81
- }
@@ -1,8 +1,6 @@
1
1
  import type { ResolvedToolBudget, ToolBudgetConfig, ToolBudgetState } from "../../shared/types.ts";
2
2
 
3
3
  export const DEFAULT_TOOL_BUDGET_BLOCK = ["read", "grep", "find", "ls"] as const;
4
- export const TOOL_BUDGET_ENV = "PI_SUBAGENT_TOOL_BUDGET";
5
- export const TOOL_BUDGET_ZERO_AUTH_ENV = "PI_SUBAGENT_TOOL_BUDGET_ZERO_AUTH";
6
4
 
7
5
  export function normalizeToolBudgetBlock(block: ToolBudgetConfig["block"] | undefined): "*" | string[] {
8
6
  if (block === "*") return "*";
@@ -66,15 +64,3 @@ export function toolBudgetSoftNudge(budget: ResolvedToolBudget, toolCount: numbe
66
64
  export function toolBudgetBlockedMessage(budget: ResolvedToolBudget, toolName: string, toolCount: number): string {
67
65
  return `Tool budget hard limit reached after ${toolCount} tool call${toolCount === 1 ? "" : "s"} (hard ${budget.hard}). The '${toolName}' tool is blocked so you can finalize from the context you already have.`;
68
66
  }
69
-
70
- export function encodeToolBudgetEnv(budget: ResolvedToolBudget | undefined): string | undefined {
71
- return budget ? JSON.stringify(budget) : undefined;
72
- }
73
-
74
- export function decodeToolBudgetEnv(value: string | undefined, options: { allowZero?: boolean } = {}): ResolvedToolBudget | undefined {
75
- if (!value?.trim()) return undefined;
76
- const parsed = JSON.parse(value) as unknown;
77
- const normalized = validateToolBudgetConfig(parsed, TOOL_BUDGET_ENV, options.allowZero ? { minimumHard: 0 } : undefined);
78
- if (normalized.error) throw new Error(normalized.error);
79
- return normalized.budget;
80
- }
@@ -13,7 +13,9 @@ import type {
13
13
  ParallelHandoffGroup,
14
14
  ParallelHandoffManifest,
15
15
  } from "../../shared/types.ts";
16
+ import { getAgentDir } from "../../shared/utils.ts";
16
17
  import { isTerminalParallelHandoffChildStatus } from "./parallel-handoff.ts";
18
+ import { MACHINE_DIFF_OPTIONS, validateWorktreePatchRepresentsCurrentWorktree } from "./worktree.ts";
17
19
 
18
20
  export const WORKTREE_CLEANUP_PLAN_VERSION = 1 as const;
19
21
  export const WORKTREE_CLEANUP_PLAN_TTL_MS = 30 * 60 * 1000;
@@ -244,11 +246,24 @@ function resolveRepoRoot(repo: string): string {
244
246
  }
245
247
 
246
248
  function resolveCleanupBaseDir(repoRoot: string, configuredBaseDir: string | undefined): string {
247
- const raw = configuredBaseDir ?? process.env.PI_SUBAGENTS_WORKTREE_DIR ?? os.tmpdir();
248
- const trimmed = raw.trim();
249
- if (!trimmed) throw new Error("worktree base directory cannot be empty");
250
- const expanded = trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(2)) : trimmed;
251
- return path.resolve(path.isAbsolute(expanded) ? expanded : path.resolve(repoRoot, expanded));
249
+ const raw = configuredBaseDir ?? process.env.PI_SUBAGENTS_WORKTREE_DIR;
250
+ let dedicatedRoot: string;
251
+ if (raw === undefined || (configuredBaseDir === undefined && !raw.trim())) {
252
+ dedicatedRoot = path.join(path.dirname(repoRoot), "worktrees");
253
+ } else {
254
+ const trimmed = raw.trim();
255
+ if (!trimmed) throw new Error("worktree base directory cannot be empty");
256
+ const expanded = trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(2)) : trimmed;
257
+ dedicatedRoot = path.isAbsolute(expanded) ? expanded : path.resolve(repoRoot, expanded);
258
+ }
259
+ return path.resolve(path.join(dedicatedRoot, path.basename(repoRoot)));
260
+ }
261
+
262
+ function cleanupContainmentInvalid(repoRoot: string, projectDir: string): boolean {
263
+ const extensionsDir = path.join(getAgentDir(), "extensions");
264
+ return samePath(projectDir, repoRoot)
265
+ || pathInside(repoRoot, projectDir, true)
266
+ || pathInside(extensionsDir, projectDir);
252
267
  }
253
268
 
254
269
  export function parseGitWorktreeList(raw: string): GitWorktreeRecord[] {
@@ -558,7 +573,7 @@ function resolveBranchTip(repoRoot: string, branch: string): { value?: string; e
558
573
  return { value: result.stdout.trim() };
559
574
  }
560
575
 
561
- function isPatchCaptured(record: ManifestMetadataRecord, worktreePath: string): { path?: string; error?: string } {
576
+ function isPatchCaptured(record: ManifestMetadataRecord, worktreePath: string, baseCommit: string): { path?: string; error?: string } {
562
577
  const patchPath = metadataPatchPath(record);
563
578
  if (!patchPath || record.child.patch?.error !== undefined || record.child.patch?.changed !== true) return {};
564
579
  const inspection = inspectPath(patchPath);
@@ -573,12 +588,15 @@ function isPatchCaptured(record: ManifestMetadataRecord, worktreePath: string):
573
588
  if (!patchStat.isFile()) return { error: "captured handoff patch is not a file" };
574
589
  if (pathInside(worktreePath, inspection.realpath)) return { error: "durable handoff patch lives inside the worktree" };
575
590
  if (patchStat.size <= 0) return { error: "captured handoff patch is empty" };
591
+ const validationError = validateWorktreePatchRepresentsCurrentWorktree(worktreePath, baseCommit, patchPath);
592
+ if (validationError) return { error: `captured handoff patch failed validation: ${validationError}` };
576
593
  return { path: patchPath };
577
594
  }
578
595
 
579
596
  function buildManagedEntry(input: {
580
597
  repoRoot: string;
581
598
  baseDir: string;
599
+ containmentInvalid: boolean;
582
600
  targetHead: string;
583
601
  rootPath: string;
584
602
  git: GitWorktreeRecord;
@@ -605,7 +623,9 @@ function buildManagedEntry(input: {
605
623
  if (pathInspection.missing) return blockedEntry(entry, "stale", "unknown", "worktree path is missing from disk");
606
624
  if (pathInspection.symlink) return blockedEntry(entry, "unknown", "unknown", "worktree path is a symlink; cleanup requires a real directory");
607
625
  if (!pathInspection.directory || !pathInspection.realpath) return blockedEntry(entry, "unknown", "unknown", "worktree path is not a directory");
608
- if (!pathInside(baseDir, pathInspection.realpath, true)) return blockedEntry(entry, "ineligible", "keep", `worktree real path is outside configured base directory '${baseDir}'`);
626
+ const extensionsDir = path.join(getAgentDir(), "extensions");
627
+ if (pathInside(extensionsDir, pathInspection.realpath)) return blockedEntry(entry, "ineligible", "keep", `worktree real path is inside Pi extensions directory '${extensionsDir}'`);
628
+ if (input.containmentInvalid || !pathInside(baseDir, pathInspection.realpath, true)) return blockedEntry(entry, "ineligible", "keep", `worktree real path is outside configured base directory '${baseDir}'`);
609
629
 
610
630
  if (rootPath === comparablePath(pathInspection.realpath)) return blockedEntry(entry, "ineligible", "keep", "worktree is the repository root");
611
631
  if (!git.branch) return blockedEntry(entry, "unknown", "unknown", "detached worktrees have no metadata-recorded branch");
@@ -648,14 +668,14 @@ function buildManagedEntry(input: {
648
668
  entry.preconditions.statusDigest = status.digest;
649
669
  if (status.output && status.output.trim()) return blockedEntry(entry, "dirty", "keep", "worktree has uncommitted or untracked changes");
650
670
 
651
- const diff = runGit(worktreePath, ["diff", "--quiet", resolvedBase.value, "--"]);
671
+ const diff = runGit(worktreePath, ["diff", "--quiet", ...MACHINE_DIFF_OPTIONS, resolvedBase.value, "--"]);
652
672
  if (diff.status !== 0 && diff.status !== 1) return blockedEntry(entry, "unknown", "unknown", `git diff safety check failed: ${gitFailure(diff, "git diff")}`);
653
673
  const ancestor = runGit(repoRoot, ["merge-base", "--is-ancestor", git.head, targetHead]);
654
674
  if (ancestor.status !== 0 && ancestor.status !== 1) return blockedEntry(entry, "unknown", "unknown", `local merge safety check failed: ${gitFailure(ancestor, "git merge-base --is-ancestor")}`);
655
675
  const branchTipIsAncestor = ancestor.status === 0;
656
676
  let divergenceSafe = diff.status === 0;
657
677
  if (!divergenceSafe) {
658
- const captured = isPatchCaptured(record, worktreePath);
678
+ const captured = isPatchCaptured(record, worktreePath, resolvedBase.value);
659
679
  if (captured.error) return blockedEntry(entry, "ineligible", "keep", captured.error);
660
680
  if (captured.path) {
661
681
  entry.patchPath = captured.path;
@@ -725,6 +745,7 @@ export function buildWorktreeCleanupPlan(input: BuildWorktreeCleanupPlanInput):
725
745
  const now = input.now ?? Date.now();
726
746
  if (!Number.isFinite(now)) throw new Error("worktree cleanup plan timestamp must be finite");
727
747
  const baseDir = resolveCleanupBaseDir(repoRoot, input.worktreeBaseDir);
748
+ const containmentInvalid = cleanupContainmentInvalid(repoRoot, baseDir);
728
749
  const gitRecords = listGitWorktrees(repoRoot);
729
750
  const targetHead = runGitChecked(repoRoot, ["rev-parse", "HEAD"]);
730
751
  const rootPath = comparablePath(repoRoot);
@@ -749,6 +770,7 @@ export function buildWorktreeCleanupPlan(input: BuildWorktreeCleanupPlanInput):
749
770
  else entries.push(buildManagedEntry({
750
771
  repoRoot,
751
772
  baseDir,
773
+ containmentInvalid,
752
774
  targetHead,
753
775
  rootPath,
754
776
  git,
@@ -9,6 +9,7 @@ import { getAgentDir } from "../../shared/utils.ts";
9
9
  import type { ManagedWorktreeProvider, WorktreeNaming, WorktreeProvider } from "../../shared/types.ts";
10
10
 
11
11
  export const DEFAULT_WORKTREE_PROVIDER: WorktreeProvider = "auto";
12
+ export const DEFAULT_WORKTREE_BASE_REF = "HEAD";
12
13
  export const DEFAULT_WORKTREE_BRANCH_PREFIX = "pi-subagents/";
13
14
  /** Internal marker used to defer Worktrunk-dependent instruction paths to launch time. */
14
15
  export const WORKTREE_AGENT_CWD_PLACEHOLDER = path.join(path.parse(process.cwd()).root, "__pi_subagents_worktree_cwd__");
@@ -16,6 +17,9 @@ const WORKTREE_NAMING_COMPONENT_MAX_BYTES = 96;
16
17
  const WORKTREE_NAMING_LABEL_MAX_BYTES = 256;
17
18
  const WORKTREE_NAMING_BRANCH_MAX_BYTES = 256;
18
19
  const WORKTREE_COMMAND_OUTPUT_MAX_BYTES = 128 * 1024;
20
+ export const MACHINE_DIFF_OPTIONS = ["--no-color", "--no-ext-diff", "--no-textconv", "--default-prefix", "--line-prefix=", "--no-relative"] as const;
21
+ const MACHINE_PATCH_OPTIONS = [...MACHINE_DIFF_OPTIONS, "--binary"] as const;
22
+ const PATCH_VALIDATION_OPTIONS = ["apply", "--check", "--cached", "--reverse", "--binary", "--whitespace=nowarn"] as const;
19
23
 
20
24
  export interface WorktreeNamingInput {
21
25
  runId: string;
@@ -121,6 +125,8 @@ export interface CreateWorktreesOptions {
121
125
  tasks?: Array<string | undefined>;
122
126
  /** Worktree allocator selection; auto prefers Worktrunk when available. */
123
127
  provider?: WorktreeProvider;
128
+ /** Git ref used as the worktree base; defaults to `HEAD`. */
129
+ baseRef?: string;
124
130
  /** Branch namespace; defaults to `pi-subagents/`. */
125
131
  branchPrefix?: string;
126
132
  setupHook?: WorktreeSetupHookConfig;
@@ -159,13 +165,14 @@ interface GitResult {
159
165
  interface RepoState {
160
166
  toplevel: string;
161
167
  cwdRelative: string;
168
+ sourceCheckout: SourceCheckoutSnapshot;
162
169
  baseCommit: string;
163
170
  }
164
171
 
165
172
  const DEFAULT_WORKTREE_SETUP_HOOK_TIMEOUT_MS = 30000;
166
173
 
167
- function runGit(cwd: string, args: string[]): GitResult {
168
- const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", windowsHide: true, shell: false });
174
+ function runGit(cwd: string, args: string[], env?: NodeJS.ProcessEnv): GitResult {
175
+ const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", windowsHide: true, shell: false, ...(env ? { env: { ...process.env, ...env } } : {}) });
169
176
  return {
170
177
  stdout: result.stdout ?? "",
171
178
  stderr: result.stderr ?? "",
@@ -183,6 +190,53 @@ function runGitChecked(cwd: string, args: string[]): string {
183
190
  return result.stdout;
184
191
  }
185
192
 
193
+ /** Validate a captured patch against the worktree index without changing either. */
194
+ export function validateWorktreePatch(worktreePath: string, patchPath: string): string | undefined {
195
+ const result = runGit(worktreePath, [...PATCH_VALIDATION_OPTIONS, patchPath]);
196
+ if (result.status === 0) return undefined;
197
+ return result.stderr.trim() || result.stdout.trim() || `git -C ${worktreePath} apply --check failed`;
198
+ }
199
+
200
+ function currentWorktreePatch(worktreePath: string, baseCommit: string): { patch: string } | { error: string } {
201
+ let tempDir: string;
202
+ try {
203
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-worktree-index-"));
204
+ } catch (error) {
205
+ return { error: error instanceof Error ? error.message : String(error) };
206
+ }
207
+ const env = { GIT_INDEX_FILE: path.join(tempDir, "index") };
208
+ try {
209
+ const readTree = runGit(worktreePath, ["read-tree", "HEAD"], env);
210
+ if (readTree.status !== 0) return { error: readTree.stderr.trim() || readTree.stdout.trim() || "git read-tree failed" };
211
+ const add = runGit(worktreePath, ["add", "-A"], env);
212
+ if (add.status !== 0) return { error: add.stderr.trim() || add.stdout.trim() || "git add failed" };
213
+ const diff = runGit(worktreePath, ["diff", "--cached", ...MACHINE_PATCH_OPTIONS, baseCommit], env);
214
+ if (diff.status !== 0) return { error: diff.stderr.trim() || diff.stdout.trim() || "git diff failed" };
215
+ return { patch: diff.stdout };
216
+ } finally {
217
+ try {
218
+ fs.rmSync(tempDir, { recursive: true, force: true });
219
+ } catch {
220
+ // Cleanup safety depends on preserving the worktree, not on best-effort temp-index deletion.
221
+ }
222
+ }
223
+ }
224
+
225
+ export function validateWorktreePatchRepresentsCurrentWorktree(worktreePath: string, baseCommit: string, patchPath: string): string | undefined {
226
+ const validationError = validateWorktreePatch(worktreePath, patchPath);
227
+ if (validationError) return validationError;
228
+ let capturedPatch: string;
229
+ try {
230
+ capturedPatch = fs.readFileSync(patchPath, "utf-8");
231
+ } catch (error) {
232
+ return error instanceof Error ? error.message : String(error);
233
+ }
234
+ const current = currentWorktreePatch(worktreePath, baseCommit);
235
+ if ("error" in current) return current.error || "failed to capture current worktree patch";
236
+ if (capturedPatch !== current.patch) return "captured handoff patch does not match current worktree changes";
237
+ return undefined;
238
+ }
239
+
186
240
  function findGitWorktreePath(cwd: string, branch: string): string | undefined {
187
241
  const targetBranch = `branch refs/heads/${branch}`;
188
242
  let currentPath: string | undefined;
@@ -193,7 +247,7 @@ function findGitWorktreePath(cwd: string, branch: string): string | undefined {
193
247
  return undefined;
194
248
  }
195
249
 
196
- function resolveRepoState(cwd: string): RepoState {
250
+ function resolveRepoState(cwd: string, requestedBaseRef: string | undefined): RepoState {
197
251
  const cwdRelative = resolveRepoCwdRelative(cwd);
198
252
  const toplevel = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim();
199
253
 
@@ -204,8 +258,17 @@ function resolveRepoState(cwd: string): RepoState {
204
258
  throw new Error("worktree isolation requires a clean git working tree. Commit or stash changes first.");
205
259
  }
206
260
 
207
- const baseCommit = runGitChecked(toplevel, ["rev-parse", "HEAD"]).trim();
208
- return { toplevel, cwdRelative, baseCommit };
261
+ const sourceHead = runGitChecked(toplevel, ["rev-parse", "HEAD"]).trim();
262
+ const sourceCheckout = snapshotSourceCheckout(toplevel, sourceHead);
263
+ const baseRef = normalizeWorktreeBaseRef(requestedBaseRef) ?? DEFAULT_WORKTREE_BASE_REF;
264
+ let baseCommit: string;
265
+ try {
266
+ baseCommit = runGitChecked(toplevel, ["rev-parse", "--verify", "--end-of-options", `${baseRef}^{commit}`]).trim();
267
+ } catch (error) {
268
+ throw new Error(`baseRef '${baseRef}' could not be resolved to a commit: ${error instanceof Error ? error.message : String(error)}`, { cause: error instanceof Error ? error : undefined });
269
+ }
270
+ if (!baseCommit) throw new Error(`baseRef '${baseRef}' could not be resolved to a commit`);
271
+ return { toplevel, cwdRelative, sourceCheckout, baseCommit };
209
272
  }
210
273
 
211
274
  function normalizeComparableCwd(cwd: string): string {
@@ -214,7 +277,13 @@ function normalizeComparableCwd(cwd: string): string {
214
277
  const missingSegments: string[] = [];
215
278
  while (true) {
216
279
  try {
217
- return path.join(fs.realpathSync(existing), ...missingSegments.reverse());
280
+ let realpath: string;
281
+ try {
282
+ realpath = fs.realpathSync.native(existing);
283
+ } catch {
284
+ realpath = fs.realpathSync(existing);
285
+ }
286
+ return path.join(realpath, ...missingSegments.reverse());
218
287
  } catch {
219
288
  const parent = path.dirname(existing);
220
289
  if (parent === existing) return resolved;
@@ -279,11 +348,19 @@ export function sanitizeWorktreePathComponent(value: string, maxBytes = WORKTREE
279
348
  }
280
349
 
281
350
  function validGitRef(ref: string): boolean {
282
- if (!ref || Buffer.byteLength(ref, "utf-8") > 1024 || ref.startsWith("/") || ref.endsWith("/") || ref.includes("//") || ref.includes("..") || ref.includes("@{")) return false;
283
- if (/[[\]\\~^:?*\u0000-\u0020]/u.test(ref) || ref.endsWith(".") || ref.endsWith(".lock")) return false;
351
+ if (!ref || ref === "@" || Buffer.byteLength(ref, "utf-8") > 1024 || ref.startsWith("/") || ref.endsWith("/") || ref.includes("//") || ref.includes("..") || ref.includes("@{")) return false;
352
+ if (/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu.test(ref)) return false;
353
+ if (/[[\]\\~^:?*\u0000-\u0020\u007f]/u.test(ref) || ref.endsWith(".") || ref.endsWith(".lock")) return false;
284
354
  return ref.split("/").every((component) => component.length > 0 && component !== "." && component !== ".." && !component.startsWith(".") && !component.endsWith(".") && !component.endsWith(".lock"));
285
355
  }
286
356
 
357
+ /** Normalize and validate a configured worktree base ref without resolving it. */
358
+ export function normalizeWorktreeBaseRef(value: unknown): string | undefined {
359
+ if (value === undefined) return undefined;
360
+ if (typeof value !== "string" || !validGitRef(value)) throw new Error("baseRef must be a valid Git ref");
361
+ return value;
362
+ }
363
+
287
364
  /** Normalize and validate the configured Git branch namespace. */
288
365
  export function normalizeWorktreeBranchPrefix(value: string | undefined): string {
289
366
  const raw = value === undefined ? DEFAULT_WORKTREE_BRANCH_PREFIX : value.trim();
@@ -428,31 +505,85 @@ export function shouldDeferWorktreeCwd(requested: WorktreeProvider | undefined,
428
505
  return (requested ?? DEFAULT_WORKTREE_PROVIDER) !== "native" && !hasConfiguredWorktreeBaseDir(baseDir);
429
506
  }
430
507
 
431
- function resolveWorktreeBaseDir(configuredBaseDir: string | undefined, repoRoot: string): string {
508
+ /**
509
+ * Resolves the dedicated worktree root: the configured base directory or
510
+ * PI_SUBAGENTS_WORKTREE_DIR when set, otherwise a `worktrees` folder sibling
511
+ * to the repository. Managed leaves always nest one level deeper under the
512
+ * project folder (`basename(repoRoot)`).
513
+ */
514
+ function resolveWorktreeDedicatedRoot(configuredBaseDir: string | undefined, repoRoot: string): string {
432
515
  const rawBaseDir = configuredBaseDir ?? process.env.PI_SUBAGENTS_WORKTREE_DIR;
433
- if (rawBaseDir === undefined || (configuredBaseDir === undefined && !rawBaseDir.trim())) return os.tmpdir();
434
-
435
- const trimmed = rawBaseDir.trim();
436
- if (!trimmed) throw new Error("worktree base directory cannot be empty");
516
+ let expanded: string;
517
+ if (rawBaseDir === undefined || (configuredBaseDir === undefined && !rawBaseDir.trim())) {
518
+ expanded = path.join(path.dirname(repoRoot), "worktrees");
519
+ } else {
520
+ const trimmed = rawBaseDir.trim();
521
+ if (!trimmed) throw new Error("worktree base directory cannot be empty");
437
522
 
438
- const expanded = trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(2)) : trimmed;
439
- const resolved = path.isAbsolute(expanded) ? expanded : path.resolve(repoRoot, expanded);
523
+ const candidate = trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(2)) : trimmed;
524
+ expanded = path.isAbsolute(candidate) ? candidate : path.resolve(repoRoot, candidate);
525
+ }
440
526
  const extensionsDir = normalizeComparableCwd(path.join(getAgentDir(), "extensions"));
441
- const relativeToExtensions = path.relative(extensionsDir, normalizeComparableCwd(resolved));
442
- if (!relativeToExtensions || (!relativeToExtensions.startsWith(`..${path.sep}`) && relativeToExtensions !== ".." && !path.isAbsolute(relativeToExtensions))) {
527
+ if (isPathInside(extensionsDir, normalizeComparableCwd(expanded))) {
443
528
  throw new Error(`worktree base directory cannot be inside Pi extensions directory: ${extensionsDir}. Choose a directory outside it.`);
444
529
  }
530
+ return expanded;
531
+ }
532
+
533
+ function buildNativeProjectPath(dedicatedRoot: string, repoRoot: string): string {
534
+ return path.join(dedicatedRoot, path.basename(repoRoot));
535
+ }
536
+
537
+ /**
538
+ * Creates the project folder (parents included) so `git worktree add` can create
539
+ * the leaf inside it. Must only run after `assertSafeWorktreeLocation` accepted
540
+ * the planned leaf — an unsafe base must never be materialized on disk.
541
+ */
542
+ function ensureProjectWorktreeDir(dedicatedRoot: string, repoRoot: string): void {
445
543
  try {
446
- fs.mkdirSync(resolved, { recursive: true });
544
+ fs.mkdirSync(buildNativeProjectPath(dedicatedRoot, repoRoot), { recursive: true });
447
545
  } catch (error) {
448
546
  const message = error instanceof Error ? error.message : String(error);
449
- throw new Error(`failed to create worktree base directory ${resolved}: ${message}`);
547
+ throw new Error(`failed to create worktree base directory ${dedicatedRoot}: ${message}`);
450
548
  }
451
- return resolved;
452
549
  }
453
550
 
454
- function buildNativeWorktreePath(baseDir: string, runId: string, index: number): string {
455
- return path.join(baseDir, `pi-worktree-${sanitizeWorktreePathComponent(runId, 120)}-${index}`);
551
+ function buildNativeWorktreePath(dedicatedRoot: string, repoRoot: string, runId: string, index: number): string {
552
+ return path.join(buildNativeProjectPath(dedicatedRoot, repoRoot), `pi-worktree-${sanitizeWorktreePathComponent(runId, 120)}-${index}`);
553
+ }
554
+
555
+ function isPathInside(parent: string, child: string): boolean {
556
+ const relative = path.relative(parent, child);
557
+ if (!relative || relative === ".") return true;
558
+ return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
559
+ }
560
+
561
+ function isStrictChildPath(parent: string, child: string): boolean {
562
+ const relative = path.relative(parent, child);
563
+ return Boolean(relative) && relative !== "." && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
564
+ }
565
+
566
+ /** Fail closed before `git worktree add`: reject unsafe planned/realpath locations. */
567
+ function assertSafeWorktreeLocation(worktreePath: string, repoRoot: string, dedicatedRoot: string): void {
568
+ const resolvedLeaf = normalizeComparableCwd(worktreePath);
569
+ const resolvedRepoRoot = normalizeComparableCwd(repoRoot);
570
+ const projectDir = normalizeComparableCwd(buildNativeProjectPath(dedicatedRoot, repoRoot));
571
+ const repoParent = normalizeComparableCwd(path.dirname(resolvedRepoRoot));
572
+ const leafParent = normalizeComparableCwd(path.dirname(resolvedLeaf));
573
+ const extensionsDir = normalizeComparableCwd(path.join(getAgentDir(), "extensions"));
574
+
575
+ if (isPathInside(extensionsDir, projectDir) || isPathInside(extensionsDir, resolvedLeaf)) {
576
+ throw new Error(`worktree path cannot be inside Pi extensions directory: ${extensionsDir}. Choose a directory outside it.`);
577
+ }
578
+ if (isPathInside(resolvedRepoRoot, resolvedLeaf)) {
579
+ throw new Error(`worktree path would land inside the repository checkout: ${resolvedLeaf}`);
580
+ }
581
+ if (leafParent === repoParent) {
582
+ throw new Error(`worktree path would be a direct child of the repository parent: ${resolvedLeaf}`);
583
+ }
584
+ if (!isStrictChildPath(projectDir, resolvedLeaf)) {
585
+ throw new Error(`worktree path must be a strict child of the project worktree directory ${projectDir}: ${resolvedLeaf}`);
586
+ }
456
587
  }
457
588
 
458
589
  function resolveRepoCwdRelative(cwd: string): string {
@@ -470,7 +601,9 @@ function resolveRepoCwdRelative(cwd: string): string {
470
601
  export function resolveExpectedWorktreeAgentCwd(cwd: string, runId: string, index: number, baseDir?: string): string {
471
602
  const cwdRelative = resolveRepoCwdRelative(cwd);
472
603
  const repoRoot = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim();
473
- const worktreePath = buildNativeWorktreePath(resolveWorktreeBaseDir(baseDir, repoRoot), runId, index);
604
+ const dedicatedRoot = resolveWorktreeDedicatedRoot(baseDir, repoRoot);
605
+ const worktreePath = buildNativeWorktreePath(dedicatedRoot, repoRoot, runId, index);
606
+ assertSafeWorktreeLocation(worktreePath, repoRoot, dedicatedRoot);
474
607
  return cwdRelative ? path.join(worktreePath, cwdRelative) : worktreePath;
475
608
  }
476
609
 
@@ -660,13 +793,15 @@ function createNativeWorktree(
660
793
  baseCommit: string,
661
794
  setupHook: ResolvedWorktreeSetupHook | undefined,
662
795
  agent: string | undefined,
663
- baseDir: string,
796
+ dedicatedRoot: string,
664
797
  labels: Array<string | undefined> | undefined,
665
798
  tasks: Array<string | undefined> | undefined,
666
799
  branchPrefix: string | undefined,
667
800
  ): WorktreeInfo {
668
801
  const naming = buildWorktreeNaming({ runId, index, agent, label: labels?.[index], task: tasks?.[index], branchPrefix });
669
- const worktreePath = buildNativeWorktreePath(baseDir, runId, index);
802
+ const worktreePath = buildNativeWorktreePath(dedicatedRoot, toplevel, runId, index);
803
+ assertSafeWorktreeLocation(worktreePath, toplevel, dedicatedRoot);
804
+ ensureProjectWorktreeDir(dedicatedRoot, toplevel);
670
805
  const add = runGit(toplevel, ["worktree", "add", worktreePath, "-b", naming.requestedBranch, baseCommit]);
671
806
  if (add.status !== 0) {
672
807
  const message = add.stderr.trim() || add.stdout.trim() || `failed to create worktree ${worktreePath}`;
@@ -712,13 +847,13 @@ function createWorktrunkWorktree(
712
847
  runId: string,
713
848
  index: number,
714
849
  baseCommit: string,
850
+ sourceCheckout: SourceCheckoutSnapshot,
715
851
  agents: string[] | undefined,
716
852
  labels: Array<string | undefined> | undefined,
717
853
  tasks: Array<string | undefined> | undefined,
718
854
  branchPrefix: string | undefined,
719
855
  ): WorktreeInfo {
720
856
  const naming = buildWorktreeNaming({ runId, index, agent: agents?.[index], label: labels?.[index], task: tasks?.[index], branchPrefix });
721
- const sourceCheckout = snapshotSourceCheckout(toplevel, baseCommit);
722
857
  const args = ["-C", toplevel, "switch", "--create", naming.requestedBranch, "--base", baseCommit, "--no-cd", "--no-hooks", "--format", "json"];
723
858
  const result = runWorktrunk(args, toplevel);
724
859
  if (result.status !== 0) {
@@ -872,15 +1007,18 @@ function captureWorktreeDiff(
872
1007
  ): WorktreeDiff {
873
1008
  removeSyntheticPathsBeforeDiff(worktree);
874
1009
  runGitChecked(worktree.path, ["add", "-A"]);
875
- const diffStat = runGitChecked(worktree.path, ["diff", "--cached", "--stat", setup.baseCommit]).trim();
876
- const patch = runGitChecked(worktree.path, ["diff", "--cached", setup.baseCommit]);
877
- const numstat = runGitChecked(worktree.path, ["diff", "--cached", "--numstat", setup.baseCommit]);
1010
+ const diffStat = runGitChecked(worktree.path, ["diff", "--cached", ...MACHINE_DIFF_OPTIONS, "--stat", setup.baseCommit]).trim();
1011
+ const patch = runGitChecked(worktree.path, ["diff", "--cached", ...MACHINE_PATCH_OPTIONS, setup.baseCommit]);
1012
+ const numstat = runGitChecked(worktree.path, ["diff", "--cached", ...MACHINE_DIFF_OPTIONS, "--numstat", setup.baseCommit]);
878
1013
  fs.writeFileSync(patchPath, patch, "utf-8");
879
1014
 
880
1015
  if (!patch.trim()) {
881
1016
  return emptyDiff(worktree.index, agent, worktree.branch, patchPath);
882
1017
  }
883
1018
 
1019
+ const validationError = validateWorktreePatch(worktree.path, patchPath);
1020
+ if (validationError) throw new Error(`captured worktree patch is not machine-applyable: ${validationError}`);
1021
+
884
1022
  const parsed = parseNumstat(numstat);
885
1023
  return {
886
1024
  index: worktree.index,
@@ -949,7 +1087,7 @@ function cleanupSingleWorktree(
949
1087
  errors.push(`synthetic path cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
950
1088
  }
951
1089
  const status = runGit(worktree.path, ["status", "--porcelain"]);
952
- const baseDiff = runGit(worktree.path, ["diff", "--quiet", setup.baseCommit, "--"]);
1090
+ const baseDiff = runGit(worktree.path, ["diff", "--quiet", ...MACHINE_DIFF_OPTIONS, setup.baseCommit, "--"]);
953
1091
  if (status.status !== 0 || (baseDiff.status !== 0 && baseDiff.status !== 1)) {
954
1092
  const reason = status.status !== 0
955
1093
  ? status.stderr.trim() || status.stdout.trim() || "git status failed"
@@ -970,13 +1108,25 @@ function cleanupSingleWorktree(
970
1108
  const hasWork = status.stdout.trim().length > 0 || baseDiff.status === 1;
971
1109
  if (hasWork && intent.kind === "preserve") {
972
1110
  const captured = (intent.capturedDiffs ?? setup.capturedDiffs)?.find((diff) => diff.index === worktree.index);
973
- const patchCaptured = captured !== undefined
1111
+ let patchValidationError: string | undefined;
1112
+ let patchCaptured = false;
1113
+ if (captured !== undefined
974
1114
  && captured.error === undefined
975
1115
  && fs.existsSync(captured.patchPath)
976
- && fs.statSync(captured.patchPath).size > 0
977
- && handoffRecordsPatch(intent.handoffManifestPath, captured.patchPath);
1116
+ && handoffRecordsPatch(intent.handoffManifestPath, captured.patchPath)) {
1117
+ try {
1118
+ if (fs.statSync(captured.patchPath).size > 0) {
1119
+ patchValidationError = validateWorktreePatchRepresentsCurrentWorktree(worktree.path, setup.baseCommit, captured.patchPath);
1120
+ patchCaptured = patchValidationError === undefined;
1121
+ }
1122
+ } catch (error) {
1123
+ patchValidationError = error instanceof Error ? error.message : String(error);
1124
+ }
1125
+ }
978
1126
  if (!patchCaptured) {
979
- const reason = "worktree contains changes that are not represented by a captured handoff patch";
1127
+ const reason = patchValidationError
1128
+ ? `captured handoff patch failed validation: ${patchValidationError}`
1129
+ : "worktree contains changes that are not represented by a captured handoff patch";
980
1130
  return {
981
1131
  index: worktree.index,
982
1132
  path: worktree.path,
@@ -1045,11 +1195,11 @@ function hasWorktreeChanges(diff: WorktreeDiff): boolean {
1045
1195
 
1046
1196
  export function createWorktrees(cwd: string, runId: string, count: number, options?: CreateWorktreesOptions): WorktreeSetup {
1047
1197
  if (!Number.isSafeInteger(count) || count < 0) throw new Error("worktree count must be a non-negative integer");
1048
- const repo = resolveRepoState(cwd);
1198
+ const repo = resolveRepoState(cwd, options?.baseRef);
1049
1199
  const setupHook = resolveWorktreeSetupHook(repo.toplevel, options?.setupHook);
1050
- let provider = resolveWorktreeProvider(options?.provider, options?.baseDir);
1200
+ const provider = resolveWorktreeProvider(options?.provider, options?.baseDir);
1051
1201
  const branchPrefix = normalizeWorktreeBranchPrefix(options?.branchPrefix);
1052
- let baseDir = provider === "native" ? resolveWorktreeBaseDir(options?.baseDir, repo.toplevel) : undefined;
1202
+ const dedicatedRoot = provider === "native" ? resolveWorktreeDedicatedRoot(options?.baseDir, repo.toplevel) : undefined;
1053
1203
  const worktrees: WorktreeInfo[] = [];
1054
1204
 
1055
1205
  try {
@@ -1059,7 +1209,8 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
1059
1209
  baseCommit: repo.baseCommit,
1060
1210
  worktrees: Array.from({ length: count }, (_, index) => {
1061
1211
  const naming = buildWorktreeNaming({ runId, index, agent: options?.agents?.[index], label: options?.labels?.[index], task: options?.tasks?.[index], branchPrefix });
1062
- const worktreePath = buildNativeWorktreePath(baseDir!, runId, index);
1212
+ const worktreePath = buildNativeWorktreePath(dedicatedRoot!, repo.toplevel, runId, index);
1213
+ assertSafeWorktreeLocation(worktreePath, repo.toplevel, dedicatedRoot!);
1063
1214
  return {
1064
1215
  path: worktreePath,
1065
1216
  agentCwd: repo.cwdRelative ? path.join(worktreePath, repo.cwdRelative) : worktreePath,
@@ -1075,14 +1226,14 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
1075
1226
  options?.beforeCreate?.(plannedSetup);
1076
1227
  for (let index = 0; index < count; index++) {
1077
1228
  worktrees.push(createNativeWorktree(
1078
- repo.toplevel,
1229
+ repo.toplevel,
1079
1230
  repo.cwdRelative,
1080
1231
  runId,
1081
1232
  index,
1082
1233
  repo.baseCommit,
1083
1234
  setupHook,
1084
1235
  options?.agents?.[index],
1085
- baseDir!,
1236
+ dedicatedRoot!,
1086
1237
  options?.labels,
1087
1238
  options?.tasks,
1088
1239
  branchPrefix,
@@ -1096,6 +1247,7 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
1096
1247
  runId,
1097
1248
  index,
1098
1249
  repo.baseCommit,
1250
+ repo.sourceCheckout,
1099
1251
  options?.agents,
1100
1252
  options?.labels,
1101
1253
  options?.tasks,
@@ -6,7 +6,7 @@ import { PROMPT_REDACTED } from "./utils.ts";
6
6
  * the agent name and its task (or workflow node label). The parent computes it
7
7
  * once and threads it two ways:
8
8
  *
9
- * 1. Into the child process via `PI_SUBAGENT_SESSION_NAME`, where the prompt
9
+ * 1. Into the child's runtime config as `sessionName`, where the prompt
10
10
  * runtime calls `pi.setSessionName(...)` so the child's own session file
11
11
  * is identifiable in `pi --resume` and host session browsers.
12
12
  * 2. Into the `sessionName` field of result/progress payloads, so hosts