@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 +3 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +7 -1
- package/dist/worktree.d.ts +41 -0
- package/dist/worktree.js +252 -0
- package/extensions/role-tools.ts +2 -1
- package/extensions/subagent.ts +97 -51
- package/package.json +1 -1
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;
|
package/dist/worktree.js
ADDED
|
@@ -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
|
+
}
|
package/extensions/role-tools.ts
CHANGED
|
@@ -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
|
}
|
package/extensions/subagent.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
639
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 && !
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
804
|
-
widgetStatus =
|
|
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
|
-
?
|
|
807
|
-
:
|
|
808
|
-
|
|
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
|
-
|
|
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
|
}
|