@henryqw/pi-subagent 2.10.2 → 2.11.7

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/README.md CHANGED
@@ -60,10 +60,13 @@ Do not edit files.
60
60
  | `tools` | no | Omit for Pi defaults; when present, base tools are listed and every loaded Role/caller extension tool is added automatically. `[]` leaves extension tools only. |
61
61
  | `extensions` | no | Absolute/`~/` paths or package sources. Package-declared Skills and Pi `resources_discover` Skill paths load automatically. Repository-relative paths are rejected. |
62
62
  | `skills` | no | Additional effective Pi Skill names, resolved from Main's registry |
63
+ | `isolation` | no | Set to `worktree` to give each delegated child its own git worktree branched from Main's current `HEAD` |
63
64
  | Markdown body | yes | Role system instructions |
64
65
 
65
66
  Missing skills warn and skip; they do not block delegation. No repo-controlled `.pi/agents` roles. No package-local model picker.
66
67
 
68
+ With `isolation: worktree`, each child runs in `<primary-workspace>/.worktrees/subagent-<hash>` on branch `pi-subagent/subagent-<hash>`, using a fixed hash of the child ID so parallel children never clobber each other's files. This stable root keeps children outside removable linked checkouts. Git submodules reject worktree isolation because parent cleanup can remove their Git metadata. Non-git directories and repositories with no commits silently share Main's working directory; worktree setup failures in a real git repository reject the delegation instead of silently losing isolation. The directory is excluded from git status through the repository-local exclude file. The child is told to work only inside its worktree and commit to its branch; after each run the parent sees a worktree report (path, branch, commits, dirty, pruned) appended to the result — including failures and preserved background work during session shutdown, so kept work is always locatable. Worktrees are discarded only when the dedicated branch has no commits, `HEAD` still names that branch, and the full tree including ignored files and submodules is clean; anything holding work stays for the parent to review and merge — children never merge.
69
+
67
70
  ## Library API
68
71
 
69
72
  Package root exports shared `Role` loading, Skill resolution, task-routed Pi launch, and generic managed Herdr lifecycle:
package/dist/index.d.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { type HerdrExecutor } from "@henryqw/pi-herdr";
3
3
  import { type AvailableModel, type ProfileName, type ResolvedTaskRoute, type ThinkingLevel } from "@henryqw/pi-task-models";
