@henryqw/pi-subagent 23.0.2 → 24.0.0
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 +1 -1
- 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 +9 -8
- 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 +0 -1
- 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 +14 -25
- package/extensions/workflow.ts +0 -1
- package/package.json +1 -1
- package/dist/working-evidence.d.ts +0 -25
- package/dist/working-evidence.js +0 -261
package/CONTEXT.md
CHANGED
|
@@ -76,7 +76,7 @@ Own Role-configured Pi delegation through one `delegate_task` surface:
|
|
|
76
76
|
- Recognized Pi JSON events renew idle timeout; raw bytes do not. Child maximum runtime always terminates ephemeral children.
|
|
77
77
|
- Output, stderr, protocol events, callback draining, and inherited descendant streams are bounded.
|
|
78
78
|
- Low-level worktree finalization returns explicit `pruned`, `retained`, or `recovery` evidence and never force-deletes uncertain work.
|
|
79
|
-
- Exact review
|
|
79
|
+
- Exact review helpers produce bounded private evidence; callers must preserve identity guards and verdict validation.
|
|
80
80
|
|
|
81
81
|
## Owned storage
|
|
82
82
|
|
package/README.md
CHANGED
|
@@ -199,7 +199,7 @@ Children disable ambient extension and Skill discovery. Only declared resources
|
|
|
199
199
|
| `prepareRoleLaunch`, `finalizeRoleLaunch` | functions | Prepare Role prompts, arguments, and tool policy. |
|
|
200
200
|
| `createEphemeralSubagentExecutor` | function | Run bounded no-session Pi children through a FIFO pool. |
|
|
201
201
|
| `createChildWorktree`, `finalizeChildWorktree` | functions | Create and conservatively finalize child worktrees. |
|
|
202
|
-
| `prepareExactReviewEvidence
|
|
202
|
+
| `prepareExactReviewEvidence` | function | Prepare private exact base-to-tip review evidence. |
|
|
203
203
|
| `ExecuteRequestSchema`, `StageRequestSchema`, `IntegrationActionSchema` | schemas | Validate checked request and integration inputs. |
|
|
204
204
|
| `IsolatedRunner`, `FileRunStore` | classes | Run checked work and persist durable request state. |
|
|
205
205
|
| `RoleLaunchRuntime`, `HerdrHostRuntime`, `CheckedGitRuntime` | classes | Supply launch, host, and Git behavior to the runner. |
|
package/dist/index.d.ts
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { type AvailableModel, type ModelTask, type ProfileName, type ResolvedTaskRoute, type ThinkingLevel } from "@henryqw/pi-task-models";
|
|
3
3
|
export { DISPLAY_TEXT_CONTRACT, hasDisplayControlCharacters } from "./display-text.ts";
|
|
4
|
-
export { parseRoleMcpAllowlist,
|
|
4
|
+
export { parseRoleMcpAllowlist, selectRoleMcpConfig, type RoleMcpConfig } from "./mcp-role.ts";
|
|
5
5
|
export { addUsage, capEphemeralSubagentOutput, createEphemeralSubagentExecutor, DEFAULT_MAX_TURNS, EphemeralSubagentError, EXECUTION_BUDGET_ENV, formatDuration, type EphemeralSubagentActivityEvent, type EphemeralSubagentErrorCode, type EphemeralSubagentExecutionBudget, type EphemeralSubagentExecutor, type EphemeralSubagentExecutorOptions, type EphemeralSubagentResult, type EphemeralSubagentRunInput, type EphemeralSubagentTimeout, } from "./ephemeral.ts";
|
|
6
|
-
export { createChildWorktree, finalizeChildWorktree, inspectIndexFlags, inspectWorktreeDirty, WorktreeSetupError,
|
|
6
|
+
export { createChildWorktree, finalizeChildWorktree, inspectIndexFlags, inspectWorktreeDirty, WorktreeSetupError, type WorktreeDirtyInspection, type WorktreeInfo, type WorktreePayload, } from "./worktree.ts";
|
|
7
7
|
export { prepareExactReviewEvidence, REVIEW_MAX_PATCH_BYTES, REVIEW_MAX_PATHS, type PreparedReviewEvidence, type PrepareExactReviewEvidenceInput, } from "./review-evidence.ts";
|
|
8
|
-
export { captureWorkingCheckoutBaseline, captureWorkingCheckoutState, prepareWorkingChangeEvidence, sameWorkingCheckoutState, sameWorkingSnapshot, type PreparedWorkingChangeEvidence, type WorkingCheckoutBaseline, type WorkingCheckoutState, type WorkingSnapshotIdentity, } from "./working-evidence.ts";
|
|
9
8
|
export declare const PI_SUBAGENT_PROCESS_LEASE = "PI_SUBAGENT_PROCESS_LEASE";
|
|
10
9
|
export declare const ROLE_MCP_POLICY_FLAG = "pi-subagent-role-mcps";
|
|
10
|
+
/** Read child MCP policy before Pi binds registered extension flag values. */
|
|
11
|
+
export declare function roleMcpAllowlistFromArgv(args: readonly string[]): string[];
|
|
11
12
|
export declare const ROLE_TOOL_POLICY_FLAG = "pi-subagent-role-tools";
|
|
12
|
-
export declare const EXECUTION_BUDGET_FLAG = "pi-subagent-execution-budget";
|
|
13
13
|
export declare const CHILD_EXCLUDED_TOOL_NAMES: readonly ["delegate_task", "ask_question", "subagent_status", "subagent_resume", "subagent_stage", "subagent_abort"];
|
|
14
|
-
export declare const CHILD_EXCLUDED_TOOLS: string;
|
|
15
14
|
export declare const DELEGATE_TASK: {
|
|
16
15
|
readonly id: "pi-subagent/delegateTask";
|
|
17
16
|
readonly label: "Subagent delegation";
|
|
@@ -91,7 +90,7 @@ export declare function prepareRoleLaunch(pi: Pick<ExtensionAPI, "getCommands">,
|
|
|
91
90
|
export declare function prepareRoleLaunch(pi: Pick<ExtensionAPI, "getCommands">, ctx: ExtensionContext, input: CreateRoleLaunchInput): PreparedRoleLaunch;
|
|
92
91
|
/** Resolve and prepare a configured Role with its package-owned resources. */
|
|
93
92
|
export declare function resolveConfiguredRoleLaunch(pi: Pick<ExtensionAPI, "getCommands">, ctx: ExtensionContext, input: ResolveConfiguredRoleLaunchInput): Promise<PreparedRoleLaunch>;
|
|
94
|
-
/**
|
|
93
|
+
/** Insert the system prompt pair after a caller has prepared its launch argv. */
|
|
95
94
|
export declare function finalizeRoleLaunch(prepared: PreparedRoleLaunch): ResolvedRoleLaunch;
|
|
96
95
|
export * from "./composition.ts";
|
|
97
96
|
export * from "./git-runtime.ts";
|
package/dist/index.js
CHANGED
|
@@ -4,22 +4,28 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import { DefaultPackageManager, getAgentDir, parseFrontmatter, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { extensionConfigDir } from "@henryqw/pi-config-store";
|
|
6
6
|
import { hasDisplayControlCharacters } from "./display-text.js";
|
|
7
|
-
import { selectRoleMcpConfig } from "./mcp-role.js";
|
|
7
|
+
import { parseRoleMcpAllowlist, selectRoleMcpConfig } from "./mcp-role.js";
|
|
8
8
|
import { loadTaskModelsConfig, modelReference, orderedProfileRoutes, PROFILE_NAMES, resolveConfiguredTaskRoute, resolveTaskModelRoute, } from "@henryqw/pi-task-models";
|
|
9
9
|
export { DISPLAY_TEXT_CONTRACT, hasDisplayControlCharacters } from "./display-text.js";
|
|
10
|
-
export { parseRoleMcpAllowlist,
|
|
10
|
+
export { parseRoleMcpAllowlist, selectRoleMcpConfig } from "./mcp-role.js";
|
|
11
11
|
export { addUsage, capEphemeralSubagentOutput, createEphemeralSubagentExecutor, DEFAULT_MAX_TURNS, EphemeralSubagentError, EXECUTION_BUDGET_ENV, formatDuration, } from "./ephemeral.js";
|
|
12
|
-
export { createChildWorktree, finalizeChildWorktree, inspectIndexFlags, inspectWorktreeDirty, WorktreeSetupError,
|
|
12
|
+
export { createChildWorktree, finalizeChildWorktree, inspectIndexFlags, inspectWorktreeDirty, WorktreeSetupError, } from "./worktree.js";
|
|
13
13
|
export { prepareExactReviewEvidence, REVIEW_MAX_PATCH_BYTES, REVIEW_MAX_PATHS, } from "./review-evidence.js";
|
|
14
|
-
export { captureWorkingCheckoutBaseline, captureWorkingCheckoutState, prepareWorkingChangeEvidence, sameWorkingCheckoutState, sameWorkingSnapshot, } from "./working-evidence.js";
|
|
15
14
|
const CODEX_ALIAS = /^openai-codex-(?:[2-9]|[1-9]\d+)$/;
|
|
16
15
|
const MULTI_CODEX_EXTENSION = fileURLToPath(import.meta.resolve("@henryqw/pi-multi-codex/extensions/multi-codex.ts"));
|
|
17
16
|
const ROLE_MCP_EXTENSION = fileURLToPath(new URL("../extensions/role-mcp.ts", import.meta.url));
|
|
18
17
|
const ROLE_TOOLS_EXTENSION = fileURLToPath(new URL("../extensions/role-tools.ts", import.meta.url));
|
|
19
18
|
export const PI_SUBAGENT_PROCESS_LEASE = "PI_SUBAGENT_PROCESS_LEASE";
|
|
20
19
|
export const ROLE_MCP_POLICY_FLAG = "pi-subagent-role-mcps";
|
|
20
|
+
/** Read child MCP policy before Pi binds registered extension flag values. */
|
|
21
|
+
export function roleMcpAllowlistFromArgv(args) {
|
|
22
|
+
const flag = `--${ROLE_MCP_POLICY_FLAG}`;
|
|
23
|
+
const indexes = args.flatMap((arg, index) => arg === flag ? [index] : []);
|
|
24
|
+
if (indexes.length !== 1)
|
|
25
|
+
throw new Error(`${flag} must appear exactly once.`);
|
|
26
|
+
return parseRoleMcpAllowlist(args[indexes[0] + 1]);
|
|
27
|
+
}
|
|
21
28
|
export const ROLE_TOOL_POLICY_FLAG = "pi-subagent-role-tools";
|
|
22
|
-
export const EXECUTION_BUDGET_FLAG = "pi-subagent-execution-budget";
|
|
23
29
|
export const CHILD_EXCLUDED_TOOL_NAMES = [
|
|
24
30
|
"delegate_task",
|
|
25
31
|
"ask_question",
|
|
@@ -28,7 +34,6 @@ export const CHILD_EXCLUDED_TOOL_NAMES = [
|
|
|
28
34
|
"subagent_stage",
|
|
29
35
|
"subagent_abort",
|
|
30
36
|
];
|
|
31
|
-
export const CHILD_EXCLUDED_TOOLS = CHILD_EXCLUDED_TOOL_NAMES.join(",");
|
|
32
37
|
const SYSTEM_PROMPT_FLAG = "--append-system-prompt";
|
|
33
38
|
const CHILD_IDENTITY_POLICY = "You are a delegated Pi Subagent, not Main. Execute the assigned Role and task directly. Main-only delegation rules do not apply. Recursive delegation is unavailable; do not seek or invoke delegation tools.";
|
|
34
39
|
export const DELEGATE_TASK = {
|
|
@@ -238,9 +243,10 @@ export async function resolveRolePackageResources(role, ctx) {
|
|
|
238
243
|
};
|
|
239
244
|
}
|
|
240
245
|
export function createRoleLaunch(pi, ctx, input) {
|
|
241
|
-
return
|
|
246
|
+
return finalizeRoleLaunch(prepareRoleLaunchFromSkills(ctx, input, resolveRoleSkills(pi, input.role)));
|
|
242
247
|
}
|
|
243
|
-
|
|
248
|
+
/** Build the prompt-free argv and tool policy; the Role prompt stays separate until `finalizeRoleLaunch`. */
|
|
249
|
+
function prepareRoleLaunchFromSkills(ctx, input, skills) {
|
|
244
250
|
const role = input.role;
|
|
245
251
|
const mcps = mcpList(role.mcps, `Role ${role.name}`);
|
|
246
252
|
const tools = roleToolPolicy(role, input.tools);
|
|
@@ -262,7 +268,7 @@ function createRoleLaunchFromSkills(ctx, input, skills) {
|
|
|
262
268
|
throw new Error(`Invalid launch environment value: ${key}`);
|
|
263
269
|
return [key, value];
|
|
264
270
|
}));
|
|
265
|
-
const args = ["--no-session", "--no-extensions", "--no-skills", "--exclude-tools",
|
|
271
|
+
const args = ["--no-session", "--no-extensions", "--no-skills", "--exclude-tools", CHILD_EXCLUDED_TOOL_NAMES.join(",")];
|
|
266
272
|
if (mcps.length)
|
|
267
273
|
args.push(`--${ROLE_MCP_POLICY_FLAG}`, JSON.stringify(mcps));
|
|
268
274
|
for (const extension of new Set(extensions))
|
|
@@ -274,46 +280,30 @@ function createRoleLaunchFromSkills(ctx, input, skills) {
|
|
|
274
280
|
if (input.route.thinkingLevel)
|
|
275
281
|
args.push("--thinking", input.route.thinkingLevel);
|
|
276
282
|
args.push(ctx.isProjectTrusted() ? "--approve" : "--no-approve");
|
|
277
|
-
args.push(SYSTEM_PROMPT_FLAG, `${CHILD_IDENTITY_POLICY}\n\n${cleanText(role.systemPrompt, "system prompt", `Role ${role.name}`)}`);
|
|
278
283
|
return {
|
|
279
284
|
env,
|
|
280
285
|
args,
|
|
281
286
|
model: input.route.model,
|
|
282
287
|
thinkingLevel: input.route.thinkingLevel,
|
|
283
288
|
missingSkills: skills.missing,
|
|
289
|
+
role: role.name,
|
|
290
|
+
tools: Object.freeze(tools),
|
|
291
|
+
systemPrompt: `${CHILD_IDENTITY_POLICY}\n\n${cleanText(role.systemPrompt, "system prompt", `Role ${role.name}`)}`,
|
|
292
|
+
promptArgIndex: args.length,
|
|
284
293
|
};
|
|
285
294
|
}
|
|
286
|
-
|
|
295
|
+
function prepareResolvedRoleLaunch(pi, ctx, input) {
|
|
287
296
|
const { task, modelClass, agentDir, ...launchInput } = input;
|
|
288
297
|
const selectedClass = modelClass ?? input.role.modelClass;
|
|
289
|
-
return
|
|
298
|
+
return prepareRoleLaunchFromSkills(ctx, {
|
|
290
299
|
...launchInput,
|
|
291
300
|
route: selectedClass === undefined
|
|
292
301
|
? resolveConfiguredTaskRoute(ctx, task, agentDir)
|
|
293
302
|
: resolveTaskRoute(ctx, selectedClass, agentDir),
|
|
294
|
-
});
|
|
303
|
+
}, resolveRoleSkills(pi, input.role));
|
|
295
304
|
}
|
|
296
|
-
function
|
|
297
|
-
|
|
298
|
-
if (indexes.length !== 1)
|
|
299
|
-
throw new Error(`Role launch must contain exactly one ${SYSTEM_PROMPT_FLAG} pair.`);
|
|
300
|
-
const promptArgIndex = indexes[0];
|
|
301
|
-
const systemPrompt = rawArgs[promptArgIndex + 1];
|
|
302
|
-
if (typeof systemPrompt !== "string" || !systemPrompt.includes("\n") || !systemPrompt.trim() || systemPrompt.includes("\0")) {
|
|
303
|
-
throw new Error(`Role ${SYSTEM_PROMPT_FLAG} value must be the exact multiline Role prompt.`);
|
|
304
|
-
}
|
|
305
|
-
const args = [...rawArgs.slice(0, promptArgIndex), ...rawArgs.slice(promptArgIndex + 2)];
|
|
306
|
-
if (args.includes(SYSTEM_PROMPT_FLAG) || args.includes(systemPrompt)) {
|
|
307
|
-
throw new Error("Sanitized Role argv must contain no prompt flag or raw Role prompt.");
|
|
308
|
-
}
|
|
309
|
-
return { args, systemPrompt, promptArgIndex };
|
|
310
|
-
}
|
|
311
|
-
function prepareResolvedRoleLaunch(roleDefinition, launch, additionalTools = []) {
|
|
312
|
-
const role = parseRoleName(roleDefinition.name);
|
|
313
|
-
rejectRetiredRoleIsolation(roleDefinition.isolation, `Role ${role}`);
|
|
314
|
-
const tools = Object.freeze(roleToolPolicy(roleDefinition, additionalTools));
|
|
315
|
-
const { args, systemPrompt, promptArgIndex } = stripRoleSystemPrompt(launch.args);
|
|
316
|
-
return { ...launch, args, role, tools, systemPrompt, promptArgIndex };
|
|
305
|
+
export function resolveRoleLaunch(pi, ctx, input) {
|
|
306
|
+
return finalizeRoleLaunch(prepareResolvedRoleLaunch(pi, ctx, input));
|
|
317
307
|
}
|
|
318
308
|
function assertNoMissingRoleSkills(role, launch) {
|
|
319
309
|
if (!launch.missingSkills.length)
|
|
@@ -321,11 +311,13 @@ function assertNoMissingRoleSkills(role, launch) {
|
|
|
321
311
|
throw new Error(`Role ${parseRoleName(role.name)} requires missing Skills: ${launch.missingSkills.join(", ")}.`);
|
|
322
312
|
}
|
|
323
313
|
export function prepareRoleLaunch(pi, ctx, input) {
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
314
|
+
const role = parseRoleName(input.role.name);
|
|
315
|
+
rejectRetiredRoleIsolation(input.role.isolation, `Role ${role}`);
|
|
316
|
+
const prepared = "route" in input
|
|
317
|
+
? prepareRoleLaunchFromSkills(ctx, input, resolveRoleSkills(pi, input.role))
|
|
318
|
+
: prepareResolvedRoleLaunch(pi, ctx, input);
|
|
319
|
+
assertNoMissingRoleSkills(input.role, prepared);
|
|
320
|
+
return { ...prepared, role };
|
|
329
321
|
}
|
|
330
322
|
/** Resolve and prepare a configured Role with its package-owned resources. */
|
|
331
323
|
export async function resolveConfiguredRoleLaunch(pi, ctx, input) {
|
|
@@ -347,36 +339,22 @@ export async function resolveConfiguredRoleLaunch(pi, ctx, input) {
|
|
|
347
339
|
...namedSkills,
|
|
348
340
|
paths: [...new Set([...namedSkills.paths, ...resources.skills])],
|
|
349
341
|
};
|
|
350
|
-
const
|
|
342
|
+
const prepared = prepareRoleLaunchFromSkills(ctx, {
|
|
351
343
|
role: effectiveRole,
|
|
352
344
|
route: resolveTaskRoute(ctx, input.modelClass),
|
|
353
345
|
}, skills);
|
|
354
|
-
assertNoMissingRoleSkills(effectiveRole,
|
|
355
|
-
const
|
|
346
|
+
assertNoMissingRoleSkills(effectiveRole, prepared);
|
|
347
|
+
const args = [
|
|
348
|
+
...prepared.args,
|
|
356
349
|
"--no-prompt-templates",
|
|
357
350
|
"--no-themes",
|
|
358
351
|
...resources.prompts.flatMap((path) => ["--prompt-template", path]),
|
|
359
352
|
...resources.themes.flatMap((path) => ["--theme", path]),
|
|
360
353
|
];
|
|
361
|
-
|
|
362
|
-
if (promptArgIndex < 0)
|
|
363
|
-
throw new Error(`Resolved Role launch has no ${SYSTEM_PROMPT_FLAG}.`);
|
|
364
|
-
const args = [...launch.args];
|
|
365
|
-
args.splice(promptArgIndex, 0, ...additions);
|
|
366
|
-
return prepareResolvedRoleLaunch(effectiveRole, { ...launch, args });
|
|
354
|
+
return { ...prepared, args, promptArgIndex: args.length };
|
|
367
355
|
}
|
|
368
|
-
/**
|
|
356
|
+
/** Insert the system prompt pair after a caller has prepared its launch argv. */
|
|
369
357
|
export function finalizeRoleLaunch(prepared) {
|
|
370
|
-
if (prepared.args.includes(SYSTEM_PROMPT_FLAG)) {
|
|
371
|
-
throw new Error(`Prepared Role launch already contains ${SYSTEM_PROMPT_FLAG}.`);
|
|
372
|
-
}
|
|
373
|
-
if (!Number.isSafeInteger(prepared.promptArgIndex)
|
|
374
|
-
|| prepared.promptArgIndex < 0 || prepared.promptArgIndex > prepared.args.length) {
|
|
375
|
-
throw new Error("Prepared Role launch has an invalid system prompt insertion index.");
|
|
376
|
-
}
|
|
377
|
-
if (typeof prepared.systemPrompt !== "string" || !prepared.systemPrompt.trim() || prepared.systemPrompt.includes("\0")) {
|
|
378
|
-
throw new Error("Prepared Role launch has an invalid system prompt.");
|
|
379
|
-
}
|
|
380
358
|
const args = [...prepared.args];
|
|
381
359
|
args.splice(prepared.promptArgIndex, 0, SYSTEM_PROMPT_FLAG, prepared.systemPrompt);
|
|
382
360
|
return {
|
package/dist/launch-runtime.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { EphemeralSubagentExecutionBudget } from "./ephemeral.ts";
|
|
3
2
|
import type { CoordinatorRuntime, OperationContext, TransientLaunchHandle, VerifiedLaunch } from "./runner.ts";
|
|
4
3
|
import { type ExecuteRequest, type ModelClass, type WorkspaceIdentity } from "./schema.ts";
|
|
5
4
|
export declare const ISOLATED_MODEL_TASK: {
|
|
@@ -21,7 +20,6 @@ export interface LaunchRuntimeOptions {
|
|
|
21
20
|
inspectMainBase(input: {
|
|
22
21
|
root: string;
|
|
23
22
|
}, context: OperationContext): Promise<WorkspaceIdentity>;
|
|
24
|
-
executionBudget?: () => Omit<EphemeralSubagentExecutionBudget, "startedAt">;
|
|
25
23
|
now?: () => number;
|
|
26
24
|
randomToken?: () => string;
|
|
27
25
|
}
|
package/dist/launch-runtime.js
CHANGED
|
@@ -3,7 +3,7 @@ import { constants } from "node:fs";
|
|
|
3
3
|
import { chmod, lstat, mkdtemp, open, realpath, rmdir, unlink } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { isAbsolute, join, normalize, relative, sep } from "node:path";
|
|
6
|
-
import {
|
|
6
|
+
import { resolveConfiguredRoleLaunch } from "./index.js";
|
|
7
7
|
import { registerModelTask } from "@henryqw/pi-task-models";
|
|
8
8
|
const PROMPT_FLAG = "--append-system-prompt";
|
|
9
9
|
const DIRECTORY_MODE = 0o700;
|
|
@@ -130,20 +130,13 @@ export class RoleLaunchRuntime {
|
|
|
130
130
|
if (prepared.missingSkills.length) {
|
|
131
131
|
throw new Error(`Role ${role} requires missing Skills: ${prepared.missingSkills.join(", ")}.`);
|
|
132
132
|
}
|
|
133
|
-
const executionBudget = this.options.executionBudget?.();
|
|
134
133
|
return Object.freeze({
|
|
135
134
|
launch: Object.freeze({
|
|
136
135
|
role: prepared.role,
|
|
137
136
|
modelClass,
|
|
138
137
|
model: `${prepared.model.provider}/${prepared.model.id}`,
|
|
139
138
|
thinkingLevel: prepared.thinkingLevel,
|
|
140
|
-
args: Object.freeze([
|
|
141
|
-
...prepared.args,
|
|
142
|
-
...(executionBudget === undefined ? [] : [
|
|
143
|
-
`--${EXECUTION_BUDGET_FLAG}`,
|
|
144
|
-
JSON.stringify({ ...executionBudget, startedAt: this.now() }),
|
|
145
|
-
]),
|
|
146
|
-
]),
|
|
139
|
+
args: Object.freeze([...prepared.args]),
|
|
147
140
|
env: Object.freeze({ ...prepared.env }),
|
|
148
141
|
tools: Object.freeze([...prepared.tools]),
|
|
149
142
|
}),
|
package/dist/mcp-role.d.ts
CHANGED
|
@@ -3,5 +3,4 @@ export interface RoleMcpConfig {
|
|
|
3
3
|
settings?: Record<string, unknown>;
|
|
4
4
|
}
|
|
5
5
|
export declare function parseRoleMcpAllowlist(value: unknown): string[];
|
|
6
|
-
export declare function roleMcpFlagValue(args: readonly string[], flag: string): string | undefined;
|
|
7
6
|
export declare function selectRoleMcpConfig(config: RoleMcpConfig, allowlist: readonly string[]): RoleMcpConfig;
|
package/dist/mcp-role.js
CHANGED
|
@@ -17,17 +17,6 @@ export function parseRoleMcpAllowlist(value) {
|
|
|
17
17
|
throw new Error("The Role MCP policy flag contains duplicate MCP server names.");
|
|
18
18
|
return names;
|
|
19
19
|
}
|
|
20
|
-
export function roleMcpFlagValue(args, flag) {
|
|
21
|
-
const indexes = args.flatMap((arg, index) => arg === flag ? [index] : []);
|
|
22
|
-
if (indexes.length > 1)
|
|
23
|
-
throw new Error(`${flag} must appear at most once.`);
|
|
24
|
-
if (!indexes.length)
|
|
25
|
-
return;
|
|
26
|
-
const value = args[indexes[0] + 1];
|
|
27
|
-
if (value === undefined)
|
|
28
|
-
throw new Error(`${flag} requires a value.`);
|
|
29
|
-
return value;
|
|
30
|
-
}
|
|
31
20
|
export function selectRoleMcpConfig(config, allowlist) {
|
|
32
21
|
const missing = allowlist.filter((name) => !Object.hasOwn(config.mcpServers, name));
|
|
33
22
|
if (missing.length)
|
package/dist/runner.js
CHANGED
|
@@ -69,7 +69,7 @@ class DurableRunStopped extends Error {
|
|
|
69
69
|
}
|
|
70
70
|
class ProductiveScope {
|
|
71
71
|
signal;
|
|
72
|
-
constructor(
|
|
72
|
+
constructor(outerSignal) {
|
|
73
73
|
this.signal = outerSignal ?? new AbortController().signal;
|
|
74
74
|
}
|
|
75
75
|
async call(operation) {
|
|
@@ -150,10 +150,6 @@ class FollowupControl {
|
|
|
150
150
|
this.sealed = true;
|
|
151
151
|
return undefined;
|
|
152
152
|
}
|
|
153
|
-
invalidate() {
|
|
154
|
-
this.sealed = true;
|
|
155
|
-
this.queue.length = 0;
|
|
156
|
-
}
|
|
157
153
|
close() {
|
|
158
154
|
this.sealed = true;
|
|
159
155
|
this.queue.length = 0;
|
|
@@ -662,7 +658,7 @@ export class IsolatedRunner {
|
|
|
662
658
|
async execute(value, cwd, outerSignal) {
|
|
663
659
|
const request = parseExecuteRequest(value);
|
|
664
660
|
const policy = Object.freeze({ ...this.currentPolicy() });
|
|
665
|
-
const scope = new ProductiveScope(
|
|
661
|
+
const scope = new ProductiveScope(outerSignal);
|
|
666
662
|
const canonicalCwd = realpathSync.native(cwd);
|
|
667
663
|
const prepared = await scope.call(async (context) => await this.coordinatorRuntime.preflight({ request, cwd: canonicalCwd }, context));
|
|
668
664
|
const root = realpathSync.native(prepared.root);
|
|
@@ -748,8 +744,7 @@ export class IsolatedRunner {
|
|
|
748
744
|
return loaded;
|
|
749
745
|
}, { productiveRunLease: lifecycle.lease });
|
|
750
746
|
const state = handle.state;
|
|
751
|
-
const
|
|
752
|
-
const scope = new ProductiveScope(Math.min(state.policy.childMaxMs, current.childMaxMs), () => this.coordinatorRuntime.now(), outerSignal);
|
|
747
|
+
const scope = new ProductiveScope(outerSignal);
|
|
753
748
|
try {
|
|
754
749
|
if (request.action === "finalize")
|
|
755
750
|
return await this.finalize(handle, scope);
|
|
@@ -782,8 +777,6 @@ export class IsolatedRunner {
|
|
|
782
777
|
throw error;
|
|
783
778
|
}
|
|
784
779
|
finally {
|
|
785
|
-
if (scope instanceof DeadlineScope)
|
|
786
|
-
scope.close();
|
|
787
780
|
this.closeRequestControls(root, request.id);
|
|
788
781
|
if (!lifecycle.stopped) {
|
|
789
782
|
delete state.recovery;
|
|
@@ -820,7 +813,7 @@ export class IsolatedRunner {
|
|
|
820
813
|
? generation.combinedTip ?? generation.stages.at(-1)?.tip ?? generation.integrationBase : state.main;
|
|
821
814
|
if (!sameIdentity(action.expectedTip, expected))
|
|
822
815
|
throw new Error("Rejection recovery has a stale integration tip.");
|
|
823
|
-
const scope = new ProductiveScope(
|
|
816
|
+
const scope = new ProductiveScope(outerSignal);
|
|
824
817
|
const termination = attempt.termination;
|
|
825
818
|
const result = await scope.call((context) => this.hostRuntime.reconcileWorkerTermination({
|
|
826
819
|
task: changesetTaskRequest(state, task.taskId), attempt, workerId: termination.workerId,
|
|
@@ -841,7 +834,7 @@ export class IsolatedRunner {
|
|
|
841
834
|
|| !sameIdentity(attempt.readiness.candidate, action.candidate)) {
|
|
842
835
|
throw new Error("Stage action refers to a stale or unowned candidate.");
|
|
843
836
|
}
|
|
844
|
-
const scope = new ProductiveScope(
|
|
837
|
+
const scope = new ProductiveScope(outerSignal);
|
|
845
838
|
const generations = state.integration.generations;
|
|
846
839
|
let generation = generations.at(-1);
|
|
847
840
|
const nextNumber = generation?.status === "superseded" ? generation.number + 1 : generation?.number ?? 1;
|
|
@@ -1100,7 +1093,7 @@ export class IsolatedRunner {
|
|
|
1100
1093
|
const request = parseExecuteRequest(state.request);
|
|
1101
1094
|
const generation = state.integration.generations.at(-1);
|
|
1102
1095
|
if (action.action === "release") {
|
|
1103
|
-
const scope = new ProductiveScope(
|
|
1096
|
+
const scope = new ProductiveScope(outerSignal);
|
|
1104
1097
|
if (action.taskId !== undefined || action.attempt !== undefined) {
|
|
1105
1098
|
if (!action.taskId || !action.attempt || action.generation !== (generation?.number ?? 1)) {
|
|
1106
1099
|
throw new Error("Candidate release requires the latest generation and exact task attempt.");
|
|
@@ -1149,7 +1142,7 @@ export class IsolatedRunner {
|
|
|
1149
1142
|
if (state.integration.generations.filter((item) => item.worktree && !item.cleanup?.every((step) => step.status === "completed")).length >= MAX_RETAINED_INTEGRATION_GENERATIONS) {
|
|
1150
1143
|
throw new Error("Retained integration worktree limit is exhausted.");
|
|
1151
1144
|
}
|
|
1152
|
-
const scope = new ProductiveScope(
|
|
1145
|
+
const scope = new ProductiveScope(outerSignal);
|
|
1153
1146
|
const actualMain = await scope.call((context) => this.gitRuntime.inspectMain({ root }, context));
|
|
1154
1147
|
if (!isCleanCommitted(action.newMain) || !sameIdentity(actualMain, action.newMain)) {
|
|
1155
1148
|
throw new Error("Refresh requires the exact new clean Main identity.");
|
|
@@ -1233,7 +1226,7 @@ export class IsolatedRunner {
|
|
|
1233
1226
|
|| (terminal(state) && !(state.status === "completed" && action.action === "cleanup"))
|
|
1234
1227
|
|| (state.status !== "needs_attention" && !(state.status === "completed" && action.action === "cleanup")))
|
|
1235
1228
|
throw new Error("Integration action has a stale generation or combined tip.");
|
|
1236
|
-
const scope = new ProductiveScope(
|
|
1229
|
+
const scope = new ProductiveScope(outerSignal);
|
|
1237
1230
|
const receipts = this.stageReceipts(generation);
|
|
1238
1231
|
if (action.action === "advance") {
|
|
1239
1232
|
if (generation.status !== "staging" || generation.stages.some((stage) => stage.status !== "staged")
|
|
@@ -1603,7 +1596,7 @@ export class IsolatedRunner {
|
|
|
1603
1596
|
}
|
|
1604
1597
|
const activeControls = this.activeControls(root, id);
|
|
1605
1598
|
for (const { control } of activeControls)
|
|
1606
|
-
control.
|
|
1599
|
+
control.close();
|
|
1607
1600
|
if (!activeControls.length
|
|
1608
1601
|
&& !lifecycle.productiveRunLeaseActive
|
|
1609
1602
|
&& this.recoverInterrupted(state)) {
|
package/dist/schema.d.ts
CHANGED
|
@@ -139,14 +139,7 @@ type ExecuteRequestInput = Static<typeof ExecuteRequestSchema>;
|
|
|
139
139
|
export type ExecuteRequest = Omit<ExecuteRequestInput, "finalChecks"> & {
|
|
140
140
|
finalChecks: CheckCommand[];
|
|
141
141
|
};
|
|
142
|
-
export
|
|
143
|
-
maxSubagents: number;
|
|
144
|
-
maxTurns: number;
|
|
145
|
-
maxTokens?: number;
|
|
146
|
-
childIdleMs: number;
|
|
147
|
-
childMaxMs: number;
|
|
148
|
-
maxCorrections: number;
|
|
149
|
-
}
|
|
142
|
+
export type ExecutionPolicySnapshot = Static<typeof ExecutionPolicySnapshotSchema>;
|
|
150
143
|
export type ResumeRequest = Static<typeof ResumeRequestSchema>;
|
|
151
144
|
export type WorkspaceIdentity = Static<typeof WorkspaceSchema>;
|
|
152
145
|
declare const WorktreeRecordSchema: Type.TObject<{
|
|
@@ -1083,6 +1076,14 @@ declare const WaveStateSchema: Type.TObject<{
|
|
|
1083
1076
|
taskIds: Type.TArray<Type.TString>;
|
|
1084
1077
|
status: Type.TUnion<[Type.TLiteral<"dispatching">, Type.TLiteral<"integrating">, Type.TLiteral<"completed">, Type.TLiteral<"needs_attention">]>;
|
|
1085
1078
|
}>;
|
|
1079
|
+
declare const ExecutionPolicySnapshotSchema: Type.TObject<{
|
|
1080
|
+
maxSubagents: Type.TInteger;
|
|
1081
|
+
maxTurns: Type.TInteger;
|
|
1082
|
+
maxTokens: Type.TOptional<Type.TInteger>;
|
|
1083
|
+
childIdleMs: Type.TNumber;
|
|
1084
|
+
childMaxMs: Type.TNumber;
|
|
1085
|
+
maxCorrections: Type.TInteger;
|
|
1086
|
+
}>;
|
|
1086
1087
|
declare const IntegrationCandidateSchema: Type.TObject<{
|
|
1087
1088
|
taskId: Type.TString;
|
|
1088
1089
|
attempt: Type.TInteger;
|
package/dist/worktree.d.ts
CHANGED
|
@@ -57,5 +57,3 @@ export declare function inspectWorktreeDirty(cwd: string, run?: GitRunner, inclu
|
|
|
57
57
|
* recorded; any uncertainty preserves work and omits unknown measurements.
|
|
58
58
|
*/
|
|
59
59
|
export declare function finalizeChildWorktree(info: WorktreeInfo, run?: GitRunner): Promise<WorktreePayload>;
|
|
60
|
-
/** Context block telling the child to work inside its isolated worktree. */
|
|
61
|
-
export declare function worktreeContextNote(info: WorktreeInfo): string;
|
package/dist/worktree.js
CHANGED
|
@@ -269,7 +269,3 @@ export async function finalizeChildWorktree(info, run = runGit) {
|
|
|
269
269
|
}
|
|
270
270
|
return { outcome: "pruned", path: info.path, branch: info.branch };
|
|
271
271
|
}
|
|
272
|
-
/** Context block telling the child to work inside its isolated worktree. */
|
|
273
|
-
export function worktreeContextNote(info) {
|
|
274
|
-
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.`;
|
|
275
|
-
}
|
package/extensions/admission.ts
CHANGED
|
@@ -14,10 +14,6 @@ export function roleIsReadOnlyScout(role: Role): boolean {
|
|
|
14
14
|
return !roleCanWrite(role) && role.extensions.length === 0 && !role.mcps?.length;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export interface CheckoutAdmission {
|
|
18
|
-
register(pi: ExtensionAPI, directCanWrite: (input: unknown) => boolean): void;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
17
|
async function checkoutKey(pi: ExtensionAPI, ctx: ExtensionContext): Promise<string> {
|
|
22
18
|
const cwd = await realpath(ctx.cwd);
|
|
23
19
|
const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd, timeout: 5_000 });
|
|
@@ -32,7 +28,7 @@ async function checkoutKey(pi: ExtensionAPI, ctx: ExtensionContext): Promise<str
|
|
|
32
28
|
* not an OS sandbox: external processes and trusted extension lifecycle code
|
|
33
29
|
* remain outside interception.
|
|
34
30
|
*/
|
|
35
|
-
export function
|
|
31
|
+
export function registerCheckoutAdmission(pi: ExtensionAPI, directCanWrite: (input: unknown) => boolean): void {
|
|
36
32
|
const ownerByCheckout = new Map<string, string>();
|
|
37
33
|
const checkoutByCall = new Map<string, string>();
|
|
38
34
|
const release = (toolCallId: string) => {
|
|
@@ -41,30 +37,26 @@ export function createCheckoutAdmission(): CheckoutAdmission {
|
|
|
41
37
|
checkoutByCall.delete(toolCallId);
|
|
42
38
|
if (ownerByCheckout.get(key) === toolCallId) ownerByCheckout.delete(key);
|
|
43
39
|
};
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
checkoutByCall.clear();
|
|
67
|
-
});
|
|
68
|
-
},
|
|
69
|
-
};
|
|
40
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
41
|
+
const potentiallyWriting = event.toolName === "delegate_task"
|
|
42
|
+
? directCanWrite(event.input)
|
|
43
|
+
: !READ_ONLY_TOOLS.has(event.toolName);
|
|
44
|
+
if (!potentiallyWriting) return;
|
|
45
|
+
const key = await checkoutKey(pi, ctx);
|
|
46
|
+
const owner = ownerByCheckout.get(key);
|
|
47
|
+
if (owner && owner !== event.toolCallId) {
|
|
48
|
+
return {
|
|
49
|
+
block: true,
|
|
50
|
+
reason: `Checkout ${key} already has an admitted Pi writer (${owner}); retry after it settles.`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
ownerByCheckout.set(key, event.toolCallId);
|
|
54
|
+
checkoutByCall.set(event.toolCallId, key);
|
|
55
|
+
});
|
|
56
|
+
pi.on("tool_result", (event) => release(event.toolCallId));
|
|
57
|
+
pi.on("tool_execution_end", (event) => release(event.toolCallId));
|
|
58
|
+
pi.on("session_shutdown", () => {
|
|
59
|
+
ownerByCheckout.clear();
|
|
60
|
+
checkoutByCall.clear();
|
|
61
|
+
});
|
|
70
62
|
}
|
package/extensions/config.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { createConfigStore, extensionConfigPath } from "@henryqw/pi-config-store";
|
|
3
|
+
import type { ExecutionPolicySnapshot } from "../dist/schema.js";
|
|
3
4
|
|
|
4
5
|
export interface SubagentTimeoutConfig {
|
|
5
6
|
/** Minutes a child may stay idle before it is asked to stop. */
|
|
@@ -23,14 +24,7 @@ export interface LoadedSubagentConfig {
|
|
|
23
24
|
error?: string;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
export
|
|
27
|
-
maxSubagents: number;
|
|
28
|
-
maxTurns: number;
|
|
29
|
-
maxTokens?: number;
|
|
30
|
-
childIdleMs: number;
|
|
31
|
-
childMaxMs: number;
|
|
32
|
-
maxCorrections: number;
|
|
33
|
-
}
|
|
27
|
+
export type EffectiveExecutionPolicy = ExecutionPolicySnapshot;
|
|
34
28
|
|
|
35
29
|
type ParsedSubagentConfig = Omit<LoadedSubagentConfig, "source">;
|
|
36
30
|
|
package/extensions/isolated.ts
CHANGED
|
@@ -77,7 +77,6 @@ export const createIsolatedComponents: CreateIsolatedComponents = ({
|
|
|
77
77
|
resolveRoot,
|
|
78
78
|
preflightHost: async ({ root }, operation) => await host.preflightHost({ root }, operation),
|
|
79
79
|
inspectMainBase: async (input, operation) => await git.inspectMainBase(input, operation),
|
|
80
|
-
executionBudget,
|
|
81
80
|
});
|
|
82
81
|
return {
|
|
83
82
|
resolveRoot,
|
|
@@ -5,31 +5,21 @@ const EVIDENCE_PREVIEW_CODE_POINTS = 256;
|
|
|
5
5
|
|
|
6
6
|
export type WorkflowTransportStatus = "pending" | "running" | "succeeded" | "rejected" | "skipped";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/** `assistantOutput` is read for running/succeeded entries and `failure` for rejected ones. */
|
|
9
|
+
export type WorkflowTransportEntry = {
|
|
9
10
|
id: WorkflowEntry["id"];
|
|
10
11
|
index: WorkflowEntry["index"];
|
|
11
12
|
name: WorkflowEntry["delegation"]["name"];
|
|
12
13
|
role: WorkflowEntry["delegation"]["role"];
|
|
13
14
|
model?: string;
|
|
14
15
|
thinkingLevel?: string;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
export type WorkflowTransportEntry =
|
|
18
|
-
| TransportEntryBase & { status: "pending" | "skipped"; assistantOutput?: never; failure?: never }
|
|
19
|
-
| TransportEntryBase & { status: "running" | "succeeded"; assistantOutput: string; failure?: never }
|
|
20
|
-
| TransportEntryBase & { status: "rejected"; assistantOutput?: never; failure: string };
|
|
21
|
-
|
|
22
|
-
export type WorkflowTransportEntryDetails = {
|
|
23
|
-
id: string;
|
|
24
|
-
index: number;
|
|
25
|
-
name: string;
|
|
26
|
-
role: string;
|
|
27
16
|
status: WorkflowTransportStatus;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
thinkingLevel?: string;
|
|
17
|
+
assistantOutput?: string;
|
|
18
|
+
failure?: string;
|
|
31
19
|
};
|
|
32
20
|
|
|
21
|
+
export type WorkflowTransportEntryDetails = Omit<WorkflowTransportEntry, "assistantOutput" | "failure"> & { summary?: string };
|
|
22
|
+
|
|
33
23
|
export type WorkflowTransportDetails = {
|
|
34
24
|
mode: WorkflowMode;
|
|
35
25
|
entries: WorkflowTransportEntryDetails[];
|
|
@@ -61,15 +51,12 @@ function workflowTitle(mode: WorkflowMode): string {
|
|
|
61
51
|
}
|
|
62
52
|
|
|
63
53
|
function statusCounts(entries: readonly WorkflowTransportEntry[]): string[] {
|
|
64
|
-
const count = (
|
|
65
|
-
return
|
|
66
|
-
[
|
|
67
|
-
[
|
|
68
|
-
|
|
69
|
-
]
|
|
70
|
-
const total = count([...statuses]);
|
|
71
|
-
return total ? [`${total} ${word}`] : [];
|
|
72
|
-
});
|
|
54
|
+
const count = (status: WorkflowTransportStatus) => entries.filter((entry) => entry.status === status).length;
|
|
55
|
+
return [
|
|
56
|
+
...(count("rejected") ? [`${count("rejected")} failed`] : []),
|
|
57
|
+
...(count("succeeded") ? [`${count("succeeded")} completed`] : []),
|
|
58
|
+
...(count("skipped") ? [`${count("skipped")} skipped`] : []),
|
|
59
|
+
];
|
|
73
60
|
}
|
|
74
61
|
|
|
75
62
|
export function displaySummary(text: string): string {
|
|
@@ -80,17 +67,13 @@ export function displaySummary(text: string): string {
|
|
|
80
67
|
|
|
81
68
|
export type WorkflowEntryStatusPresentation = { glyph: string; fallback: string };
|
|
82
69
|
|
|
83
|
-
const ENTRY_STATUS_PRESENTATION = {
|
|
70
|
+
export const ENTRY_STATUS_PRESENTATION = {
|
|
84
71
|
pending: { glyph: "○", fallback: "queued" },
|
|
85
72
|
running: { glyph: "◌", fallback: "working" },
|
|
86
73
|
succeeded: { glyph: "✓", fallback: "completed" },
|
|
87
74
|
rejected: { glyph: "✗", fallback: "failed" },
|
|
88
75
|
skipped: { glyph: "–", fallback: "skipped" },
|
|
89
|
-
} as const satisfies Record<
|
|
90
|
-
|
|
91
|
-
export function presentWorkflowEntryStatus(status: WorkflowTransportEntryDetails["status"]): WorkflowEntryStatusPresentation {
|
|
92
|
-
return ENTRY_STATUS_PRESENTATION[status];
|
|
93
|
-
}
|
|
76
|
+
} as const satisfies Record<WorkflowTransportStatus, WorkflowEntryStatusPresentation>;
|
|
94
77
|
|
|
95
78
|
function sourceFor(entry: WorkflowTransportEntry): string | undefined {
|
|
96
79
|
if (entry.status === "rejected") return entry.failure;
|
|
@@ -122,7 +105,7 @@ export function formatWorkflowResult(mode: WorkflowMode, entries: readonly Workf
|
|
|
122
105
|
const lines = [
|
|
123
106
|
[`${workflowTitle(mode)} ${failed ? "failed" : "completed"}`, ...statusCounts(ordered)].join(" · "),
|
|
124
107
|
...positioned.map(({ entry, position }) => {
|
|
125
|
-
const { glyph, fallback } =
|
|
108
|
+
const { glyph, fallback } = ENTRY_STATUS_PRESENTATION[entry.status];
|
|
126
109
|
const summary = displaySummary(sourceFor(entry) ?? "") || fallback;
|
|
127
110
|
return `${glyph} [${position}/${ordered.length}] ${entry.name} · ${entry.role} — ${summary}`;
|
|
128
111
|
}),
|
package/extensions/role-mcp.ts
CHANGED
|
@@ -2,19 +2,15 @@ import { join } from "node:path";
|
|
|
2
2
|
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { createMcpAdapter } from "pi-mcp-adapter";
|
|
4
4
|
import { loadMcpConfig } from "pi-mcp-adapter/config";
|
|
5
|
-
import {
|
|
6
|
-
parseRoleMcpAllowlist,
|
|
7
|
-
ROLE_MCP_POLICY_FLAG,
|
|
8
|
-
roleMcpFlagValue,
|
|
9
|
-
selectRoleMcpConfig,
|
|
10
|
-
} from "@henryqw/pi-subagent";
|
|
5
|
+
import { roleMcpAllowlistFromArgv, ROLE_MCP_POLICY_FLAG, selectRoleMcpConfig } from "@henryqw/pi-subagent";
|
|
11
6
|
|
|
12
7
|
export default function roleMcp(pi: ExtensionAPI): void {
|
|
13
8
|
pi.registerFlag(ROLE_MCP_POLICY_FLAG, {
|
|
14
9
|
description: "Internal Pi Subagent Role MCP policy",
|
|
15
10
|
type: "string",
|
|
16
11
|
});
|
|
17
|
-
|
|
12
|
+
// Pi binds extension flag values after factories load.
|
|
13
|
+
const allowlist = roleMcpAllowlistFromArgv(process.argv);
|
|
18
14
|
const config = selectRoleMcpConfig(loadMcpConfig(join(getAgentDir(), "mcp.json"), process.cwd()), allowlist);
|
|
19
15
|
createMcpAdapter({ config })(pi);
|
|
20
16
|
}
|
package/extensions/role-tools.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { isAbsolute } from "node:path";
|
|
|
3
3
|
import { isToolCallEventType, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import {
|
|
5
5
|
CHILD_EXCLUDED_TOOL_NAMES,
|
|
6
|
-
EXECUTION_BUDGET_FLAG,
|
|
7
6
|
EXECUTION_BUDGET_ENV,
|
|
8
7
|
PI_SUBAGENT_PROCESS_LEASE,
|
|
9
8
|
ROLE_TOOL_POLICY_FLAG,
|
|
@@ -48,9 +47,8 @@ function configuredTools(value: unknown): string[] {
|
|
|
48
47
|
return [...new Set(parsed.map((name) => name.trim()))];
|
|
49
48
|
}
|
|
50
49
|
|
|
51
|
-
function executionBudget(value: string |
|
|
50
|
+
function executionBudget(value: string | undefined): EphemeralSubagentExecutionBudget | undefined {
|
|
52
51
|
if (value === undefined) return;
|
|
53
|
-
if (typeof value !== "string") throw new Error(`${EXECUTION_BUDGET_ENV} must be a JSON execution budget.`);
|
|
54
52
|
let parsed: unknown;
|
|
55
53
|
try {
|
|
56
54
|
parsed = JSON.parse(value);
|
|
@@ -106,11 +104,7 @@ export default function roleTools(pi: ExtensionAPI): void {
|
|
|
106
104
|
description: "Internal Pi Subagent Role tool policy",
|
|
107
105
|
type: "string",
|
|
108
106
|
});
|
|
109
|
-
|
|
110
|
-
description: "Internal Pi Subagent execution budget",
|
|
111
|
-
type: "string",
|
|
112
|
-
});
|
|
113
|
-
const budget = executionBudget(pi.getFlag(EXECUTION_BUDGET_FLAG) ?? process.env[EXECUTION_BUDGET_ENV]);
|
|
107
|
+
const budget = executionBudget(process.env[EXECUTION_BUDGET_ENV]);
|
|
114
108
|
let handoffSent = false;
|
|
115
109
|
pi.on("session_start", () => {
|
|
116
110
|
const selected = configuredTools(pi.getFlag(ROLE_TOOL_POLICY_FLAG));
|
|
@@ -45,8 +45,8 @@ const errorText = (error: unknown): string => clean(error instanceof Error ? err
|
|
|
45
45
|
const completed = (status: string) => ["completed", "success", "failed", "failure", "aborted", "integrated"].includes(status);
|
|
46
46
|
const priority = (status: string) => /attention|blocked|retained|failed/i.test(status) ? 0 : completed(status) ? 2 : 1;
|
|
47
47
|
|
|
48
|
-
type
|
|
49
|
-
async function choose<T>(ctx: ExtensionContext, title: string, items: readonly
|
|
48
|
+
type Choice<T> = { label: string; value: T };
|
|
49
|
+
async function choose<T>(ctx: ExtensionContext, title: string, items: readonly Choice<T>[]): Promise<T | undefined> {
|
|
50
50
|
// Numeric suffixes are UI-only. Never parse display text into an identity.
|
|
51
51
|
const labels = items.map((item, index) => `${clean(item.label, 145)} [${index + 1}]`);
|
|
52
52
|
const selected = await ctx.ui.select(title, labels);
|
|
@@ -100,7 +100,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
100
100
|
if (unavailable) ctx.ui.notify(`Isolated inventory unavailable (${unavailable}). Direct branch recovery remains available; check the canonical Git checkout/configuration.`, "warning");
|
|
101
101
|
if (inventory?.invalidIds.length) for (const id of inventory.invalidIds) ctx.ui.notify(`Unreadable isolated state ID: ${JSON.stringify(id)}. Preserve this file; inspect the state store before making changes.`, "warning");
|
|
102
102
|
type Selection = { kind: "direct"; task: DirectTask } | { kind: "isolated"; request: IsolatedRequest; root: string } | { kind: "refresh" } | { kind: "history" } | { kind: "close" };
|
|
103
|
-
const selections:
|
|
103
|
+
const selections: Choice<Selection>[] = [
|
|
104
104
|
...direct.filter((task) => history || !completed(task.status)).map((task) => ({ label: `Direct · ${task.name} · ${task.status} · ${task.id}`, value: { kind: "direct" as const, task } })),
|
|
105
105
|
...(inventory?.requests ?? []).filter((request) => history || !completed(request.status) || /retained/i.test(request.status)).map((request) => ({ label: `Isolated · ${request.name} · ${request.status} · ${request.id}`, value: { kind: "isolated" as const, request, root: inventory!.root } })),
|
|
106
106
|
{ label: "Refresh", value: { kind: "refresh" as const } },
|
|
@@ -108,7 +108,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
108
108
|
{ label: "Close", value: { kind: "close" as const } },
|
|
109
109
|
];
|
|
110
110
|
const taskChoices = selections.splice(0, selections.length - 3);
|
|
111
|
-
const rank = ({ value }:
|
|
111
|
+
const rank = ({ value }: Choice<Selection>) => value.kind === "direct" ? priority(value.task.status)
|
|
112
112
|
: value.kind === "isolated" ? priority(value.request.status) : 3;
|
|
113
113
|
taskChoices.sort((a, b) => rank(a) - rank(b));
|
|
114
114
|
selections.unshift(...taskChoices);
|
|
@@ -129,7 +129,7 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
129
129
|
if (!valid()) return;
|
|
130
130
|
type Action = "inspect" | "send" | "edit" | "back";
|
|
131
131
|
const eligible = request.tasks.filter((task) => task.kind === "changeset" && adapter.canFollowup(root, request.id, task.id));
|
|
132
|
-
const actions:
|
|
132
|
+
const actions: Choice<Action>[] = [
|
|
133
133
|
{ label: "Inspect status and recovery", value: "inspect" },
|
|
134
134
|
...(eligible.length ? [{ label: "Send follow-up instructions", value: "send" as const }, { label: "Edit queued instructions (withdraw ALL pending; Cancel leaves withdrawn)", value: "edit" as const }] : []),
|
|
135
135
|
{ label: "Back / refresh", value: "back" },
|
package/extensions/subagent.ts
CHANGED
|
@@ -3,12 +3,12 @@ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-c
|
|
|
3
3
|
import { type Component, Text, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
4
|
import { availableTaskModels, loadTaskModelsConfig, modelReference, registerModelTask, resolveAvailableModel, type ResolvedTaskRoute, taskThinkingLevels } from "@henryqw/pi-task-models";
|
|
5
5
|
import { capEphemeralSubagentOutput as capOutput, createEphemeralSubagentExecutor, DELEGATE_TASK, formatDuration, loadRoles, prepareRoleLaunch, ROLE_TOOL_POLICY_FLAG, type Role } from "@henryqw/pi-subagent";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { readSubagentConfig, resolveExecutionPolicy, type EffectiveExecutionPolicy } from "./config.ts";
|
|
7
|
+
import { registerCheckoutAdmission, roleCanWrite, roleIsReadOnlyScout } from "./admission.ts";
|
|
8
8
|
import { registerIsolatedExtension } from "./isolated.ts";
|
|
9
9
|
import { registerSubagentCommand, type DirectTask } from "./subagent-command.ts";
|
|
10
10
|
import { MODEL_CLASS_GUIDANCE } from "./model-class-policy.ts";
|
|
11
|
-
import {
|
|
11
|
+
import { ENTRY_STATUS_PRESENTATION, formatWorkflowResult, type BackgroundWorkflowTransportDetails, type WorkflowTransportEntry } from "./result-transport.ts";
|
|
12
12
|
import { DelegateTaskSchema, identifyWorkflowEntries, parseDelegateTask, runForegroundWorkflow, type Delegation, type ParsedWorkflow, type WorkflowEntry } from "./workflow.ts";
|
|
13
13
|
import { createDirectHerdr, type DirectHandle, type DirectTab } from "../dist/direct-herdr.js";
|
|
14
14
|
import { materializeTransientLaunch } from "../dist/launch-runtime.js";
|
|
@@ -140,7 +140,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
140
140
|
const glyph = details.recovery ? "■" : details.outcome === "completed" ? "✓" : "✗";
|
|
141
141
|
const color = details.recovery ? "warning" : details.outcome === "completed" ? "success" : "error";
|
|
142
142
|
const rows = details.entries.map(({ name, role, status, summary }) => {
|
|
143
|
-
const { glyph, fallback } =
|
|
143
|
+
const { glyph, fallback } = ENTRY_STATUS_PRESENTATION[status];
|
|
144
144
|
return `${glyph} ${name} · ${role} — ${details.recovery ? fallback : summary || fallback}`;
|
|
145
145
|
});
|
|
146
146
|
const raw = content.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, " ");
|
|
@@ -159,13 +159,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
159
159
|
try {
|
|
160
160
|
initialPolicy = resolveExecutionPolicy(loadedConfig);
|
|
161
161
|
} catch {
|
|
162
|
-
initialPolicy = {
|
|
163
|
-
maxSubagents: DEFAULT_EXECUTION_POLICY.maxSubagents,
|
|
164
|
-
maxTurns: DEFAULT_EXECUTION_POLICY.maxTurns,
|
|
165
|
-
childIdleMs: DEFAULT_TIMEOUT_CONFIG.idleMinutes * 60_000,
|
|
166
|
-
childMaxMs: DEFAULT_TIMEOUT_CONFIG.maxMinutes * 60_000,
|
|
167
|
-
maxCorrections: DEFAULT_EXECUTION_POLICY.maxCorrections,
|
|
168
|
-
};
|
|
162
|
+
initialPolicy = resolveExecutionPolicy({ source: "missing", config: {} });
|
|
169
163
|
}
|
|
170
164
|
const executor = createEphemeralSubagentExecutor({
|
|
171
165
|
maxConcurrency: initialPolicy.maxSubagents,
|
|
@@ -179,13 +173,15 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
179
173
|
const parsed = parseDelegateTask(input);
|
|
180
174
|
if (parsed.mode === "isolated") return true;
|
|
181
175
|
const roles = new Map(loadRoles().map((role) => [role.name, role]));
|
|
182
|
-
return parsed.workflow.delegations.some((delegation) =>
|
|
176
|
+
return parsed.workflow.delegations.some((delegation) => {
|
|
177
|
+
const role = roles.get(delegation.role);
|
|
178
|
+
return !role || roleCanWrite(role);
|
|
179
|
+
});
|
|
183
180
|
} catch {
|
|
184
181
|
return true;
|
|
185
182
|
}
|
|
186
183
|
};
|
|
187
|
-
|
|
188
|
-
admission.register(pi, canWrite);
|
|
184
|
+
registerCheckoutAdmission(pi, canWrite);
|
|
189
185
|
const isolatedSurface = registerIsolatedExtension(pi, {
|
|
190
186
|
executor,
|
|
191
187
|
policy: initialPolicy,
|
|
@@ -513,9 +509,7 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
513
509
|
const prepared = entry.delegation.model === undefined ? route : prepareRoleLaunch(pi, context, {
|
|
514
510
|
role, route: replaceRouteModel(context, entry.delegation.model, route),
|
|
515
511
|
});
|
|
516
|
-
|
|
517
|
-
states.set(entry.id, { id: base.id, index: base.index, name: base.name, role: base.role,
|
|
518
|
-
model: modelReference(prepared.model), thinkingLevel: prepared.thinkingLevel, status: "running", assistantOutput: "" });
|
|
512
|
+
states.set(entry.id, { ...states.get(entry.id)!, model: modelReference(prepared.model), thinkingLevel: prepared.thinkingLevel, status: "running", assistantOutput: "" });
|
|
519
513
|
startWidgetItem(entry.id, taskId, role.name, prepared.model.id, prepared.thinkingLevel, entry.delegation.name, ctx);
|
|
520
514
|
const transient = await materializeTransientLaunch({ launch: prepared, prompt: prepared.systemPrompt,
|
|
521
515
|
promptArgIndex: prepared.promptArgIndex }, activeSignal);
|
|
@@ -578,17 +572,12 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
578
572
|
const tokens = await handle.usageTokens();
|
|
579
573
|
if (tokens !== undefined && widgetItems.has(entry.id)) widgetItems.get(entry.id)!.tokens = tokens;
|
|
580
574
|
} catch { /* Optional telemetry. */ }
|
|
581
|
-
|
|
582
|
-
states.set(entry.id, { id: base.id, index: base.index, name: base.name, role: base.role,
|
|
583
|
-
...(base.model ? { model: base.model } : {}), ...(base.thinkingLevel ? { thinkingLevel: base.thinkingLevel } : {}),
|
|
584
|
-
status: "succeeded", assistantOutput: answer });
|
|
575
|
+
states.set(entry.id, { ...states.get(entry.id)!, status: "succeeded", assistantOutput: answer });
|
|
585
576
|
finishWidgetItem(entry.id, "success");
|
|
586
577
|
return answer;
|
|
587
578
|
} catch (error) {
|
|
588
|
-
const base = states.get(entry.id)!;
|
|
589
|
-
states.set(entry.id, {
|
|
590
|
-
...(base.model ? { model: base.model } : {}), ...(base.thinkingLevel ? { thinkingLevel: base.thinkingLevel } : {}),
|
|
591
|
-
status: "rejected", failure: `${tabByEntry.has(entry.id) ? `recover from Herdr tab ${tabByEntry.get(entry.id)!.tabId}, agent ${tabByEntry.get(entry.id)!.name}, session ${tabByEntry.get(entry.id)!.sessionFile}: ` : ""}${capOutput(error instanceof Error ? error.message : String(error))}` });
|
|
579
|
+
const { assistantOutput: _partial, ...base } = states.get(entry.id)!;
|
|
580
|
+
states.set(entry.id, { ...base, status: "rejected", failure: `${tabByEntry.has(entry.id) ? `recover from Herdr tab ${tabByEntry.get(entry.id)!.tabId}, agent ${tabByEntry.get(entry.id)!.name}, session ${tabByEntry.get(entry.id)!.sessionFile}: ` : ""}${capOutput(error instanceof Error ? error.message : String(error))}` });
|
|
592
581
|
finishWidgetItem(entry.id, "failure");
|
|
593
582
|
throw error;
|
|
594
583
|
} finally { release(); }
|
package/extensions/workflow.ts
CHANGED
|
@@ -43,7 +43,6 @@ export const DirectWorkflowSchema = Type.Object({
|
|
|
43
43
|
}, { additionalProperties: false, description: "Direct mode: exactly one compact single, tasks, or chain workflow" });
|
|
44
44
|
|
|
45
45
|
export const DelegateTaskSchema = Type.Union([DirectWorkflowSchema, ExecuteRequestSchema], { type: "object" });
|
|
46
|
-
export const WorkflowSchema = DirectWorkflowSchema;
|
|
47
46
|
|
|
48
47
|
export type Delegation = Static<typeof DelegationSchema> & { kind: "text" };
|
|
49
48
|
export type WorkflowMode = "single" | "parallel" | "chain";
|
package/package.json
CHANGED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
export interface WorkingCheckoutState {
|
|
2
|
-
head: string;
|
|
3
|
-
indexHash: string;
|
|
4
|
-
rawManifestHash: string;
|
|
5
|
-
}
|
|
6
|
-
export interface WorkingSnapshotIdentity extends WorkingCheckoutState {
|
|
7
|
-
tree: string;
|
|
8
|
-
}
|
|
9
|
-
export interface WorkingCheckoutBaseline extends WorkingSnapshotIdentity {
|
|
10
|
-
worktree: string;
|
|
11
|
-
}
|
|
12
|
-
export interface PreparedWorkingChangeEvidence {
|
|
13
|
-
baseline: WorkingCheckoutBaseline;
|
|
14
|
-
identity: WorkingSnapshotIdentity;
|
|
15
|
-
changedPaths: string[];
|
|
16
|
-
patchPath: string;
|
|
17
|
-
rawEvidencePath: string;
|
|
18
|
-
cleanup(): Promise<void>;
|
|
19
|
-
}
|
|
20
|
-
/** Capture mutation-detection identity without requiring a clean checkout or constructing Git objects. */
|
|
21
|
-
export declare function captureWorkingCheckoutState(cwd: string, signal?: AbortSignal): Promise<WorkingCheckoutState>;
|
|
22
|
-
export declare function captureWorkingCheckoutBaseline(cwd: string, signal?: AbortSignal): Promise<WorkingCheckoutBaseline>;
|
|
23
|
-
export declare function prepareWorkingChangeEvidence(baseline: WorkingCheckoutBaseline, signal?: AbortSignal): Promise<PreparedWorkingChangeEvidence>;
|
|
24
|
-
export declare function sameWorkingCheckoutState(left: WorkingCheckoutState, right: WorkingCheckoutState): boolean;
|
|
25
|
-
export declare function sameWorkingSnapshot(left: WorkingSnapshotIdentity, right: WorkingSnapshotIdentity): boolean;
|
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
|
-
}
|