@henryqw/pi-subagent 2.10.2 → 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 +93 -35
- 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
|
@@ -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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
|
627
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
792
|
-
widgetStatus =
|
|
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
|
-
?
|
|
795
|
-
:
|
|
796
|
-
|
|
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
|
-
|
|
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
|
}
|