@henryqw/pi-subagent 23.0.2 → 24.0.1
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/CONTEXT.md +1 -1
- package/README.md +2 -2
- package/dist/index.d.ts +5 -6
- package/dist/index.js +37 -59
- package/dist/launch-runtime.d.ts +0 -2
- package/dist/launch-runtime.js +2 -9
- package/dist/mcp-role.d.ts +0 -1
- package/dist/mcp-role.js +0 -11
- package/dist/runner.js +9 -16
- package/dist/schema.d.ts +39 -8
- package/dist/schema.js +26 -2
- package/dist/worktree.d.ts +0 -2
- package/dist/worktree.js +0 -4
- package/extensions/admission.ts +23 -31
- package/extensions/config.ts +2 -8
- package/extensions/isolated.ts +4 -5
- package/extensions/result-transport.ts +15 -32
- package/extensions/role-mcp.ts +3 -7
- package/extensions/role-tools.ts +2 -8
- package/extensions/subagent-command.ts +5 -5
- package/extensions/subagent.ts +16 -27
- package/extensions/workflow.ts +14 -1
- package/package.json +1 -1
- package/dist/working-evidence.d.ts +0 -25
- package/dist/working-evidence.js +0 -261
package/dist/working-evidence.js
DELETED
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
import { constants } from "node:fs";
|
|
4
|
-
import { chmod, copyFile, lstat, mkdir, mkdtemp, open, readFile, readlink, realpath, rm, writeFile } from "node:fs/promises";
|
|
5
|
-
import { tmpdir } from "node:os";
|
|
6
|
-
import { isAbsolute, join, resolve } from "node:path";
|
|
7
|
-
import { inspectIndexFlags } from "./worktree.js";
|
|
8
|
-
import { runGit as runCapturedGit } from "./git-process.js";
|
|
9
|
-
import { REVIEW_MAX_PATCH_BYTES, REVIEW_MAX_PATHS } from "./review-evidence.js";
|
|
10
|
-
const GIT_TIMEOUT_MS = 30_000;
|
|
11
|
-
const OUTPUT_LIMIT = REVIEW_MAX_PATCH_BYTES + 1;
|
|
12
|
-
const MANIFEST_MAX_BYTES = 512 * 1024;
|
|
13
|
-
function hash(value) {
|
|
14
|
-
return createHash("sha256").update(value).digest("hex");
|
|
15
|
-
}
|
|
16
|
-
function line(value, field) {
|
|
17
|
-
const text = value.toString("utf8").replace(/\r?\n$/, "");
|
|
18
|
-
if (!text || text.includes("�") || /[\r\n\0]/.test(text))
|
|
19
|
-
throw new Error(`Git returned malformed ${field}.`);
|
|
20
|
-
return text;
|
|
21
|
-
}
|
|
22
|
-
function oid(value, field) {
|
|
23
|
-
const result = line(value, field);
|
|
24
|
-
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(result))
|
|
25
|
-
throw new Error(`Git returned invalid ${field}.`);
|
|
26
|
-
return result;
|
|
27
|
-
}
|
|
28
|
-
async function command(args, cwd, env = process.env, signal, limit = OUTPUT_LIMIT) {
|
|
29
|
-
signal?.throwIfAborted();
|
|
30
|
-
const child = spawn("git", args, { cwd, env, signal, timeout: GIT_TIMEOUT_MS, stdio: ["ignore", "pipe", "pipe"] });
|
|
31
|
-
const stdout = [];
|
|
32
|
-
const stderr = [];
|
|
33
|
-
let stdoutBytes = 0;
|
|
34
|
-
let stderrBytes = 0;
|
|
35
|
-
let overflow = false;
|
|
36
|
-
const collect = (chunks, chunk, current) => {
|
|
37
|
-
const available = limit - current;
|
|
38
|
-
if (available <= 0) {
|
|
39
|
-
overflow = true;
|
|
40
|
-
return 0;
|
|
41
|
-
}
|
|
42
|
-
chunks.push(chunk.subarray(0, available));
|
|
43
|
-
if (chunk.length > available)
|
|
44
|
-
overflow = true;
|
|
45
|
-
return Math.min(chunk.length, available);
|
|
46
|
-
};
|
|
47
|
-
child.stdout.on("data", (chunk) => { stdoutBytes += collect(stdout, chunk, stdoutBytes); if (overflow)
|
|
48
|
-
child.kill(); });
|
|
49
|
-
child.stderr.on("data", (chunk) => { stderrBytes += collect(stderr, chunk, stderrBytes); if (overflow)
|
|
50
|
-
child.kill(); });
|
|
51
|
-
const code = await new Promise((resolveCode, reject) => {
|
|
52
|
-
child.once("error", reject);
|
|
53
|
-
child.once("close", resolveCode);
|
|
54
|
-
});
|
|
55
|
-
signal?.throwIfAborted();
|
|
56
|
-
if (overflow)
|
|
57
|
-
throw new Error(`Working-change evidence exceeds ${limit - 1} bytes.`);
|
|
58
|
-
return { code: code ?? -1, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) };
|
|
59
|
-
}
|
|
60
|
-
async function git(args, cwd, env, signal) {
|
|
61
|
-
const result = await command(args, cwd, env, signal);
|
|
62
|
-
if (result.code !== 0) {
|
|
63
|
-
const detail = result.stderr.toString("utf8").slice(0, 200).trim();
|
|
64
|
-
throw new Error(`git ${args.join(" ")} failed with exit ${result.code}${detail ? `: ${detail}` : ""}`);
|
|
65
|
-
}
|
|
66
|
-
return result.stdout;
|
|
67
|
-
}
|
|
68
|
-
function paths(value) {
|
|
69
|
-
if (!value.length)
|
|
70
|
-
return [];
|
|
71
|
-
const text = value.toString("utf8");
|
|
72
|
-
if (text.includes("�") || value.at(-1) !== 0)
|
|
73
|
-
throw new Error("Git returned malformed working-change paths.");
|
|
74
|
-
const result = text.slice(0, -1).split("\0");
|
|
75
|
-
if (result.length > REVIEW_MAX_PATHS)
|
|
76
|
-
throw new Error(`Working-change evidence exceeds ${REVIEW_MAX_PATHS} paths.`);
|
|
77
|
-
for (const path of result) {
|
|
78
|
-
if (!path || path.includes("\0") || isAbsolute(path) || path.split(/[\\/]/).some((part) => !part || part === "." || part === "..")) {
|
|
79
|
-
throw new Error(`Git returned malformed or unsupported path: ${JSON.stringify(path)}`);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
return result;
|
|
83
|
-
}
|
|
84
|
-
async function indexPath(worktree, signal) {
|
|
85
|
-
const raw = line(await git(["rev-parse", "--git-path", "index"], worktree, undefined, signal), "index path");
|
|
86
|
-
const path = isAbsolute(raw) ? raw : resolve(worktree, raw);
|
|
87
|
-
return await realpath(path);
|
|
88
|
-
}
|
|
89
|
-
async function repositoryObjects(worktree, signal) {
|
|
90
|
-
const raw = line(await git(["rev-parse", "--git-common-dir"], worktree, undefined, signal), "common Git directory");
|
|
91
|
-
const directory = isAbsolute(raw) ? raw : resolve(worktree, raw);
|
|
92
|
-
return await realpath(join(directory, "objects"));
|
|
93
|
-
}
|
|
94
|
-
async function assertNoExternalFilters(worktree, signal) {
|
|
95
|
-
const result = await command(["config", "--get-regexp", "^filter\\..*\\.(clean|process)$"], worktree, process.env, signal, 64 * 1024);
|
|
96
|
-
if (result.code === 1 && result.stdout.length === 0)
|
|
97
|
-
return;
|
|
98
|
-
if (result.code !== 0)
|
|
99
|
-
throw new Error("Could not inspect effective Git content filters.");
|
|
100
|
-
throw new Error("Checked direct changesets reject configured Git clean/process filters; use isolated mode or remove the filter configuration before retrying.");
|
|
101
|
-
}
|
|
102
|
-
async function assertSupportedIndex(worktree, signal) {
|
|
103
|
-
const flags = await inspectIndexFlags(worktree, runCapturedGit, signal);
|
|
104
|
-
if (flags.failure)
|
|
105
|
-
throw new Error(`Working-change index inspection failed: ${flags.failure}`);
|
|
106
|
-
if (flags.hidden)
|
|
107
|
-
throw new Error("Checked direct changesets reject assume-unchanged or skip-worktree index entries.");
|
|
108
|
-
const stage = (await git(["ls-files", "--stage", "-z"], worktree, undefined, signal)).toString("utf8");
|
|
109
|
-
if (stage.includes("�") || /(?:^|\0)160000 /.test(stage))
|
|
110
|
-
throw new Error("Checked direct changesets reject Git links.");
|
|
111
|
-
}
|
|
112
|
-
async function rawManifest(worktree, signal) {
|
|
113
|
-
const listed = paths(await git(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], worktree, undefined, signal));
|
|
114
|
-
const entries = [];
|
|
115
|
-
for (const path of [...new Set(listed)].sort()) {
|
|
116
|
-
signal?.throwIfAborted();
|
|
117
|
-
const absolute = join(worktree, path);
|
|
118
|
-
let info;
|
|
119
|
-
try {
|
|
120
|
-
info = await lstat(absolute);
|
|
121
|
-
}
|
|
122
|
-
catch (error) {
|
|
123
|
-
if (error.code === "ENOENT") {
|
|
124
|
-
entries.push({ path, kind: "absent" });
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
127
|
-
throw error;
|
|
128
|
-
}
|
|
129
|
-
if (info.isSymbolicLink()) {
|
|
130
|
-
const target = await readlink(absolute, { encoding: "buffer" });
|
|
131
|
-
entries.push({ path, kind: "symlink", mode: info.mode & 0o7777, size: target.length, hash: hash(target) });
|
|
132
|
-
}
|
|
133
|
-
else if (info.isFile()) {
|
|
134
|
-
const contents = await readFile(absolute);
|
|
135
|
-
entries.push({ path, kind: "file", mode: info.mode & 0o7777, size: contents.length, hash: hash(contents) });
|
|
136
|
-
}
|
|
137
|
-
else {
|
|
138
|
-
throw new Error(`Checked direct evidence rejects unsupported working path type: ${path}`);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
const json = `${JSON.stringify(entries)}\n`;
|
|
142
|
-
if (Buffer.byteLength(json, "utf8") > MANIFEST_MAX_BYTES)
|
|
143
|
-
throw new Error(`Working-change raw manifest exceeds ${MANIFEST_MAX_BYTES} bytes.`);
|
|
144
|
-
return { json, digest: hash(json) };
|
|
145
|
-
}
|
|
146
|
-
async function actualIdentity(worktree, signal) {
|
|
147
|
-
const [head, index] = await Promise.all([
|
|
148
|
-
git(["rev-parse", "--verify", "HEAD"], worktree, undefined, signal),
|
|
149
|
-
readFile(await indexPath(worktree, signal)),
|
|
150
|
-
]);
|
|
151
|
-
return { head: oid(head, "HEAD"), indexHash: hash(index) };
|
|
152
|
-
}
|
|
153
|
-
async function privateSnapshot(worktree, directory, signal) {
|
|
154
|
-
const privateIndex = join(directory, "index");
|
|
155
|
-
const objectDirectory = join(directory, "objects");
|
|
156
|
-
await mkdir(objectDirectory, { mode: 0o700 });
|
|
157
|
-
await copyFile(await indexPath(worktree, signal), privateIndex, constants.COPYFILE_EXCL);
|
|
158
|
-
await chmod(privateIndex, 0o600);
|
|
159
|
-
const env = {
|
|
160
|
-
...process.env,
|
|
161
|
-
GIT_INDEX_FILE: privateIndex,
|
|
162
|
-
GIT_OBJECT_DIRECTORY: objectDirectory,
|
|
163
|
-
GIT_ALTERNATE_OBJECT_DIRECTORIES: await repositoryObjects(worktree, signal),
|
|
164
|
-
GIT_CONFIG_NOSYSTEM: "1",
|
|
165
|
-
};
|
|
166
|
-
await git(["add", "-A", "--", "."], worktree, env, signal);
|
|
167
|
-
const tree = oid(await git(["write-tree"], worktree, env, signal), "private working tree");
|
|
168
|
-
const manifest = await rawManifest(worktree, signal);
|
|
169
|
-
return { tree, manifest };
|
|
170
|
-
}
|
|
171
|
-
async function makeDirectory() {
|
|
172
|
-
const directory = await mkdtemp(join(tmpdir(), "pi-subagent-working-review-"));
|
|
173
|
-
await chmod(directory, 0o700);
|
|
174
|
-
let cleaning;
|
|
175
|
-
return { directory, cleanup: () => cleaning ??= rm(directory, { recursive: true, force: true }) };
|
|
176
|
-
}
|
|
177
|
-
async function capture(worktree, directory, signal) {
|
|
178
|
-
await assertNoExternalFilters(worktree, signal);
|
|
179
|
-
await assertSupportedIndex(worktree, signal);
|
|
180
|
-
const before = await actualIdentity(worktree, signal);
|
|
181
|
-
const snapshot = await privateSnapshot(worktree, directory, signal);
|
|
182
|
-
const afterManifest = await rawManifest(worktree, signal);
|
|
183
|
-
const after = await actualIdentity(worktree, signal);
|
|
184
|
-
if (before.head !== after.head || before.indexHash !== after.indexHash || snapshot.manifest.digest !== afterManifest.digest) {
|
|
185
|
-
throw new Error("Working checkout changed while direct evidence was being captured.");
|
|
186
|
-
}
|
|
187
|
-
return {
|
|
188
|
-
identity: { ...before, tree: snapshot.tree, rawManifestHash: snapshot.manifest.digest },
|
|
189
|
-
manifest: snapshot.manifest.json,
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
async function canonicalRoot(cwd, signal) {
|
|
193
|
-
const canonical = await realpath(cwd);
|
|
194
|
-
return await realpath(line(await git(["rev-parse", "--show-toplevel"], canonical, undefined, signal), "checkout root"));
|
|
195
|
-
}
|
|
196
|
-
/** Capture mutation-detection identity without requiring a clean checkout or constructing Git objects. */
|
|
197
|
-
export async function captureWorkingCheckoutState(cwd, signal) {
|
|
198
|
-
const worktree = await canonicalRoot(cwd, signal);
|
|
199
|
-
const before = await actualIdentity(worktree, signal);
|
|
200
|
-
const manifest = await rawManifest(worktree, signal);
|
|
201
|
-
const after = await actualIdentity(worktree, signal);
|
|
202
|
-
if (before.head !== after.head || before.indexHash !== after.indexHash) {
|
|
203
|
-
throw new Error("Working checkout changed while direct state was being captured.");
|
|
204
|
-
}
|
|
205
|
-
return { ...before, rawManifestHash: manifest.digest };
|
|
206
|
-
}
|
|
207
|
-
export async function captureWorkingCheckoutBaseline(cwd, signal) {
|
|
208
|
-
const worktree = await canonicalRoot(cwd, signal);
|
|
209
|
-
const branch = await command(["symbolic-ref", "-q", "HEAD"], worktree, process.env, signal, 64 * 1024);
|
|
210
|
-
if (branch.code !== 0)
|
|
211
|
-
throw new Error("Checked direct changesets require an attached branch.");
|
|
212
|
-
if ((await git(["status", "--porcelain=v1", "--untracked-files=all", "--ignore-submodules=none"], worktree, undefined, signal)).length) {
|
|
213
|
-
throw new Error("Checked direct changesets require a clean initial checkout; settle existing work or choose authorized isolated mode. No files were stashed, reset, or deleted.");
|
|
214
|
-
}
|
|
215
|
-
const owned = await makeDirectory();
|
|
216
|
-
try {
|
|
217
|
-
const captured = await capture(worktree, owned.directory, signal);
|
|
218
|
-
return { worktree, ...captured.identity };
|
|
219
|
-
}
|
|
220
|
-
finally {
|
|
221
|
-
await owned.cleanup();
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
export async function prepareWorkingChangeEvidence(baseline, signal) {
|
|
225
|
-
const owned = await makeDirectory();
|
|
226
|
-
try {
|
|
227
|
-
const captured = await capture(baseline.worktree, owned.directory, signal);
|
|
228
|
-
if (captured.identity.head !== baseline.head)
|
|
229
|
-
throw new Error("Checked direct changesets cannot move HEAD or create commits.");
|
|
230
|
-
const env = {
|
|
231
|
-
...process.env,
|
|
232
|
-
GIT_OBJECT_DIRECTORY: join(owned.directory, "objects"),
|
|
233
|
-
GIT_ALTERNATE_OBJECT_DIRECTORIES: await repositoryObjects(baseline.worktree, signal),
|
|
234
|
-
GIT_CONFIG_NOSYSTEM: "1",
|
|
235
|
-
};
|
|
236
|
-
const raw = await git(["diff", "--no-ext-diff", "--no-textconv", "--ignore-submodules=none", "--raw", "-z", baseline.tree, captured.identity.tree], baseline.worktree, env, signal);
|
|
237
|
-
if (raw.toString("utf8").includes("160000"))
|
|
238
|
-
throw new Error("Checked direct changesets reject changed Git links.");
|
|
239
|
-
const changedPaths = paths(await git(["diff", "--no-ext-diff", "--no-textconv", "--ignore-submodules=none", "--name-only", "-z", baseline.tree, captured.identity.tree], baseline.worktree, env, signal));
|
|
240
|
-
const patch = await git(["--no-pager", "diff", "--no-ext-diff", "--no-textconv", "--ignore-submodules=none", "--binary", baseline.tree, captured.identity.tree], baseline.worktree, env, signal);
|
|
241
|
-
if (patch.length > REVIEW_MAX_PATCH_BYTES)
|
|
242
|
-
throw new Error(`Working-change evidence exceeds ${REVIEW_MAX_PATCH_BYTES} bytes.`);
|
|
243
|
-
const patchPath = join(owned.directory, "review.patch");
|
|
244
|
-
const rawEvidencePath = join(owned.directory, "raw-manifest.json");
|
|
245
|
-
const patchFile = await open(patchPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
|
246
|
-
await patchFile.writeFile(patch);
|
|
247
|
-
await patchFile.close();
|
|
248
|
-
await writeFile(rawEvidencePath, captured.manifest, { mode: 0o600, flag: "wx" });
|
|
249
|
-
return { baseline, identity: captured.identity, changedPaths, patchPath, rawEvidencePath, cleanup: owned.cleanup };
|
|
250
|
-
}
|
|
251
|
-
catch (error) {
|
|
252
|
-
await owned.cleanup();
|
|
253
|
-
throw error;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
export function sameWorkingCheckoutState(left, right) {
|
|
257
|
-
return left.head === right.head && left.indexHash === right.indexHash && left.rawManifestHash === right.rawManifestHash;
|
|
258
|
-
}
|
|
259
|
-
export function sameWorkingSnapshot(left, right) {
|
|
260
|
-
return sameWorkingCheckoutState(left, right) && left.tree === right.tree;
|
|
261
|
-
}
|