@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 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 and working-change helpers produce bounded private evidence; callers must preserve identity guards and verdict validation.
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`, `prepareWorkingChangeEvidence` | functions | Prepare private exact review or working-change evidence. |
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, roleMcpFlagValue, selectRoleMcpConfig, type RoleMcpConfig } from "./mcp-role.ts";
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, worktreeContextNote, type WorktreeDirtyInspection, type WorktreeInfo, type WorktreePayload, } from "./worktree.ts";
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
- /** Restore the system prompt pair after a caller has prepared its launch argv. */
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, roleMcpFlagValue, selectRoleMcpConfig } from "./mcp-role.js";
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, worktreeContextNote, } from "./worktree.js";
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 createRoleLaunchFromSkills(ctx, input, resolveRoleSkills(pi, input.role));
246
+ return finalizeRoleLaunch(prepareRoleLaunchFromSkills(ctx, input, resolveRoleSkills(pi, input.role)));
242
247
  }
243
- function createRoleLaunchFromSkills(ctx, input, skills) {
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", CHILD_EXCLUDED_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
- export function resolveRoleLaunch(pi, ctx, input) {
295
+ function prepareResolvedRoleLaunch(pi, ctx, input) {
287
296
  const { task, modelClass, agentDir, ...launchInput } = input;
288
297
  const selectedClass = modelClass ?? input.role.modelClass;
289
- return createRoleLaunch(pi, ctx, {
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 stripRoleSystemPrompt(rawArgs) {
297
- const indexes = rawArgs.flatMap((arg, index) => arg === SYSTEM_PROMPT_FLAG ? [index] : []);
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 launch = "route" in input
325
- ? createRoleLaunch(pi, ctx, input)
326
- : resolveRoleLaunch(pi, ctx, input);
327
- assertNoMissingRoleSkills(input.role, launch);
328
- return prepareResolvedRoleLaunch(input.role, launch, input.tools);
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 launch = createRoleLaunchFromSkills(ctx, {
342
+ const prepared = prepareRoleLaunchFromSkills(ctx, {
351
343
  role: effectiveRole,
352
344
  route: resolveTaskRoute(ctx, input.modelClass),
353
345
  }, skills);
354
- assertNoMissingRoleSkills(effectiveRole, launch);
355
- const additions = [
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
- const promptArgIndex = launch.args.indexOf(SYSTEM_PROMPT_FLAG);
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
- /** Restore the system prompt pair after a caller has prepared its launch argv. */
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 {
@@ -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
  }
@@ -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 { EXECUTION_BUDGET_FLAG, resolveConfiguredRoleLaunch } from "./index.js";
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
  }),
@@ -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(_timeoutMs, _now, outerSignal) {
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(policy.childMaxMs, () => this.coordinatorRuntime.now(), outerSignal);
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 current = this.currentPolicy();
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(Math.min(state.policy.childMaxMs, this.currentPolicy().childMaxMs), () => this.coordinatorRuntime.now(), outerSignal);
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(Math.min(state.policy.childMaxMs, this.currentPolicy().childMaxMs), () => this.coordinatorRuntime.now(), outerSignal);
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(Math.min(state.policy.childMaxMs, this.currentPolicy().childMaxMs), () => this.coordinatorRuntime.now(), outerSignal);
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(Math.min(state.policy.childMaxMs, this.currentPolicy().childMaxMs), () => this.coordinatorRuntime.now(), outerSignal);
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(Math.min(state.policy.childMaxMs, this.currentPolicy().childMaxMs), () => this.coordinatorRuntime.now(), outerSignal);
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.invalidate();
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 interface ExecutionPolicySnapshot {
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;
@@ -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
- }
@@ -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 createCheckoutAdmission(): CheckoutAdmission {
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
- return {
45
- register(pi, directCanWrite) {
46
- pi.on("tool_call", async (event, ctx) => {
47
- const potentiallyWriting = event.toolName === "delegate_task"
48
- ? directCanWrite(event.input)
49
- : !READ_ONLY_TOOLS.has(event.toolName);
50
- if (!potentiallyWriting) return;
51
- const key = await checkoutKey(pi, ctx);
52
- const owner = ownerByCheckout.get(key);
53
- if (owner && owner !== event.toolCallId) {
54
- return {
55
- block: true,
56
- reason: `Checkout ${key} already has an admitted Pi writer (${owner}); retry after it settles.`,
57
- };
58
- }
59
- ownerByCheckout.set(key, event.toolCallId);
60
- checkoutByCall.set(event.toolCallId, key);
61
- });
62
- pi.on("tool_result", (event) => release(event.toolCallId));
63
- pi.on("tool_execution_end", (event) => release(event.toolCallId));
64
- pi.on("session_shutdown", () => {
65
- ownerByCheckout.clear();
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
  }
@@ -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 interface EffectiveExecutionPolicy {
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
 
@@ -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
- type TransportEntryBase = {
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
- summary?: string;
29
- model?: string;
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 = (statuses: WorkflowTransportStatus[]) => entries.filter(({ status }) => statuses.includes(status)).length;
65
- return ([
66
- [["rejected"], "failed"],
67
- [["succeeded"], "completed"],
68
- [["skipped"], "skipped"],
69
- ] as const).flatMap(([statuses, word]) => {
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<WorkflowTransportEntryDetails["status"], WorkflowEntryStatusPresentation>;
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 } = presentWorkflowEntryStatus(entry.status);
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
  }),
@@ -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
- const allowlist = parseRoleMcpAllowlist(roleMcpFlagValue(process.argv, `--${ROLE_MCP_POLICY_FLAG}`));
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
  }
@@ -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 | boolean | undefined): EphemeralSubagentExecutionBudget | undefined {
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
- pi.registerFlag(EXECUTION_BUDGET_FLAG, {
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 Pick<T> = { label: string; value: T };
49
- async function choose<T>(ctx: ExtensionContext, title: string, items: readonly Pick<T>[]): Promise<T | undefined> {
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: Pick<Selection>[] = [
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 }: Pick<Selection>) => value.kind === "direct" ? priority(value.task.status)
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: Pick<Action>[] = [
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" },
@@ -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 { DEFAULT_EXECUTION_POLICY, DEFAULT_TIMEOUT_CONFIG, readSubagentConfig, resolveExecutionPolicy, type EffectiveExecutionPolicy } from "./config.ts";
7
- import { createCheckoutAdmission, roleCanWrite, roleIsReadOnlyScout } from "./admission.ts";
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 { formatWorkflowResult, presentWorkflowEntryStatus, type BackgroundWorkflowTransportDetails, type WorkflowTransportEntry } from "./result-transport.ts";
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 } = presentWorkflowEntryStatus(status);
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) => roleCanWrite(roles.get(delegation.role) ?? { name: delegation.role } as Role));
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
- const admission = createCheckoutAdmission();
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
- const base = states.get(entry.id)!;
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
- const base = states.get(entry.id)!;
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, { id: base.id, index: base.index, name: base.name, role: base.role,
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(); }
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "23.0.2",
3
+ "version": "24.0.0",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -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;
@@ -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
- }