@henryqw/pi-subagent 2.10.1 → 2.11.6

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
  }
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { basename } from "node:path";
4
+ import { StringDecoder } from "node:string_decoder";
4
5
  import { StringEnum } from "@earendil-works/pi-ai";
5
6
  import { type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
6
7
  import { type Component, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui";
@@ -11,16 +12,14 @@ import {
11
12
  type ThinkingLevel,
12
13
  modelReference,
13
14
  PROFILE_NAMES,
14
- type ProfileName,
15
15
  resolveAvailableModel,
16
16
  resolveConfiguredTaskRoute,
17
17
  type ResolvedTaskRoute,
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
- const MODEL_CLASSES = PROFILE_NAMES;
24
23
  const SUBAGENT_TASK = "pi-subagent/delegateTask";
25
24
  const MAX_OUTPUT_BYTES = 50 * 1024;
26
25
  const MAX_JSON_EVENT_BYTES = 1024 * 1024;
@@ -48,7 +47,6 @@ export function resolveTimeoutPolicy(partial: SubagentTimeoutConfig | undefined)
48
47
  if (partial.activeWindowSeconds !== undefined) policy.activeWindowMs = partial.activeWindowSeconds * 1_000;
49
48
  return policy;
50
49
  }
51
- type ModelClass = ProfileName;
52
50
  class SubagentTimeoutError extends Error {}
53
51
  type ChildResult = {
54
52
  exitCode: number;
@@ -57,6 +55,11 @@ type ChildResult = {
57
55
  stopReason?: string;
58
56
  errorMessage?: string;
59
57
  };
58
+ type DelegateResult = {
59
+ content: [{ type: "text"; text: string }];
60
+ details: Record<string, unknown>;
61
+ isError?: boolean;
62
+ };
60
63
  type WidgetStatus = "working" | "success" | "failure" | "aborted";
61
64
  type WidgetItem = {
62
65
  roleRoute: string;
@@ -68,8 +71,6 @@ type WidgetItem = {
68
71
  removeAt?: number;
69
72
  };
70
73
 
71
- const isModelClass = isProfileName;
72
-
73
74
  const cleanText = (value: unknown, field: string, file: string): string => {
74
75
  if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
75
76
  throw new Error(`${file}: ${field} must be non-empty text.`);
@@ -103,15 +104,7 @@ function assistantText(message: unknown): string | undefined {
103
104
  }
104
105
 
105
106
  function utf8Prefix(text: string, maxBytes: number): string {
106
- let low = 0;
107
- let high = Math.min(text.length, maxBytes);
108
- while (low < high) {
109
- const middle = Math.ceil((low + high) / 2);
110
- if (Buffer.byteLength(text.slice(0, middle), "utf8") <= maxBytes) low = middle;
111
- else high = middle - 1;
112
- }
113
- if (low > 0 && low < text.length && /[\uD800-\uDBFF]/.test(text[low - 1]) && /[\uDC00-\uDFFF]/.test(text[low])) low--;
114
- return text.slice(0, low);
107
+ return new StringDecoder().write(Buffer.from(text).subarray(0, maxBytes));
115
108
  }
116
109
 
117
110
  function cappedPrefix(text: string, totalBytes: number): string {
@@ -142,6 +135,16 @@ function taskSummary(task: string): string {
142
135
  return task.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim().split(/\s+/).slice(0, 4).join(" ");
143
136
  }
144
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
+
145
148
  function formatTokens(tokens: number): string {
146
149
  if (tokens < 1_000) return String(tokens);
147
150
  if (tokens < 100_000) return `${(tokens / 1_000).toFixed(1)}k`;
@@ -323,12 +326,16 @@ async function runPi(
323
326
  }
324
327
  };
325
328
 
326
- const killTree = (force: boolean) => {
329
+ const killTree = async (force: boolean): Promise<void> => {
327
330
  if (!child.pid) return;
328
331
  if (process.platform === "win32") {
329
- spawn("taskkill", [...(force ? ["/F"] : []), "/T", "/PID", String(child.pid)], {
330
- stdio: "ignore",
331
- 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());
332
339
  });
333
340
  return;
334
341
  }
@@ -362,7 +369,7 @@ async function runPi(
362
369
  lineBytes += Buffer.byteLength(part, "utf8");
363
370
  if (lineBytes > MAX_JSON_EVENT_BYTES) {
364
371
  protocolError = new Error(`Subagent JSON event exceeds ${MAX_JSON_EVENT_BYTES} bytes.`);
365
- killTree(true);
372
+ void killTree(true);
366
373
  return;
367
374
  }
368
375
  if (part) lineParts.push(part);
@@ -385,8 +392,8 @@ async function runPi(
385
392
  child.on("error", (error) => { spawnError = error; });
386
393
 
387
394
  const stop = () => {
388
- killTree(false);
389
- killTimer = setTimeout(() => killTree(true), 5_000);
395
+ void killTree(false);
396
+ killTimer = setTimeout(() => void killTree(true), 5_000);
390
397
  killTimer.unref();
391
398
  };
392
399
  const abort = () => {
@@ -413,9 +420,11 @@ async function runPi(
413
420
  signal?.addEventListener("abort", abort, { once: true });
414
421
  if (signal?.aborted) abort();
415
422
 
416
- child.on("close", (code) => {
423
+ child.on("close", async (code) => {
417
424
  if (!protocolError && lineBytes) processLine(lineParts.join(""));
418
- 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);
419
428
  if (softDeadlineTimer) clearTimeout(softDeadlineTimer);
420
429
  if (hardDeadlineTimer) clearTimeout(hardDeadlineTimer);
421
430
  if (killTimer) clearTimeout(killTimer);
@@ -439,7 +448,7 @@ const Parameters = Type.Object({
439
448
  model: Type.Optional(Type.String({
440
449
  description: "Designated model as provider/modelId; overrides modelClass. Unknown references reject with the list of available models.",
441
450
  })),
442
- modelClass: Type.Optional(StringEnum(MODEL_CLASSES, {
451
+ modelClass: Type.Optional(StringEnum(PROFILE_NAMES, {
443
452
  description: "Classify task complexity: fast for narrow lookups or mechanical edits; balanced for normal bounded work; frontier for ambiguous, cross-cutting, or high-risk reasoning; fav for the user's favorite model when they ask for it. Defaults to the shared pi-subagent/delegateTask assignment.",
444
453
  })),
445
454
  background: Type.Optional(Type.Boolean({
@@ -511,7 +520,7 @@ export default function subagentExtension(
511
520
  const timeoutPolicy: TimeoutPolicy = overrideTimeoutPolicy ?? resolveTimeoutPolicy(loadedConfig.config.timeout);
512
521
  // Background children outlive the launching tool call, so they get their own
513
522
  // abort signal: tied to the session, not to the turn that started them.
514
- const backgroundTasks = new Map<string, AbortController>();
523
+ const backgroundTasks = new Map<string, { controller: AbortController; settled: Promise<void> }>();
515
524
  // Latest known session context; refreshed on session lifecycle and model
516
525
  // changes so queued background launches resolve against effective state.
517
526
  let latestCtx: ExtensionContext | undefined;
@@ -629,16 +638,18 @@ export default function subagentExtension(
629
638
  ensureWidget(ctx);
630
639
  for (const warning of startupWarnings.splice(0)) ctx.ui.notify(warning, "warning");
631
640
  });
632
- pi.on("session_shutdown", (_event, ctx) => {
641
+ pi.on("session_shutdown", async (_event, ctx) => {
633
642
  stopWidgetTimer();
634
643
  widgetItems.clear();
635
644
  activeTui = undefined;
636
645
  widgetInstalled = false;
637
646
  if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
638
- // Invalidate every outstanding background delivery: the aborting session
639
- // 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.
640
649
  sessionEpoch += 1;
641
- 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));
642
653
  backgroundTasks.clear();
643
654
  });
644
655
  // btw-style context refresh: model_select carries the new model on the event,
@@ -656,16 +667,23 @@ export default function subagentExtension(
656
667
  details: { role: string; model?: string; thinkingLevel?: string },
657
668
  outcome: "completed" | "failed" | "aborted",
658
669
  text: string,
670
+ worktreePayload?: WorktreePayload,
671
+ setupRecovery?: string,
659
672
  ): Promise<void> => {
660
- if (launchEpoch !== sessionEpoch) return;
673
+ const stale = launchEpoch !== sessionEpoch;
674
+ if (stale && (!worktreePayload || worktreePayload.pruned) && !setupRecovery) return;
661
675
  // Custom messages convert to user-role LLM messages, so the parent agent
662
676
  // sees the outcome on its next turn without a forced turn now.
663
677
  try {
664
678
  pi.sendMessage({
665
679
  customType: BACKGROUND_RESULT_TYPE,
666
- 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)}` : ""}`,
667
685
  display: true,
668
- details: { ...details, taskId, outcome },
686
+ details: { ...details, taskId, outcome, ...(stale ? { recovery: true } : {}) },
669
687
  }, { triggerTurn: false });
670
688
  } catch {
671
689
  // Session may already be gone; the widget row still shows the outcome.
@@ -693,7 +711,7 @@ export default function subagentExtension(
693
711
  throw new Error(`Unknown Subagent role: ${params.role}. Available roles: ${roles.map(({ name }) => name).join(", ") || "none"}.`);
694
712
  }
695
713
 
696
- if (params.modelClass !== undefined && !isModelClass(params.modelClass)) {
714
+ if (params.modelClass !== undefined && !isProfileName(params.modelClass)) {
697
715
  throw new Error("delegate_task modelClass must be fast, balanced, frontier, or fav.");
698
716
  }
699
717
  // Resolve against the latest known session context: a task queued past
@@ -722,18 +740,21 @@ export default function subagentExtension(
722
740
  if (params.background) {
723
741
  const taskId = `bg-${++backgroundSequence}-${Date.now().toString(36)}`;
724
742
  const controller = new AbortController();
725
- backgroundTasks.set(taskId, controller);
726
743
  // Freeze the launching session now: a task that settles after a
727
744
  // reload must not deliver into whichever session is active then.
728
745
  const launchEpoch = sessionEpoch;
729
- void (async () => {
746
+ const settled = (async () => {
730
747
  let acquired = false;
731
748
  let widgetStatus: Exclude<WidgetStatus, "working"> = "failure";
732
749
  // Role is known up front; model/thinking join after launch resolution.
733
750
  let details: { role: string; model?: string; thinkingLevel?: string } = { role: role.name };
751
+ let worktree: WorktreeInfo | undefined;
734
752
  try {
735
753
  await acquireChildPermit(controller.signal);
736
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);
737
758
  // Resolve resources only once launched: a task queued past the
738
759
  // cap must not start with model routes or skills resolved before
739
760
  // registries or accounts changed while it waited.
@@ -742,8 +763,8 @@ export default function subagentExtension(
742
763
  details = { role: role.name, model: modelReference(launch.model), thinkingLevel: launch.thinkingLevel };
743
764
  startWidgetItem(taskId, role.name, launch.model.id, launch.thinkingLevel, task, ctx);
744
765
  const result = await runPi(
745
- ["--mode", "json", "-p", ...launch.args, `Task: ${task}`],
746
- ctx.cwd,
766
+ ["--mode", "json", "-p", ...launch.args, `Task: ${worktree ? `${task}${worktreeContextNote(worktree)}` : task}`],
767
+ worktree?.cwd ?? ctx.cwd,
747
768
  controller.signal,
748
769
  undefined,
749
770
  (tokens) => updateWidgetTokens(taskId, tokens),
@@ -751,25 +772,31 @@ export default function subagentExtension(
751
772
  );
752
773
  const failed = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
753
774
  widgetStatus = result.stopReason === "aborted" ? "aborted" : failed ? "failure" : "success";
754
- const text = capOutput(failed
775
+ const text = failed
755
776
  ? result.errorMessage || result.stderr.trim() || result.output || `Subagent exited with code ${result.exitCode}.`
756
- : result.output || "(no output)");
777
+ : result.output || "(no output)";
778
+ const payloadLine = await finalizeWorktreePayload(worktree);
757
779
  await reportBackground(
758
780
  launchEpoch,
759
781
  taskId,
760
782
  details,
761
783
  result.stopReason === "aborted" ? "aborted" : failed ? "failed" : "completed",
762
784
  text,
785
+ payloadLine,
763
786
  );
764
787
  } catch (error) {
765
788
  const aborted = controller.signal.aborted && !(error instanceof SubagentTimeoutError);
766
789
  widgetStatus = aborted ? "aborted" : "failure";
790
+ const failureText = error instanceof Error ? error.message : String(error);
791
+ const payloadLine = await finalizeWorktreePayload(worktree);
767
792
  await reportBackground(
768
793
  launchEpoch,
769
794
  taskId,
770
795
  details,
771
796
  aborted ? "aborted" : "failed",
772
- capOutput(error instanceof Error ? error.message : String(error)),
797
+ failureText,
798
+ payloadLine,
799
+ error instanceof Error && error.name === "WorktreeSetupError" ? failureText : undefined,
773
800
  );
774
801
  } finally {
775
802
  if (acquired) releaseChildPermit();
@@ -777,6 +804,8 @@ export default function subagentExtension(
777
804
  backgroundTasks.delete(taskId);
778
805
  }
779
806
  })();
807
+ backgroundTasks.set(taskId, { controller, settled });
808
+ void settled;
780
809
  return {
781
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.` }],
782
811
  details: { role: role.name, taskId, background: true },
@@ -787,32 +816,49 @@ export default function subagentExtension(
787
816
  notifyMissingSkills(launch);
788
817
  const modelReferenceValue = modelReference(launch.model);
789
818
  const details = { role: role.name, model: modelReferenceValue, thinkingLevel: launch.thinkingLevel };
790
-
791
819
  await acquireChildPermit(signal);
792
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;
793
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);
794
829
  startWidgetItem(toolCallId, role.name, launch.model.id, launch.thinkingLevel, task, ctx);
795
- const result = await runPi(
796
- ["--mode", "json", "-p", ...launch.args, `Task: ${task}`],
797
- ctx.cwd,
830
+ const child = await runPi(
831
+ ["--mode", "json", "-p", ...launch.args, `Task: ${worktree ? `${task}${worktreeContextNote(worktree)}` : task}`],
832
+ worktree?.cwd ?? ctx.cwd,
798
833
  signal,
799
834
  (text) => onUpdate?.({ content: [{ type: "text", text }], details }),
800
835
  (tokens) => updateWidgetTokens(toolCallId, tokens),
801
836
  timeoutPolicy,
802
837
  );
803
- const failed = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
804
- 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";
805
840
  const text = capOutput(failed
806
- ? result.errorMessage || result.stderr.trim() || result.output || `Subagent exited with code ${result.exitCode}.`
807
- : result.output || "(no output)");
808
- 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;
809
845
  } catch (error) {
810
846
  if (signal?.aborted && !(error instanceof SubagentTimeoutError)) widgetStatus = "aborted";
811
- throw error;
847
+ rethrow = error;
812
848
  } finally {
813
849
  releaseChildPermit();
814
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;
815
860
  }
861
+ return result!;
816
862
  },
817
863
  });
818
864
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "2.10.1",
3
+ "version": "2.11.6",
4
4
  "description": "Delegate one task to an isolated Pi role with explicit extensions and skills.",
5
5
  "keywords": [
6
6
  "pi-package",