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.
- package/CHANGELOG.md +57 -1
- package/README.md +2 -2
- package/agents/reviewer.md +1 -1
- package/agents/scout.md +1 -1
- package/docs/agents.md +18 -16
- package/docs/configuration.md +7 -15
- package/docs/extension-api.md +15 -6
- package/docs/missions.md +2 -0
- package/docs/observability.md +10 -12
- package/docs/tool-reference.md +7 -6
- package/docs/watchdog.md +92 -114
- package/docs/workflows.md +5 -3
- package/package.json +3 -4
- package/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
- package/skills/pi-subagents/references/execution-controls.md +6 -5
- package/src/agents/agent-management.ts +47 -15
- package/src/api/capability-ceiling.ts +0 -1
- package/src/api/{pi-args.ts → child-tool-plan.ts} +1 -1
- package/src/api/preflight.ts +2 -3
- package/src/extension/doctor.ts +2 -10
- package/src/extension/fanout-child.ts +9 -11
- package/src/extension/index.ts +27 -5
- package/src/extension/public-execution.ts +14 -0
- package/src/extension/rpc.ts +3 -2
- package/src/extension/schemas.ts +2 -1
- package/src/extension/tool-description.ts +9 -8
- package/src/intercom/native-supervisor-channel.ts +138 -60
- package/src/intercom/supervisor-ui.ts +243 -0
- package/src/runs/background/async-execution.ts +46 -24
- package/src/runs/background/async-job-tracker.ts +11 -0
- package/src/runs/background/async-resume.ts +11 -2
- package/src/runs/background/control-channel.ts +2 -204
- package/src/runs/background/notify.ts +41 -2
- package/src/runs/background/process-terminal.ts +1 -1
- package/src/runs/background/run-child-session.ts +613 -0
- package/src/runs/background/run-status.ts +0 -1
- package/src/runs/background/runner-aliases.ts +125 -0
- package/src/runs/background/runner-child-sessions.ts +31 -0
- package/src/runs/background/scheduled-runs.ts +18 -4
- package/src/runs/background/subagent-runner.ts +203 -898
- package/src/runs/foreground/async-steering-action.ts +1 -17
- package/src/runs/foreground/execution.ts +196 -378
- package/src/runs/foreground/foreground-control.ts +4 -0
- package/src/runs/foreground/subagent-executor.ts +121 -58
- package/src/runs/foreground/workflow-foreground-steering.ts +24 -98
- package/src/runs/shared/abort-recovery.ts +3 -3
- package/src/runs/shared/acceptance.ts +10 -0
- package/src/runs/shared/async-status-projection.ts +11 -43
- package/src/runs/shared/capability-ceiling.ts +1 -2
- package/src/runs/shared/child-hooks.ts +25 -0
- package/src/runs/shared/child-identity.ts +13 -2
- package/src/runs/shared/child-launch.ts +314 -0
- package/src/runs/shared/child-lifecycle.ts +25 -0
- package/src/runs/shared/child-runtime-config.ts +126 -0
- package/src/runs/shared/child-session.ts +342 -0
- package/src/runs/shared/child-tool-plan.ts +530 -0
- package/src/runs/shared/claude-code-adapter.ts +5 -1
- package/src/runs/shared/completion-guard.ts +1 -1
- package/src/runs/shared/external-cli-preflight.ts +16 -0
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +5 -4
- package/src/runs/shared/model-exclusions.ts +82 -14
- package/src/runs/shared/model-fallback.ts +47 -4
- package/src/runs/shared/nested-events.ts +29 -45
- package/src/runs/shared/nested-path.ts +0 -14
- package/src/runs/shared/orca-progress-tabs.ts +12 -7
- package/src/runs/shared/parallel-utils.ts +0 -2
- package/src/runs/shared/permissions.ts +0 -13
- package/src/runs/shared/process-signal.ts +4 -1
- package/src/runs/shared/run-fanout-budget.ts +0 -13
- package/src/runs/shared/runtime-acknowledged-extensions.ts +0 -27
- package/src/runs/shared/structured-output.ts +17 -4
- package/src/runs/shared/subagent-control.ts +6 -2
- package/src/runs/shared/subagent-prompt-runtime.ts +87 -384
- package/src/runs/shared/tool-availability.ts +18 -62
- package/src/runs/shared/tool-budget.ts +0 -14
- package/src/runs/shared/worktree-cleanup-plan.ts +25 -6
- package/src/runs/shared/worktree.ts +117 -30
- package/src/shared/child-session-name.ts +1 -1
- package/src/shared/jsonl-writer.ts +11 -0
- package/src/shared/thinking-ceiling.ts +0 -6
- package/src/shared/types.ts +64 -30
- package/src/shared/utils.ts +3 -4
- package/src/slash/slash-commands.ts +0 -6
- package/src/tui/fleet.ts +0 -1
- package/src/tui/render.ts +238 -31
- package/src/watchdog/child-status.ts +54 -34
- package/src/watchdog/diff-tool.ts +77 -0
- package/src/watchdog/emission-guard.ts +5 -3
- package/src/watchdog/guidance.ts +20 -0
- package/src/watchdog/register-child.ts +28 -25
- package/src/watchdog/register-main.ts +10 -9
- package/src/watchdog/render.ts +4 -5
- package/src/watchdog/review.ts +15 -4
- package/src/watchdog/rules.ts +70 -0
- package/src/watchdog/runtime.ts +75 -92
- package/src/watchdog/scope.ts +0 -11
- package/src/watchdog/settings.ts +48 -104
- package/src/watchdog/types.ts +18 -32
- package/src/watchdog/warning-format.ts +0 -1
- package/src/workflows/chat-progress.ts +3 -2
- package/src/workflows/scripted-workflow.ts +48 -1
- package/src/workflows/workflow-checklist.ts +10 -12
- package/src/workflows/workflow-preflight.ts +28 -1
- package/src/runs/shared/child-protocol.ts +0 -415
- package/src/runs/shared/pi-args.ts +0 -1062
- package/src/runs/shared/subagent-startup-retry.ts +0 -116
- 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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
248
|
-
|
|
249
|
-
if (
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
|
208
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
|
|
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(
|
|
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 ${
|
|
497
|
+
throw new Error(`failed to create worktree base directory ${dedicatedRoot}: ${message}`);
|
|
450
498
|
}
|
|
451
|
-
return resolved;
|
|
452
499
|
}
|
|
453
500
|
|
|
454
|
-
function buildNativeWorktreePath(
|
|
455
|
-
return path.join(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1135
|
+
const provider = resolveWorktreeProvider(options?.provider, options?.baseDir);
|
|
1051
1136
|
const branchPrefix = normalizeWorktreeBranchPrefix(options?.branchPrefix);
|
|
1052
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|