4
+ export { createChildWorktree, finalizeChildWorktree, worktreeContextNote, type WorktreeInfo, type WorktreePayload, } from "./worktree.ts";
4
5
  export interface Role {
5
6
  name: string;
6
7
  description: string;
7
8
  tools?: string[];
9
+ isolation?: string;
8
10
  extensions: string[];
9
11
  skills: string[];
10
12
  systemPrompt: string;
package/dist/index.js CHANGED
@@ -5,10 +5,12 @@ import { fileURLToPath } from "node:url";
5
5
  import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
6
6
  import { createHerdrClient, herdrCommandFailure, hasHerdrErrorCode } from "@henryqw/pi-herdr";
7
7
  import { modelReference, orderedProfileRoutes, PROFILE_NAMES, readTaskModelsConfig, resolveConfiguredTaskRoute, resolveTaskModelRoute, } from "@henryqw/pi-task-models";
8
+ export { createChildWorktree, finalizeChildWorktree, worktreeContextNote, } from "./worktree.js";
8
9
  const CODEX_ALIAS = /^openai-codex-(?:[2-9]|[1-9]\d+)$/;
9
10
  const MULTI_CODEX_EXTENSION = fileURLToPath(import.meta.resolve("@henryqw/pi-multi-codex/extensions/multi-codex.ts"));
10
11
  const ROLE_TOOLS_EXTENSION = fileURLToPath(new URL("../extensions/role-tools.ts", import.meta.url));
11
12
  const ROLE_TOOL_POLICY_FLAG = "pi-subagent-role-tools";
13
+ const CHILD_EXCLUDED_TOOLS = "delegate_task,ask_question,auto_dag_approve,auto_dag_start";
12
14
  export const isProfileName = (value) => typeof value === "string" && PROFILE_NAMES.includes(value);
13
15
  const cleanText = (value, field, source) => {
14
16
  if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
@@ -64,10 +66,14 @@ export function loadRoles(agentDir = getAgentDir()) {
64
66
  throw new Error(`${file}: ${error instanceof Error ? error.message : String(error)}`);
65
67
  }
66
68
  const frontmatter = parsed.frontmatter;
69
+ const isolation = frontmatter.isolation === undefined ? undefined : cleanText(frontmatter.isolation, "isolation", file);
70
+ if (isolation !== undefined && isolation !== "worktree")
71
+ throw new Error(`${file}: isolation must be "worktree".`);
67
72
  return {
68
73
  name: cleanText(frontmatter.name, "name", file),
69
74
  description: cleanText(frontmatter.description, "description", file),
70
75
  tools: frontmatter.tools === undefined ? undefined : stringList(frontmatter.tools, "tools", file, true),
76
+ isolation,
71
77
  extensions: extensionList(frontmatter.extensions, file),
72
78
  skills: stringList(frontmatter.skills, "skills", file),
73
79
  systemPrompt: cleanText(parsed.body, "system prompt", file),
@@ -135,7 +141,7 @@ export function createRoleLaunch(pi, ctx, input) {
135
141
  throw new Error(`Invalid launch environment value: ${key}`);
136
142
  return [key, value];
137
143
  }));
138
- const args = ["--no-session", "--no-extensions", "--no-skills"];
144
+ const args = ["--no-session", "--no-extensions", "--no-skills", "--exclude-tools", CHILD_EXCLUDED_TOOLS];
139
145
  for (const extension of new Set(extensions))
140
146
  args.push("--extension", extension);
141
147
  for (const skill of skills.paths)
@@ -0,0 +1,41 @@
1
+ export interface WorktreeInfo {
2
+ path: string;
3
+ cwd: string;
4
+ branch: string;
5
+ repoRoot: string;
6
+ baseCommit: string;
7
+ }
8
+ export interface WorktreePayload {
9
+ path: string;
10
+ branch: string;
11
+ commits: number;
12
+ dirty: boolean;
13
+ pruned: boolean;
14
+ inspection_failed?: boolean;
15
+ note?: string;
16
+ }
17
+ export type GitRunner = (args: string[], cwd: string, signal?: AbortSignal) => Promise<{
18
+ code: number;
19
+ stdout: string;
20
+ stderr: string;
21
+ }>;
22
+ /**
23
+ * Creates one worktree per child from parent HEAD. Returns undefined only when
24
+ * the workspace is not a git repository or HEAD is unborn — callers degrade
25
+ * silently to the shared working directory. Setup failures in a real git
26
+ * repository (unwritable path, stale branch, git lock) throw so an isolated
27
+ * role never silently loses its isolation.
28
+ */
29
+ export declare function createChildWorktree(cwd: string, childId: string, run?: GitRunner, signal?: AbortSignal): Promise<WorktreeInfo | undefined>;
30
+ /**
31
+ * Inspects and possibly prunes a child worktree after it finishes. Commit count
32
+ * reads the dedicated branch and refuses to prune when checkout HEAD no longer
33
+ * names it; the clean-tree proof includes untracked, ignored, and submodule
34
+ * changes despite repository config. A worktree with zero branch commits and a
35
+ * clean tree is removed only when every probe succeeds and base_commit was
36
+ * recorded; any probe failure keeps everything and reports `inspection_failed`
37
+ * so unmeasured state is never read as empty.
38
+ */
39
+ export declare function finalizeChildWorktree(info: WorktreeInfo, run?: GitRunner): Promise<WorktreePayload>;
40
+ /** Context block telling the child to work inside its isolated worktree. */
41
+ export declare function worktreeContextNote(info: WorktreeInfo): string;
@@ -0,0 +1,252 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, lstatSync } from "node:fs";
4
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
5
+ import { dirname, isAbsolute, join, resolve } from "node:path";
6
+ const GIT_TIMEOUT_MS = 30_000;
7
+ const WORKTREES_DIRNAME = ".worktrees";
8
+ const BRANCH_NAMESPACE = "pi-subagent";
9
+ class WorktreeSetupError extends Error {
10
+ name = "WorktreeSetupError";
11
+ }
12
+ /** Runs git, capturing output; never throws on non-zero exit or spawn failure. */
13
+ const runGit = (args, cwd, signal) => new Promise((resolve) => {
14
+ execFile("git", args, { cwd, timeout: GIT_TIMEOUT_MS, signal }, (error, stdout, stderr) => {
15
+ resolve({
16
+ code: error ? (typeof error.code === "number" ? error.code : -1) : 0,
17
+ stdout: String(stdout),
18
+ stderr: String(stderr),
19
+ });
20
+ });
21
+ });
22
+ const sanitizeShortId = (childId) => createHash("sha256").update(childId).digest("hex").slice(0, 24);
23
+ const stripGitLineEnd = (value) => value.replace(/\r?\n$/, "");
24
+ function hasRepositoryMarker(cwd) {
25
+ for (let directory = resolve(cwd);; directory = dirname(directory)) {
26
+ try {
27
+ lstatSync(join(directory, ".git"));
28
+ return true;
29
+ }
30
+ catch (error) {
31
+ if (!error || typeof error !== "object" || !("code" in error) || !["ENOENT", "ENOTDIR"].includes(String(error.code)))
32
+ return true;
33
+ }
34
+ if (dirname(directory) === directory)
35
+ return false;
36
+ }
37
+ }
38
+ /** Keeps ".worktrees/" out of git status via the common repository's exclude file; never dirties any checkout. */
39
+ async function ensureLocalExclude(gitDir) {
40
+ const entry = `/${WORKTREES_DIRNAME}/`;
41
+ const exclude = join(gitDir, "info", "exclude");
42
+ try {
43
+ await mkdir(join(gitDir, "info"), { recursive: true });
44
+ let existing = "";
45
+ try {
46
+ existing = await readFile(exclude, "utf8");
47
+ }
48
+ catch (error) {
49
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT")
50
+ throw error;
51
+ }
52
+ if (existing.split("\n").some((line) => line.trim() === entry))
53
+ return;
54
+ await appendFile(exclude, `${existing && !existing.endsWith("\n") ? "\n" : ""}${entry}\n`);
55
+ }
56
+ catch (error) {
57
+ throw new WorktreeSetupError(`Could not update ${exclude}: ${error instanceof Error ? error.message : String(error)}`);
58
+ }
59
+ }
60
+ /**
61
+ * Creates one worktree per child from parent HEAD. Returns undefined only when
62
+ * the workspace is not a git repository or HEAD is unborn — callers degrade
63
+ * silently to the shared working directory. Setup failures in a real git
64
+ * repository (unwritable path, stale branch, git lock) throw so an isolated
65
+ * role never silently loses its isolation.
66
+ */
67
+ export async function createChildWorktree(cwd, childId, run = runGit, signal) {
68
+ const root = await run(["rev-parse", "--show-toplevel"], cwd, signal);
69
+ if (root.code !== 0) {
70
+ signal?.throwIfAborted();
71
+ const repository = await run(["-c", "safe.directory=*", "rev-parse", "--show-toplevel"], cwd, signal);
72
+ signal?.throwIfAborted();
73
+ if (repository.code !== 0 && !hasRepositoryMarker(cwd) && !process.env.GIT_DIR && !process.env.GIT_WORK_TREE)
74
+ return undefined;
75
+ throw new Error(`git rev-parse failed (${root.stderr.trim().slice(0, 200)})`); // dubious ownership, timeout, …
76
+ }
77
+ const repoRoot = stripGitLineEnd(root.stdout);
78
+ if (!repoRoot)
79
+ return undefined;
80
+ const prefix = await run(["rev-parse", "--show-prefix"], cwd, signal);
81
+ if (prefix.code !== 0)
82
+ throw new Error(`git rev-parse --show-prefix failed (${prefix.stderr.trim().slice(0, 200)})`);
83
+ const relativeCwd = stripGitLineEnd(prefix.stdout);
84
+ const base = await run(["rev-parse", "HEAD"], repoRoot, signal);
85
+ if (base.code !== 0) {
86
+ signal?.throwIfAborted();
87
+ const head = await run(["symbolic-ref", "--quiet", "HEAD"], repoRoot, signal);
88
+ signal?.throwIfAborted();
89
+ const reference = stripGitLineEnd(head.stdout);
90
+ if (head.code === 0 && reference) {
91
+ const exists = await run(["show-ref", "--verify", "--quiet", reference], repoRoot, signal);
92
+ signal?.throwIfAborted();
93
+ if (exists.code === 1)
94
+ return undefined; // unborn HEAD
95
+ }
96
+ throw new Error(`git rev-parse HEAD failed (${base.stderr.trim().slice(0, 200)})`);
97
+ }
98
+ const baseCommit = base.stdout.trim();
99
+ const superproject = await run(["rev-parse", "--show-superproject-working-tree"], repoRoot, signal);
100
+ if (superproject.code !== 0) {
101
+ throw new Error(`git rev-parse --show-superproject-working-tree failed (${superproject.stderr.trim().slice(0, 200)})`);
102
+ }
103
+ if (stripGitLineEnd(superproject.stdout)) {
104
+ throw new Error("Worktree isolation is unavailable inside Git submodules because parent cleanup can remove their Git metadata.");
105
+ }
106
+ const common = await run(["rev-parse", "--git-common-dir"], repoRoot, signal);
107
+ const rawCommonGitDir = stripGitLineEnd(common.stdout);
108
+ if (common.code !== 0 || !rawCommonGitDir) {
109
+ throw new Error(`git rev-parse --git-common-dir failed (${common.stderr.trim().slice(0, 200)})`);
110
+ }
111
+ const current = await run(["rev-parse", "--git-dir"], repoRoot, signal);
112
+ const rawCurrentGitDir = stripGitLineEnd(current.stdout);
113
+ if (current.code !== 0 || !rawCurrentGitDir) {
114
+ throw new Error(`git rev-parse --git-dir failed (${current.stderr.trim().slice(0, 200)})`);
115
+ }
116
+ const gitDir = isAbsolute(rawCommonGitDir) ? rawCommonGitDir : join(repoRoot, rawCommonGitDir);
117
+ const currentGitDir = isAbsolute(rawCurrentGitDir) ? rawCurrentGitDir : join(repoRoot, rawCurrentGitDir);
118
+ let stableRepoRoot = repoRoot;
119
+ if (currentGitDir !== gitDir) {
120
+ const worktrees = await run(["worktree", "list", "--porcelain", "-z"], repoRoot, signal);
121
+ const primary = worktrees.stdout.split("\0", 1)[0];
122
+ if (worktrees.code !== 0 || !primary?.startsWith("worktree ") || primary.length === "worktree ".length) {
123
+ throw new Error(`git worktree list failed (${worktrees.stderr.trim().slice(0, 200)})`);
124
+ }
125
+ stableRepoRoot = primary.slice("worktree ".length);
126
+ }
127
+ signal?.throwIfAborted();
128
+ const worktreesRoot = join(stableRepoRoot, WORKTREES_DIRNAME);
129
+ const name = `subagent-${sanitizeShortId(childId)}`;
130
+ const branch = `${BRANCH_NAMESPACE}/${name}`;
131
+ const path = join(worktreesRoot, name);
132
+ try {
133
+ await mkdir(worktreesRoot, { recursive: true });
134
+ }
135
+ catch (error) {
136
+ throw new Error(`Could not create ${worktreesRoot}: ${error instanceof Error ? error.message : String(error)}`);
137
+ }
138
+ await ensureLocalExclude(gitDir);
139
+ signal?.throwIfAborted();
140
+ const added = await run(["worktree", "add", path, "-b", branch, baseCommit], repoRoot, signal);
141
+ if (added.code !== 0) {
142
+ throw new WorktreeSetupError(`git worktree add failed; preserved ${path} and ${branch}: ${added.stderr.trim().slice(0, 200)}`);
143
+ }
144
+ return { path, cwd: join(path, relativeCwd), branch, repoRoot: stableRepoRoot, baseCommit };
145
+ }
146
+ /** Flags a payload whose state could not be measured (#88113): unmeasured is not zero. */
147
+ function markUnproven(payload, reason, unmeasured = "commits/dirty") {
148
+ payload.inspection_failed = true;
149
+ payload.note =
150
+ `git inspection failed (${reason}): ${unmeasured} UNKNOWN — not proven zero/clean. `
151
+ + `Any remaining worktree or branch was preserved — inspect ${payload.path} (branch ${payload.branch}) before assuming no work.`;
152
+ return payload;
153
+ }
154
+ async function inspectDirty(run, cwd) {
155
+ const refreshed = await run(["update-index", "--really-refresh"], cwd);
156
+ if (refreshed.code !== 0 && refreshed.code !== 1) {
157
+ return { dirty: false, failure: `update-index exit ${refreshed.code}: ${refreshed.stderr.trim().slice(0, 200)}` };
158
+ }
159
+ const status = await run(["status", "--porcelain", "--untracked-files=all", "--ignored=matching", "--ignore-submodules=none"], cwd);
160
+ if (status.code !== 0)
161
+ return { dirty: false, failure: `status exit ${status.code}: ${status.stderr.trim().slice(0, 200)}` };
162
+ if (refreshed.code === 1 || status.stdout.trim())
163
+ return { dirty: true };
164
+ const flags = await run(["ls-files", "-v", "-z"], cwd);
165
+ if (flags.code !== 0)
166
+ return { dirty: false, failure: `ls-files exit ${flags.code}: ${flags.stderr.trim().slice(0, 200)}` };
167
+ if (flags.stdout.split("\0").some((entry) => /^(?:[a-z]|S) /.test(entry))) {
168
+ return { dirty: false, failure: "assume-unchanged or skip-worktree index entries remain" };
169
+ }
170
+ return { dirty: false };
171
+ }
172
+ /**
173
+ * Inspects and possibly prunes a child worktree after it finishes. Commit count
174
+ * reads the dedicated branch and refuses to prune when checkout HEAD no longer
175
+ * names it; the clean-tree proof includes untracked, ignored, and submodule
176
+ * changes despite repository config. A worktree with zero branch commits and a
177
+ * clean tree is removed only when every probe succeeds and base_commit was
178
+ * recorded; any probe failure keeps everything and reports `inspection_failed`
179
+ * so unmeasured state is never read as empty.
180
+ */
181
+ export async function finalizeChildWorktree(info, run = runGit) {
182
+ const payload = { path: info.path, branch: info.branch, commits: 0, dirty: false, pruned: false };
183
+ const checkoutExists = existsSync(info.path);
184
+ const gitCwd = checkoutExists ? info.path : info.repoRoot || info.path;
185
+ if (!info.baseCommit)
186
+ return markUnproven(payload, "no base_commit recorded — commit count unmeasurable", "commits");
187
+ const counted = await run(["rev-list", "--count", `${info.baseCommit}..${info.branch}`], gitCwd);
188
+ const commits = Number.parseInt(counted.stdout.trim(), 10);
189
+ const countFailure = counted.code !== 0
190
+ ? `rev-list exit ${counted.code}: ${counted.stderr.trim().slice(0, 200)}`
191
+ : Number.isNaN(commits) ? "rev-list produced non-numeric output" : undefined;
192
+ const inspection = checkoutExists ? await inspectDirty(run, info.path) : undefined;
193
+ if (!countFailure)
194
+ payload.commits = commits;
195
+ if (inspection)
196
+ payload.dirty = inspection.dirty;
197
+ if (countFailure || inspection?.failure) {
198
+ return markUnproven(payload, [countFailure, inspection?.failure].filter(Boolean).join("; "), [countFailure && "commits", inspection?.failure && "dirty"].filter(Boolean).join("/"));
199
+ }
200
+ let forceRemove = false;
201
+ if (checkoutExists) {
202
+ if (payload.commits > 0 || payload.dirty)
203
+ return payload;
204
+ const head = await run(["symbolic-ref", "--quiet", "HEAD"], info.path);
205
+ if (head.code !== 0 || head.stdout.trim() !== `refs/heads/${info.branch}`) {
206
+ return markUnproven(payload, "HEAD is detached, switched, or unreadable", "checked-out commits");
207
+ }
208
+ const modules = await run(["submodule", "status", "--recursive"], info.path);
209
+ if (modules.code !== 0)
210
+ return markUnproven(payload, `submodule list exit ${modules.code}: ${modules.stderr.trim().slice(0, 200)}`, "dirty");
211
+ forceRemove = modules.stdout.split("\n").some((line) => line && !line.startsWith("-"));
212
+ if (forceRemove) {
213
+ const listed = await run(["submodule", "foreach", "--recursive", "--quiet", "printf '%s\\0' \"$PWD\""], info.path);
214
+ if (listed.code !== 0)
215
+ return markUnproven(payload, `submodule list exit ${listed.code}: ${listed.stderr.trim().slice(0, 200)}`, "dirty");
216
+ const paths = listed.stdout.split("\0").filter(Boolean);
217
+ if (!paths.length)
218
+ return markUnproven(payload, "initialized submodule paths unavailable", "dirty");
219
+ for (const path of paths) {
220
+ const nested = await inspectDirty(run, path);
221
+ if (nested.failure)
222
+ return markUnproven(payload, `submodule ${path}: ${nested.failure}`, "dirty");
223
+ if (nested.dirty) {
224
+ payload.dirty = true;
225
+ return payload;
226
+ }
227
+ }
228
+ }
229
+ const rechecked = await inspectDirty(run, info.path);
230
+ if (rechecked.failure)
231
+ return markUnproven(payload, `final ${rechecked.failure}`, "dirty");
232
+ if (rechecked.dirty) {
233
+ payload.dirty = true;
234
+ return payload;
235
+ }
236
+ }
237
+ else if (payload.commits > 0)
238
+ return payload;
239
+ const cleanupCwd = info.repoRoot || info.path;
240
+ const removed = await run(["worktree", "remove", ...(forceRemove ? ["--force"] : []), info.path], cleanupCwd);
241
+ if (removed.code !== 0)
242
+ return markUnproven(payload, `worktree remove exit ${removed.code}: ${removed.stderr.trim().slice(0, 200)}`, "cleanup");
243
+ const deleted = await run(["update-ref", "-d", `refs/heads/${info.branch}`, info.baseCommit], cleanupCwd);
244
+ if (deleted.code !== 0)
245
+ return markUnproven(payload, `branch delete exit ${deleted.code}: ${deleted.stderr.trim().slice(0, 200)}`, "cleanup");
246
+ payload.pruned = true;
247
+ return payload;
248
+ }
249
+ /** Context block telling the child to work inside its isolated worktree. */
250
+ export function worktreeContextNote(info) {
251
+ return `\n\n[WORKTREE ISOLATION] Work only in ${info.path} on ${info.branch}; do not use main checkout. Commit changes to this branch for parent review.`;
252
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  export const ROLE_TOOL_POLICY_FLAG = "pi-subagent-role-tools";
4
+ const CHILD_EXCLUDED_TOOLS = new Set(["delegate_task", "ask_question", "auto_dag_approve", "auto_dag_start"]);
4
5
 
5
6
  function configuredTools(value: unknown): string[] | undefined {
6
7
  if (value === undefined) return;
@@ -28,6 +29,6 @@ export default function roleTools(pi: ExtensionAPI): void {
28
29
  const extensionTools = pi.getAllTools()
29
30
  .filter((tool) => !["builtin", "sdk", "inline"].includes(tool.sourceInfo.source))
30
31
  .map((tool) => tool.name);
31
- pi.setActiveTools([...new Set([...selected, ...extensionTools])]);
32
+ pi.setActiveTools([...new Set([...selected, ...extensionTools])].filter((name) => !CHILD_EXCLUDED_TOOLS.has(name)));
32
33
  });
33
34
  }
