pi-subagents 0.63.0 → 0.65.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 (107) hide show
  1. package/CHANGELOG.md +57 -1
  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 +18 -16
  6. package/docs/configuration.md +7 -15
  7. package/docs/extension-api.md +15 -6
  8. package/docs/missions.md +2 -0
  9. package/docs/observability.md +10 -12
  10. package/docs/tool-reference.md +7 -6
  11. package/docs/watchdog.md +92 -114
  12. package/docs/workflows.md +5 -3
  13. package/package.json +3 -4
  14. package/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
  15. package/skills/pi-subagents/references/execution-controls.md +6 -5
  16. package/src/agents/agent-management.ts +47 -15
  17. package/src/api/capability-ceiling.ts +0 -1
  18. package/src/api/{pi-args.ts → child-tool-plan.ts} +1 -1
  19. package/src/api/preflight.ts +2 -3
  20. package/src/extension/doctor.ts +2 -10
  21. package/src/extension/fanout-child.ts +9 -11
  22. package/src/extension/index.ts +27 -5
  23. package/src/extension/public-execution.ts +14 -0
  24. package/src/extension/rpc.ts +3 -2
  25. package/src/extension/schemas.ts +2 -1
  26. package/src/extension/tool-description.ts +9 -8
  27. package/src/intercom/native-supervisor-channel.ts +138 -60
  28. package/src/intercom/supervisor-ui.ts +243 -0
  29. package/src/runs/background/async-execution.ts +46 -24
  30. package/src/runs/background/async-job-tracker.ts +11 -0
  31. package/src/runs/background/async-resume.ts +11 -2
  32. package/src/runs/background/control-channel.ts +2 -204
  33. package/src/runs/background/notify.ts +41 -2
  34. package/src/runs/background/process-terminal.ts +1 -1
  35. package/src/runs/background/run-child-session.ts +613 -0
  36. package/src/runs/background/run-status.ts +0 -1
  37. package/src/runs/background/runner-aliases.ts +125 -0
  38. package/src/runs/background/runner-child-sessions.ts +31 -0
  39. package/src/runs/background/scheduled-runs.ts +18 -4
  40. package/src/runs/background/subagent-runner.ts +203 -898
  41. package/src/runs/foreground/async-steering-action.ts +1 -17
  42. package/src/runs/foreground/execution.ts +196 -378
  43. package/src/runs/foreground/foreground-control.ts +4 -0
  44. package/src/runs/foreground/subagent-executor.ts +121 -58
  45. package/src/runs/foreground/workflow-foreground-steering.ts +24 -98
  46. package/src/runs/shared/abort-recovery.ts +3 -3
  47. package/src/runs/shared/acceptance.ts +10 -0
  48. package/src/runs/shared/async-status-projection.ts +11 -43
  49. package/src/runs/shared/capability-ceiling.ts +1 -2
  50. package/src/runs/shared/child-hooks.ts +25 -0
  51. package/src/runs/shared/child-identity.ts +13 -2
  52. package/src/runs/shared/child-launch.ts +314 -0
  53. package/src/runs/shared/child-lifecycle.ts +25 -0
  54. package/src/runs/shared/child-runtime-config.ts +126 -0
  55. package/src/runs/shared/child-session.ts +342 -0
  56. package/src/runs/shared/child-tool-plan.ts +530 -0
  57. package/src/runs/shared/claude-code-adapter.ts +5 -1
  58. package/src/runs/shared/completion-guard.ts +1 -1
  59. package/src/runs/shared/external-cli-preflight.ts +16 -0
  60. package/src/runs/shared/mcp-direct-tool-allowlist.ts +5 -4
  61. package/src/runs/shared/model-exclusions.ts +82 -14
  62. package/src/runs/shared/model-fallback.ts +47 -4
  63. package/src/runs/shared/nested-events.ts +29 -45
  64. package/src/runs/shared/nested-path.ts +0 -14
  65. package/src/runs/shared/orca-progress-tabs.ts +12 -7
  66. package/src/runs/shared/parallel-utils.ts +0 -2
  67. package/src/runs/shared/permissions.ts +0 -13
  68. package/src/runs/shared/process-signal.ts +4 -1
  69. package/src/runs/shared/run-fanout-budget.ts +0 -13
  70. package/src/runs/shared/runtime-acknowledged-extensions.ts +0 -27
  71. package/src/runs/shared/structured-output.ts +17 -4
  72. package/src/runs/shared/subagent-control.ts +6 -2
  73. package/src/runs/shared/subagent-prompt-runtime.ts +87 -384
  74. package/src/runs/shared/tool-availability.ts +18 -62
  75. package/src/runs/shared/tool-budget.ts +0 -14
  76. package/src/runs/shared/worktree-cleanup-plan.ts +25 -6
  77. package/src/runs/shared/worktree.ts +117 -30
  78. package/src/shared/child-session-name.ts +1 -1
  79. package/src/shared/jsonl-writer.ts +11 -0
  80. package/src/shared/thinking-ceiling.ts +0 -6
  81. package/src/shared/types.ts +64 -30
  82. package/src/shared/utils.ts +3 -4
  83. package/src/slash/slash-commands.ts +0 -6
  84. package/src/tui/fleet.ts +0 -1
  85. package/src/tui/render.ts +238 -31
  86. package/src/watchdog/child-status.ts +54 -34
  87. package/src/watchdog/diff-tool.ts +77 -0
  88. package/src/watchdog/emission-guard.ts +5 -3
  89. package/src/watchdog/guidance.ts +20 -0
  90. package/src/watchdog/register-child.ts +28 -25
  91. package/src/watchdog/register-main.ts +10 -9
  92. package/src/watchdog/render.ts +4 -5
  93. package/src/watchdog/review.ts +15 -4
  94. package/src/watchdog/rules.ts +70 -0
  95. package/src/watchdog/runtime.ts +75 -92
  96. package/src/watchdog/scope.ts +0 -11
  97. package/src/watchdog/settings.ts +48 -104
  98. package/src/watchdog/types.ts +18 -32
  99. package/src/watchdog/warning-format.ts +0 -1
  100. package/src/workflows/chat-progress.ts +3 -2
  101. package/src/workflows/scripted-workflow.ts +48 -1
  102. package/src/workflows/workflow-checklist.ts +10 -12
  103. package/src/workflows/workflow-preflight.ts +28 -1
  104. package/src/runs/shared/child-protocol.ts +0 -415
  105. package/src/runs/shared/pi-args.ts +0 -1062
  106. package/src/runs/shared/subagent-startup-retry.ts +0 -116
  107. 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,6 +13,7 @@ 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";