@@ -18,7 +18,7 @@ import {
18
18
  taskThinkingLevels,
19
19
  } from "@henryqw/pi-task-models";
20
20
  import { Type } from "typebox";
21
- import { createRoleLaunch, isProfileName, loadRoles, resolveTaskRoute } from "@henryqw/pi-subagent";
21
+ import { createChildWorktree, createRoleLaunch, finalizeChildWorktree, isProfileName, loadRoles, resolveTaskRoute, worktreeContextNote, type WorktreeInfo, type WorktreePayload } from "@henryqw/pi-subagent";
22
22
 
23
23
  const SUBAGENT_TASK = "pi-subagent/delegateTask";
24
24
  const MAX_OUTPUT_BYTES = 50 * 1024;
@@ -55,6 +55,11 @@ type ChildResult = {
55
55
  stopReason?: string;
56
56
  errorMessage?: string;
57
57
  };
58
+ type DelegateResult = {
59
+ content: [{ type: "text"; text: string }];
60
+ details: Record<string, unknown>;
61
+ isError?: boolean;
62
+ };
58
63
  type WidgetStatus = "working" | "success" | "failure" | "aborted";
59
64
  type WidgetItem = {
60
65
  roleRoute: string;
@@ -130,6 +135,16 @@ function taskSummary(task: string): string {
130
135
  return task.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().split(/\s+/).slice(0, 4).join(" ");
131
136
  }
132
137
 
138
+ /** Finalizes an isolated child's worktree; returns its report, or undefined when absent/failed. */
139
+ async function finalizeWorktreePayload(worktree: WorktreeInfo | undefined): Promise<WorktreePayload | undefined> {
140
+ if (!worktree) return undefined;
141
+ try {
142
+ return await finalizeChildWorktree(worktree);
143
+ } catch {
144
+ return undefined;
145
+ }
146
+ }
147
+
133
148
  function formatTokens(tokens: number): string {
134
149
  if (tokens < 1_000) return String(tokens);
135
150
  if (tokens < 100_000) return `${(tokens / 1_000).toFixed(1)}k`;
@@ -311,12 +326,16 @@ async function runPi(
311
326
  }
312
327
  };
313
328
 
314
- const killTree = (force: boolean) => {
329
+ const killTree = async (force: boolean): Promise<void> => {
315
330
  if (!child.pid) return;
316
331
  if (process.platform === "win32") {
317
- spawn("taskkill", [...(force ? ["/F"] : []), "/T", "/PID", String(child.pid)], {
318
- stdio: "ignore",
319
- windowsHide: true,
332
+ await new Promise<void>((resolve) => {
333
+ const taskkill = spawn("taskkill", [...(force ? ["/F"] : []), "/T", "/PID", String(child.pid)], {
334
+ stdio: "ignore",
335
+ windowsHide: true,
336
+ });
337
+ taskkill.once("error", () => resolve());
338
+ taskkill.once("close", () => resolve());
320
339
  });
321
340
  return;
322
341
  }
@@ -350,7 +369,7 @@ async function runPi(
350
369
  lineBytes += Buffer.byteLength(part, "utf8");
351
370
  if (lineBytes > MAX_JSON_EVENT_BYTES) {
352
371
  protocolError = new Error(`Subagent JSON event exceeds ${MAX_JSON_EVENT_BYTES} bytes.`);
353
- killTree(true);
372
+ void killTree(true);
354
373
  return;
355
374
  }
356
375
  if (part) lineParts.push(part);
@@ -373,8 +392,8 @@ async function runPi(
373
392
  child.on("error", (error) => { spawnError = error; });
374
393
 
375
394
  const stop = () => {
376
- killTree(false);
377
- killTimer = setTimeout(() => killTree(true), 5_000);
395
+ void killTree(false);
396
+ killTimer = setTimeout(() => void killTree(true), 5_000);
378
397
  killTimer.unref();
379
398
  };
380
399
  const abort = () => {
@@ -401,9 +420,11 @@ async function runPi(
401
420
  signal?.addEventListener("abort", abort, { once: true });
402
421
  if (signal?.aborted) abort();
403
422
 
404
- child.on("close", (code) => {
423
+ child.on("close", async (code) => {
405
424
  if (!protocolError && lineBytes) processLine(lineParts.join(""));
406
- if (aborted || timedOutAfterMs !== undefined) killTree(true);
425
+ // Pi may exit while redirected background commands remain in its process
426
+ // group. Stop every descendant before callers inspect or prune its cwd.
427
+ await killTree(true);
407
428
  if (softDeadlineTimer) clearTimeout(softDeadlineTimer);
408
429
  if (hardDeadlineTimer) clearTimeout(hardDeadlineTimer);
409
430
  if (killTimer) clearTimeout(killTimer);
@@ -499,7 +520,7 @@ export default function subagentExtension(
499
520
  const timeoutPolicy: TimeoutPolicy = overrideTimeoutPolicy ?? resolveTimeoutPolicy(loadedConfig.config.timeout);
500
521
  // Background children outlive the launching tool call, so they get their own
501
522
  // abort signal: tied to the session, not to the turn that started them.
502
- const backgroundTasks = new Map<string, AbortController>();
523
+ const backgroundTasks = new Map<string, { controller: AbortController; settled: Promise<void> }>();
503
524
  // Latest known session context; refreshed on session lifecycle and model
504
525
  // changes so queued background launches resolve against effective state.
505
526
  let latestCtx: ExtensionContext | undefined;
@@ -617,16 +638,18 @@ export default function subagentExtension(
617
638
  ensureWidget(ctx);
618
639
  for (const warning of startupWarnings.splice(0)) ctx.ui.notify(warning, "warning");
619
640
  });
620
- pi.on("session_shutdown", (_event, ctx) => {
641
+ pi.on("session_shutdown", async (_event, ctx) => {
621
642
  stopWidgetTimer();
622
643
  widgetItems.clear();
623
644
  activeTui = undefined;
624
645
  widgetInstalled = false;
625
646
  if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
626
- // Invalidate every outstanding background delivery: the aborting session
627
- // is gone, and a later-settling child must not reach the next session.
647
+ // Invalidate ordinary outcomes, abort children, then let preserved isolated
648
+ // work report into the outgoing session before Pi tears it down.
628
649
  sessionEpoch += 1;
629
- for (const controller of backgroundTasks.values()) controller.abort();
650
+ const tasks = [...backgroundTasks.values()];
651
+ for (const { controller } of tasks) controller.abort();
652
+ await Promise.allSettled(tasks.map(({ settled }) => settled));
630
653
  backgroundTasks.clear();
631
654
  });
632
655
  // btw-style context refresh: model_select carries the new model on the event,
@@ -644,16 +667,23 @@ export default function subagentExtension(
644
667
  details: { role: string; model?: string; thinkingLevel?: string },
645
668
  outcome: "completed" | "failed" | "aborted",
646
669
  text: string,
670
+ worktreePayload?: WorktreePayload,
671
+ setupRecovery?: string,
647
672
  ): Promise<void> => {
648
- if (launchEpoch !== sessionEpoch) return;
673
+ const stale = launchEpoch !== sessionEpoch;
674
+ if (stale && (!worktreePayload || worktreePayload.pruned) && !setupRecovery) return;
649
675
  // Custom messages convert to user-role LLM messages, so the parent agent
650
676
  // sees the outcome on its next turn without a forced turn now.
651
677
  try {
652
678
  pi.sendMessage({
653
679
  customType: BACKGROUND_RESULT_TYPE,
654
- content: `Background subagent ${taskId} (${details.role}) ${outcome}.\n\n${capOutput(text)}`,
680
+ content: stale
681
+ ? worktreePayload && !worktreePayload.pruned
682
+ ? `Background subagent ${taskId} (${details.role}) left recoverable isolated work after session shutdown.\n${JSON.stringify(worktreePayload)}`
683
+ : `Background subagent ${taskId} (${details.role}) left recoverable isolated setup state after session shutdown.\n${setupRecovery}`
684
+ : `Background subagent ${taskId} (${details.role}) ${outcome}.\n\n${capOutput(text)}${worktreePayload ? `\n${JSON.stringify(worktreePayload)}` : ""}`,
655
685
  display: true,
656
- details: { ...details, taskId, outcome },
686
+ details: { ...details, taskId, outcome, ...(stale ? { recovery: true } : {}) },
657
687
  }, { triggerTurn: false });
658
688
  } catch {
659
689
  // Session may already be gone; the widget row still shows the outcome.
@@ -710,18 +740,21 @@ export default function subagentExtension(
710
740
  if (params.background) {
711
741
  const taskId = `bg-${++backgroundSequence}-${Date.now().toString(36)}`;
712
742
  const controller = new AbortController();
713
- backgroundTasks.set(taskId, controller);
714
743
  // Freeze the launching session now: a task that settles after a
715
744
  // reload must not deliver into whichever session is active then.
716
745
  const launchEpoch = sessionEpoch;
717
- void (async () => {
746
+ const settled = (async () => {
718
747
  let acquired = false;
719
748
  let widgetStatus: Exclude<WidgetStatus, "working"> = "failure";
720
749
  // Role is known up front; model/thinking join after launch resolution.
721
750
  let details: { role: string; model?: string; thinkingLevel?: string } = { role: role.name };
751
+ let worktree: WorktreeInfo | undefined;
722
752
  try {
723
753
  await acquireChildPermit(controller.signal);
724
754
  acquired = true;
755
+ // Same contract as foreground: isolation only after the permit,
756
+ // setup failure fails closed via the catch below.
757
+ if (role.isolation === "worktree") worktree = await createChildWorktree(ctx.cwd, toolCallId, undefined, controller.signal);
725
758
  // Resolve resources only once launched: a task queued past the
726
759
  // cap must not start with model routes or skills resolved before
727
760
  // registries or accounts changed while it waited.
@@ -730,8 +763,8 @@ export default function subagentExtension(
730
763
  details = { role: role.name, model: modelReference(launch.model), thinkingLevel: launch.thinkingLevel };
731
764
  startWidgetItem(taskId, role.name, launch.model.id, launch.thinkingLevel, task, ctx);
732
765
  const result = await runPi(
733
- ["--mode", "json", "-p", ...launch.args, `Task: ${task}`],
734
- ctx.cwd,
766
+ ["--mode", "json", "-p", ...launch.args, `Task: ${worktree ? `${task}${worktreeContextNote(worktree)}` : task}`],
767
+ worktree?.cwd ?? ctx.cwd,
735
768
  controller.signal,
736
769
  undefined,
737
770
  (tokens) => updateWidgetTokens(taskId, tokens),
@@ -739,25 +772,31 @@ export default function subagentExtension(
739
772
  );
740
773
  const failed = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
741
774
  widgetStatus = result.stopReason === "aborted" ? "aborted" : failed ? "failure" : "success";
742
- const text = capOutput(failed
775
+ const text = failed
743
776
  ? result.errorMessage || result.stderr.trim() || result.output || `Subagent exited with code ${result.exitCode}.`
744
- : result.output || "(no output)");
777
+ : result.output || "(no output)";
778
+ const payloadLine = await finalizeWorktreePayload(worktree);
745
779
  await reportBackground(
746
780
  launchEpoch,
747
781
  taskId,
748
782
  details,
749
783
  result.stopReason === "aborted" ? "aborted" : failed ? "failed" : "completed",
750
784
  text,
785
+ payloadLine,
751
786
  );
752
787
  } catch (error) {
753
788
  const aborted = controller.signal.aborted && !(error instanceof SubagentTimeoutError);
754
789
  widgetStatus = aborted ? "aborted" : "failure";
790
+ const failureText = error instanceof Error ? error.message : String(error);
791
+ const payloadLine = await finalizeWorktreePayload(worktree);
755
792
  await reportBackground(
756
793
  launchEpoch,
757
794
  taskId,
758
795
  details,
759
796
  aborted ? "aborted" : "failed",
760
- capOutput(error instanceof Error ? error.message : String(error)),
797
+ failureText,
798
+ payloadLine,
799
+ error instanceof Error && error.name === "WorktreeSetupError" ? failureText : undefined,
761
800
  );
762
801
  } finally {
763
802
  if (acquired) releaseChildPermit();
@@ -765,6 +804,8 @@ export default function subagentExtension(
765
804
  backgroundTasks.delete(taskId);
766
805
  }
767
806
  })();
807
+ backgroundTasks.set(taskId, { controller, settled });
808
+ void settled;
768
809
  return {
769
810
  content: [{ type: "text" as const, text: `Background subagent ${taskId} started (${role.name}). The outcome arrives as a message when the task settles; keep working or end your turn.` }],
770
811
  details: { role: role.name, taskId, background: true },
@@ -775,32 +816,49 @@ export default function subagentExtension(
775
816
  notifyMissingSkills(launch);
776
817
  const modelReferenceValue = modelReference(launch.model);
777
818
  const details = { role: role.name, model: modelReferenceValue, thinkingLevel: launch.thinkingLevel };
778
-
779
819
  await acquireChildPermit(signal);
780
820
  let widgetStatus: Exclude<WidgetStatus, "working"> = "failure";
821
+ let result: DelegateResult | undefined;
822
+ let worktree: WorktreeInfo | undefined;
823
+ let rethrow: unknown;
824
+ let worktreePayload: WorktreePayload | undefined;
781
825
  try {
826
+ // Isolation is created only after preflight and permit acquisition so a
827
+ // rejected delegation cannot leak worktrees; setup failure fails closed.
828
+ if (role.isolation === "worktree") worktree = await createChildWorktree(ctx.cwd, toolCallId, undefined, signal);
782
829
  startWidgetItem(toolCallId, role.name, launch.model.id, launch.thinkingLevel, task, ctx);
783
- const result = await runPi(
784
- ["--mode", "json", "-p", ...launch.args, `Task: ${task}`],
785
- ctx.cwd,
830
+ const child = await runPi(
831
+ ["--mode", "json", "-p", ...launch.args, `Task: ${worktree ? `${task}${worktreeContextNote(worktree)}` : task}`],
832
+ worktree?.cwd ?? ctx.cwd,
786
833
  signal,
787
834
  (text) => onUpdate?.({ content: [{ type: "text", text }], details }),
788
835
  (tokens) => updateWidgetTokens(toolCallId, tokens),
789
836
  timeoutPolicy,
790
837
  );
791
- const failed = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
792
- widgetStatus = result.stopReason === "aborted" ? "aborted" : failed ? "failure" : "success";
838
+ const failed = child.exitCode !== 0 || child.stopReason === "error" || child.stopReason === "aborted";
839
+ widgetStatus = child.stopReason === "aborted" ? "aborted" : failed ? "failure" : "success";
793
840
  const text = capOutput(failed
794
- ? result.errorMessage || result.stderr.trim() || result.output || `Subagent exited with code ${result.exitCode}.`
795
- : result.output || "(no output)");
796
- return { content: [{ type: "text" as const, text }], details, ...(failed ? { isError: true } : {}) };
841
+ ? child.errorMessage || child.stderr.trim() || child.output || `Subagent exited with code ${child.exitCode}.`
842
+ : child.output || "(no output)");
843
+ result = { content: [{ type: "text" as const, text }], details, ...(failed ? { isError: true } : {}) };
844
+ return result;
797
845
  } catch (error) {
798
846
  if (signal?.aborted && !(error instanceof SubagentTimeoutError)) widgetStatus = "aborted";
799
- throw error;
847
+ rethrow = error;
800
848
  } finally {
801
849
  releaseChildPermit();
802
850
  finishWidgetItem(toolCallId, widgetStatus);
851
+ worktreePayload = await finalizeWorktreePayload(worktree);
852
+ if (result && worktreePayload) result.content[0].text += `\n${JSON.stringify(worktreePayload)}`;
853
+ }
854
+ if (rethrow !== undefined) {
855
+ // A kept-but-unreported worktree is unrecoverable: attach the report
856
+ // locating it (path/branch/dirty state) to whatever failure escapes.
857
+ throw worktreePayload
858
+ ? new Error(`${rethrow instanceof Error ? rethrow.message : String(rethrow)}\n${JSON.stringify(worktreePayload)}`)
859
+ : rethrow;
803
860
  }
861
+ return result!;
804
862
  },
805
863
  });
806
864
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "2.10.2",
3
+ "version": "2.11.7",
4
4
  "description": "Delegate one task to an isolated Pi role with explicit extensions and skills.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -59,6 +59,6 @@
59
59
  "dependencies": {
60
60
  "@henryqw/pi-herdr": "^0.1.1",
61
61
  "@henryqw/pi-multi-codex": "^0.3.8",
62
- "@henryqw/pi-task-models": "^0.6.0"
62
+ "@henryqw/pi-task-models": "^0.7.2"
63
63
  }
64
64
  }