17
18
 
18
19
  export const WORKTREE_CLEANUP_PLAN_VERSION = 1 as const;
@@ -244,11 +245,24 @@ function resolveRepoRoot(repo: string): string {
244
245
  }
245
246
 
246
247
  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));
248
+ const raw = configuredBaseDir ?? process.env.PI_SUBAGENTS_WORKTREE_DIR;
249
+ let dedicatedRoot: string;
250
+ if (raw === undefined || (configuredBaseDir === undefined && !raw.trim())) {
251
+ dedicatedRoot = path.join(path.dirname(repoRoot), "worktrees");
252
+ } else {
253
+ const trimmed = raw.trim();
254
+ if (!trimmed) throw new Error("worktree base directory cannot be empty");
255
+ const expanded = trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(2)) : trimmed;
256
+ dedicatedRoot = path.isAbsolute(expanded) ? expanded : path.resolve(repoRoot, expanded);
257
+ }
258
+ return path.resolve(path.join(dedicatedRoot, path.basename(repoRoot)));
259
+ }
260
+
261
+ function cleanupContainmentInvalid(repoRoot: string, projectDir: string): boolean {
262
+ const extensionsDir = path.join(getAgentDir(), "extensions");
263
+ return samePath(projectDir, repoRoot)
264
+ || pathInside(repoRoot, projectDir, true)
265
+ || pathInside(extensionsDir, projectDir);
252
266
  }
253
267
 
254
268
  export function parseGitWorktreeList(raw: string): GitWorktreeRecord[] {
@@ -579,6 +593,7 @@ function isPatchCaptured(record: ManifestMetadataRecord, worktreePath: string):
579
593
  function buildManagedEntry(input: {
580
594
  repoRoot: string;
581
595
  baseDir: string;
596
+ containmentInvalid: boolean;
582
597
  targetHead: string;
583
598
  rootPath: string;
584
599
  git: GitWorktreeRecord;
@@ -605,7 +620,9 @@ function buildManagedEntry(input: {
605
620
  if (pathInspection.missing) return blockedEntry(entry, "stale", "unknown", "worktree path is missing from disk");
606
621
  if (pathInspection.symlink) return blockedEntry(entry, "unknown", "unknown", "worktree path is a symlink; cleanup requires a real directory");
607
622
  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}'`);
623
+ const extensionsDir = path.join(getAgentDir(), "extensions");
624
+ if (pathInside(extensionsDir, pathInspection.realpath)) return blockedEntry(entry, "ineligible", "keep", `worktree real path is inside Pi extensions directory '${extensionsDir}'`);
625
+ if (input.containmentInvalid || !pathInside(baseDir, pathInspection.realpath, true)) return blockedEntry(entry, "ineligible", "keep", `worktree real path is outside configured base directory '${baseDir}'`);
609
626
 
610
627
  if (rootPath === comparablePath(pathInspection.realpath)) return blockedEntry(entry, "ineligible", "keep", "worktree is the repository root");
611
628
  if (!git.branch) return blockedEntry(entry, "unknown", "unknown", "detached worktrees have no metadata-recorded branch");
@@ -725,6 +742,7 @@ export function buildWorktreeCleanupPlan(input: BuildWorktreeCleanupPlanInput):
725
742
  const now = input.now ?? Date.now();
726
743
  if (!Number.isFinite(now)) throw new Error("worktree cleanup plan timestamp must be finite");
727
744
  const baseDir = resolveCleanupBaseDir(repoRoot, input.worktreeBaseDir);
745
+ const containmentInvalid = cleanupContainmentInvalid(repoRoot, baseDir);
728
746
  const gitRecords = listGitWorktrees(repoRoot);
729
747
  const targetHead = runGitChecked(repoRoot, ["rev-parse", "HEAD"]);
730
748
  const rootPath = comparablePath(repoRoot);
@@ -749,6 +767,7 @@ export function buildWorktreeCleanupPlan(input: BuildWorktreeCleanupPlanInput):
749
767
  else entries.push(buildManagedEntry({
750
768
  repoRoot,
751
769
  baseDir,
770
+ containmentInvalid,
752
771
  targetHead,
753
772
  rootPath,
754
773
  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__");
@@ -121,6 +122,8 @@ export interface CreateWorktreesOptions {
121
122
  tasks?: Array<string | undefined>;
122
123
  /** Worktree allocator selection; auto prefers Worktrunk when available. */
123
124
  provider?: WorktreeProvider;
125
+ /** Git ref used as the worktree base; defaults to `HEAD`. */
126
+ baseRef?: string;
124
127
  /** Branch namespace; defaults to `pi-subagents/`. */
125
128
  branchPrefix?: string;
126
129
  setupHook?: WorktreeSetupHookConfig;
@@ -159,6 +162,7 @@ interface GitResult {
159
162
  interface RepoState {
160
163
  toplevel: string;
161
164
  cwdRelative: string;
165
+ sourceCheckout: SourceCheckoutSnapshot;
162
166
  baseCommit: string;
163
167
  }
164
168
 
@@ -193,7 +197,7 @@ function findGitWorktreePath(cwd: string, branch: string): string | undefined {
193
197
  return undefined;
194
198
  }
195
199
 
196
- function resolveRepoState(cwd: string): RepoState {
200
+ function resolveRepoState(cwd: string, requestedBaseRef: string | undefined): RepoState {
197
201
  const cwdRelative = resolveRepoCwdRelative(cwd);
198
202
  const toplevel = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim();
199
203
 
@@ -204,8 +208,17 @@ function resolveRepoState(cwd: string): RepoState {
204
208
  throw new Error("worktree isolation requires a clean git working tree. Commit or stash changes first.");
205
209
  }
206
210
 
207
- const baseCommit = runGitChecked(toplevel, ["rev-parse", "HEAD"]).trim();
208
- return { toplevel, cwdRelative, baseCommit };
211
+ const sourceHead = runGitChecked(toplevel, ["rev-parse", "HEAD"]).trim();
212
+ const sourceCheckout = snapshotSourceCheckout(toplevel, sourceHead);
213
+ const baseRef = normalizeWorktreeBaseRef(requestedBaseRef) ?? DEFAULT_WORKTREE_BASE_REF;
214
+ let baseCommit: string;
215
+ try {
216
+ baseCommit = runGitChecked(toplevel, ["rev-parse", "--verify", "--end-of-options", `${baseRef}^{commit}`]).trim();
217
+ } catch (error) {
218
+ 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 });
219
+ }
220
+ if (!baseCommit) throw new Error(`baseRef '${baseRef}' could not be resolved to a commit`);
221
+ return { toplevel, cwdRelative, sourceCheckout, baseCommit };
209
222
  }
210
223
 
211
224
  function normalizeComparableCwd(cwd: string): string {
@@ -214,7 +227,13 @@ function normalizeComparableCwd(cwd: string): string {
214
227
  const missingSegments: string[] = [];
215
228
  while (true) {
216
229
  try {
217
- return path.join(fs.realpathSync(existing), ...missingSegments.reverse());
230
+ let realpath: string;
231
+ try {
232
+ realpath = fs.realpathSync.native(existing);
233
+ } catch {
234
+ realpath = fs.realpathSync(existing);
235
+ }
236
+ return path.join(realpath, ...missingSegments.reverse());
218
237
  } catch {
219
238
  const parent = path.dirname(existing);
220
239
  if (parent === existing) return resolved;
@@ -279,11 +298,19 @@ export function sanitizeWorktreePathComponent(value: string, maxBytes = WORKTREE
279
298
  }
280
299
 
281
300
  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;
301
+ if (!ref || ref === "@" || Buffer.byteLength(ref, "utf-8") > 1024 || ref.startsWith("/") || ref.endsWith("/") || ref.includes("//") || ref.includes("..") || ref.includes("@{")) return false;
302
+ if (/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu.test(ref)) return false;
303
+ if (/[[\]\\~^:?*\u0000-\u0020\u007f]/u.test(ref) || ref.endsWith(".") || ref.endsWith(".lock")) return false;
284
304
  return ref.split("/").every((component) => component.length > 0 && component !== "." && component !== ".." && !component.startsWith(".") && !component.endsWith(".") && !component.endsWith(".lock"));
285
305
  }
286
306
 
307
+ /** Normalize and validate a configured worktree base ref without resolving it. */
308
+ export function normalizeWorktreeBaseRef(value: unknown): string | undefined {
309
+ if (value === undefined) return undefined;
310
+ if (typeof value !== "string" || !validGitRef(value)) throw new Error("baseRef must be a valid Git ref");
311
+ return value;
312
+ }
313
+
287
314
  /** Normalize and validate the configured Git branch namespace. */
288
315
  export function normalizeWorktreeBranchPrefix(value: string | undefined): string {
289
316
  const raw = value === undefined ? DEFAULT_WORKTREE_BRANCH_PREFIX : value.trim();
@@ -428,31 +455,85 @@ export function shouldDeferWorktreeCwd(requested: WorktreeProvider | undefined,
428
455
  return (requested ?? DEFAULT_WORKTREE_PROVIDER) !== "native" && !hasConfiguredWorktreeBaseDir(baseDir);
429
456
  }
430
457
 
431
- function resolveWorktreeBaseDir(configuredBaseDir: string | undefined, repoRoot: string): string {
458
+ /**
459
+ * Resolves the dedicated worktree root: the configured base directory or
460
+ * PI_SUBAGENTS_WORKTREE_DIR when set, otherwise a `worktrees` folder sibling
461
+ * to the repository. Managed leaves always nest one level deeper under the
462
+ * project folder (`basename(repoRoot)`).
463
+ */
464
+ function resolveWorktreeDedicatedRoot(configuredBaseDir: string | undefined, repoRoot: string): string {
432
465
  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");
466
+ let expanded: string;
467
+ if (rawBaseDir === undefined || (configuredBaseDir === undefined && !rawBaseDir.trim())) {
468
+ expanded = path.join(path.dirname(repoRoot), "worktrees");
469
+ } else {
470
+ const trimmed = rawBaseDir.trim();
471
+ if (!trimmed) throw new Error("worktree base directory cannot be empty");
437
472
 
438
- const expanded = trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(2)) : trimmed;
439
- const resolved = path.isAbsolute(expanded) ? expanded : path.resolve(repoRoot, expanded);
473
+ const candidate = trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(2)) : trimmed;
474
+ expanded = path.isAbsolute(candidate) ? candidate : path.resolve(repoRoot, candidate);
475
+ }
440
476
  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))) {
477
+ if (isPathInside(extensionsDir, normalizeComparableCwd(expanded))) {
443
478
  throw new Error(`worktree base directory cannot be inside Pi extensions directory: ${extensionsDir}. Choose a directory outside it.`);
444
479
  }
480
+ return expanded;
481
+ }
482
+
483
+ function buildNativeProjectPath(dedicatedRoot: string, repoRoot: string): string {
484
+ return path.join(dedicatedRoot, path.basename(repoRoot));
485
+ }
486
+
487
+ /**
488
+ * Creates the project folder (parents included) so `git worktree add` can create
489
+ * the leaf inside it. Must only run after `assertSafeWorktreeLocation` accepted
490
+ * the planned leaf — an unsafe base must never be materialized on disk.
491
+ */
492
+ function ensureProjectWorktreeDir(dedicatedRoot: string, repoRoot: string): void {
445
493
  try {
446
- fs.mkdirSync(resolved, { recursive: true });
494
+ fs.mkdirSync(buildNativeProjectPath(dedicatedRoot, repoRoot), { recursive: true });
447
495
  } catch (error) {
448
496
  const message = error instanceof Error ? error.message : String(error);
449
- throw new Error(`failed to create worktree base directory ${resolved}: ${message}`);
497
+ throw new Error(`failed to create worktree base directory ${dedicatedRoot}: ${message}`);
450
498
  }
451
- return resolved;
452
499
  }
453
500
 
454
- function buildNativeWorktreePath(baseDir: string, runId: string, index: number): string {
455
- return path.join(baseDir, `pi-worktree-${sanitizeWorktreePathComponent(runId, 120)}-${index}`);
501
+ function buildNativeWorktreePath(dedicatedRoot: string, repoRoot: string, runId: string, index: number): string {
502
+ return path.join(buildNativeProjectPath(dedicatedRoot, repoRoot), `pi-worktree-${sanitizeWorktreePathComponent(runId, 120)}-${index}`);
503
+ }
504
+
505
+ function isPathInside(parent: string, child: string): boolean {
506
+ const relative = path.relative(parent, child);
507
+ if (!relative || relative === ".") return true;
508
+ return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
509
+ }
510
+
511
+ function isStrictChildPath(parent: string, child: string): boolean {
512
+ const relative = path.relative(parent, child);
513
+ return Boolean(relative) && relative !== "." && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
514
+ }
515
+
516
+ /** Fail closed before `git worktree add`: reject unsafe planned/realpath locations. */
517
+ function assertSafeWorktreeLocation(worktreePath: string, repoRoot: string, dedicatedRoot: string): void {
518
+ const resolvedLeaf = normalizeComparableCwd(worktreePath);
519
+ const resolvedRepoRoot = normalizeComparableCwd(repoRoot);
520
+ const projectDir = normalizeComparableCwd(buildNativeProjectPath(dedicatedRoot, repoRoot));
521
+ const repoParent = normalizeComparableCwd(path.dirname(resolvedRepoRoot));
522
+ const leafParent = normalizeComparableCwd(path.dirname(resolvedLeaf));
523
+ const extensionsDir = normalizeComparableCwd(path.join(getAgentDir(), "extensions"));
524
+
525
+ if (isPathInside(extensionsDir, projectDir) || isPathInside(extensionsDir, resolvedLeaf)) {
526
+ throw new Error(`worktree path cannot be inside Pi extensions directory: ${extensionsDir}. Choose a directory outside it.`);
527
+ }
528
+ if (isPathInside(resolvedRepoRoot, resolvedLeaf)) {
529
+ throw new Error(`worktree path would land inside the repository checkout: ${resolvedLeaf}`);
530
+ }
531
+ if (leafParent === repoParent) {
532
+ throw new Error(`worktree path would be a direct child of the repository parent: ${resolvedLeaf}`);
533
+ }
534
+ if (!isStrictChildPath(projectDir, resolvedLeaf)) {
535
+ throw new Error(`worktree path must be a strict child of the project worktree directory ${projectDir}: ${resolvedLeaf}`);
536
+ }
456
537
  }
457
538
 
458
539
  function resolveRepoCwdRelative(cwd: string): string {
@@ -470,7 +551,9 @@ function resolveRepoCwdRelative(cwd: string): string {
470
551
  export function resolveExpectedWorktreeAgentCwd(cwd: string, runId: string, index: number, baseDir?: string): string {
471
552
  const cwdRelative = resolveRepoCwdRelative(cwd);
472
553
  const repoRoot = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim();
473
- const worktreePath = buildNativeWorktreePath(resolveWorktreeBaseDir(baseDir, repoRoot), runId, index);
554
+ const dedicatedRoot = resolveWorktreeDedicatedRoot(baseDir, repoRoot);
555
+ const worktreePath = buildNativeWorktreePath(dedicatedRoot, repoRoot, runId, index);
556
+ assertSafeWorktreeLocation(worktreePath, repoRoot, dedicatedRoot);
474
557
  return cwdRelative ? path.join(worktreePath, cwdRelative) : worktreePath;
475
558
  }
476
559
 
@@ -660,13 +743,15 @@ function createNativeWorktree(
660
743
  baseCommit: string,
661
744
  setupHook: ResolvedWorktreeSetupHook | undefined,
662
745
  agent: string | undefined,
663
- baseDir: string,
746
+ dedicatedRoot: string,
664
747
  labels: Array<string | undefined> | undefined,
665
748
  tasks: Array<string | undefined> | undefined,
666
749
  branchPrefix: string | undefined,
667
750
  ): WorktreeInfo {
668
751
  const naming = buildWorktreeNaming({ runId, index, agent, label: labels?.[index], task: tasks?.[index], branchPrefix });
669
- const worktreePath = buildNativeWorktreePath(baseDir, runId, index);
752
+ const worktreePath = buildNativeWorktreePath(dedicatedRoot, toplevel, runId, index);
753
+ assertSafeWorktreeLocation(worktreePath, toplevel, dedicatedRoot);
754
+ ensureProjectWorktreeDir(dedicatedRoot, toplevel);
670
755
  const add = runGit(toplevel, ["worktree", "add", worktreePath, "-b", naming.requestedBranch, baseCommit]);
671
756
  if (add.status !== 0) {
672
757
  const message = add.stderr.trim() || add.stdout.trim() || `failed to create worktree ${worktreePath}`;
@@ -712,13 +797,13 @@ function createWorktrunkWorktree(
712
797
  runId: string,
713
798
  index: number,
714
799
  baseCommit: string,
800
+ sourceCheckout: SourceCheckoutSnapshot,
715
801
  agents: string[] | undefined,
716
802
  labels: Array<string | undefined> | undefined,
717
803
  tasks: Array<string | undefined> | undefined,
718
804
  branchPrefix: string | undefined,
719
805
  ): WorktreeInfo {
720
806
  const naming = buildWorktreeNaming({ runId, index, agent: agents?.[index], label: labels?.[index], task: tasks?.[index], branchPrefix });
721
- const sourceCheckout = snapshotSourceCheckout(toplevel, baseCommit);
722
807
  const args = ["-C", toplevel, "switch", "--create", naming.requestedBranch, "--base", baseCommit, "--no-cd", "--no-hooks", "--format", "json"];
723
808
  const result = runWorktrunk(args, toplevel);
724
809
  if (result.status !== 0) {
@@ -1045,11 +1130,11 @@ function hasWorktreeChanges(diff: WorktreeDiff): boolean {
1045
1130
 
1046
1131
  export function createWorktrees(cwd: string, runId: string, count: number, options?: CreateWorktreesOptions): WorktreeSetup {
1047
1132
  if (!Number.isSafeInteger(count) || count < 0) throw new Error("worktree count must be a non-negative integer");
1048
- const repo = resolveRepoState(cwd);
1133
+ const repo = resolveRepoState(cwd, options?.baseRef);
1049
1134
  const setupHook = resolveWorktreeSetupHook(repo.toplevel, options?.setupHook);
1050
- let provider = resolveWorktreeProvider(options?.provider, options?.baseDir);
1135
+ const provider = resolveWorktreeProvider(options?.provider, options?.baseDir);
1051
1136
  const branchPrefix = normalizeWorktreeBranchPrefix(options?.branchPrefix);
1052
- let baseDir = provider === "native" ? resolveWorktreeBaseDir(options?.baseDir, repo.toplevel) : undefined;
1137
+ const dedicatedRoot = provider === "native" ? resolveWorktreeDedicatedRoot(options?.baseDir, repo.toplevel) : undefined;
1053
1138
  const worktrees: WorktreeInfo[] = [];
1054
1139
 
1055
1140
  try {
@@ -1059,7 +1144,8 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
1059
1144
  baseCommit: repo.baseCommit,
1060
1145
  worktrees: Array.from({ length: count }, (_, index) => {
1061
1146
  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);
1147
+ const worktreePath = buildNativeWorktreePath(dedicatedRoot!, repo.toplevel, runId, index);
1148
+ assertSafeWorktreeLocation(worktreePath, repo.toplevel, dedicatedRoot!);
1063
1149
  return {
1064
1150
  path: worktreePath,
1065
1151
  agentCwd: repo.cwdRelative ? path.join(worktreePath, repo.cwdRelative) : worktreePath,
@@ -1075,14 +1161,14 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
1075
1161
  options?.beforeCreate?.(plannedSetup);
1076
1162
  for (let index = 0; index < count; index++) {
1077
1163
  worktrees.push(createNativeWorktree(
1078
- repo.toplevel,
1164
+ repo.toplevel,
1079
1165
  repo.cwdRelative,
1080
1166
  runId,
1081
1167
  index,
1082
1168
  repo.baseCommit,
1083
1169
  setupHook,
1084
1170
  options?.agents?.[index],
1085
- baseDir!,
1171
+ dedicatedRoot!,
1086
1172
  options?.labels,
1087
1173
  options?.tasks,
1088
1174
  branchPrefix,
@@ -1096,6 +1182,7 @@ export function createWorktrees(cwd: string, runId: string, count: number, optio
1096
1182
  runId,
1097
1183
  index,
1098
1184
  repo.baseCommit,
1185
+ repo.sourceCheckout,
1099
1186
  options?.agents,
1100
1187
  options?.labels,
1101
1188
  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
@@ -8,6 +8,7 @@ export interface DrainableSource {
8
8
  export interface JsonlWriteStream {
9
9
  write(chunk: string): boolean;
10
10
  once(event: "drain", listener: () => void): JsonlWriteStream;
11
+ on?(event: "error", listener: (error: Error) => void): JsonlWriteStream;
11
12
  end(callback?: () => void): void;
12
13
  }
13
14
 
@@ -50,6 +51,16 @@ export function createJsonlWriter(
50
51
  let closed = false;
51
52
  let bytesWritten = 0;
52
53
  const maxBytes = deps.maxBytes ?? DEFAULT_MAX_JSONL_BYTES;
54
+ // The mirror is best effort: a stream that cannot open or write (for example
55
+ // because its directory was removed) must not surface as an uncaught error.
56
+ stream.on?.("error", () => {
57
+ closed = true;
58
+ stream = undefined;
59
+ if (backpressured) {
60
+ backpressured = false;
61
+ source.resume();
62
+ }
63
+ });
53
64
 
54
65
  return {
55
66
  writeLine(line: string) {
@@ -1,8 +1,6 @@
1
1
  import { resolveEffectiveThinking, THINKING_LEVELS, type ThinkingLevel } from "./model-info.ts";
2
2
  export type { ThinkingLevel } from "./model-info.ts";
3
3
 
4
- export const SUBAGENT_THINKING_CEILING_ENV = "PI_SUBAGENT_THINKING_CEILING";
5
-
6
4
  const thinkingLevelRanks = new Map<ThinkingLevel, number>(THINKING_LEVELS.map((level, index) => [level, index]));
7
5
 
8
6
  export function parseThinkingLevel(value: unknown, field = "thinking level"): ThinkingLevel {
@@ -26,10 +24,6 @@ export function intersectThinkingCeilings(...ceilings: Array<ThinkingLevel | und
26
24
  return active.reduce((lowest, ceiling) => compareThinkingLevels(ceiling, lowest) < 0 ? ceiling : lowest);
27
25
  }
28
26
 
29
- export function decodeThinkingCeiling(value: string | undefined): ThinkingLevel | undefined {
30
- if (value === undefined || value === "") return undefined;
31
- return parseThinkingLevel(value, "inherited thinking ceiling");
32
- }
33
27
 
34
28
  export interface ThinkingCeilingCheck {
35
29
  model?: string;