@henryqw/pi-subagent 23.0.1 → 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
@@ -113,7 +113,7 @@ An isolated request has one durable ID, one goal, and 1–8 typed tasks:
113
113
  "modelClass": "balanced",
114
114
  "requirements": "Implement the smallest correct repair.",
115
115
  "deliverable": "A committed candidate in the owned worktree.",
116
- "dependsOn": ["inspect"],
116
+ "dependsOn": [],
117
117
  "contextFrom": ["inspect"],
118
118
  "checks": [
119
119
  { "command": "pnpm", "args": ["test", "--", "refresh"] }
@@ -131,7 +131,7 @@ An isolated request has one durable ID, one goal, and 1–8 typed tasks:
131
131
  }
132
132
  ```
133
133
 
134
- `dependsOn` controls scheduling. `contextFrom` may name completed text tasks and preserves the declared order. Changesets require focused task checks; a graph with changesets requires final checks. A canonical root `package.json` test script is required: the runner adds `pnpm test` to final checks if missing, and Main runs it on the combined integration checkout before promotion. Checks and judgments bind to exact Git identities; candidate drift, Main drift, mutation during validation, ambiguity, or conflicts stop promotion and retain evidence.
134
+ `dependsOn` controls scheduling. `contextFrom` also schedules dependencies and carries completed text outputs in the declared order. Do not repeat the same task in both fields. Changesets require focused task checks; a graph with changesets requires final checks. A canonical root `package.json` test script is required: the runner adds `pnpm test` to final checks if missing, and Main runs it on the combined integration checkout before promotion. Checks and judgments bind to exact Git identities; candidate drift, Main drift, mutation during validation, ambiguity, or conflicts stop promotion and retain evidence.
135
135
 
136
136
  `delegate_task`, `subagent_resume`, and `subagent_integrate advance` return a durable request ID after the wave is saved; productive work continues while Main is free. Main can do other work or end its turn instead of sleeping or polling. Pi sends a follow-up in the launching session whenever a checked worker becomes ready or a worker needs attention, and again when the wave finishes. A worker whose exact turn ends without a changed clean commit—including a failed or aborted turn—needs attention, not an inferred candidate; use `subagent_status` for its saved evidence. If delivery is missed, inspect `subagent_status`.
137
137
 
@@ -147,9 +147,11 @@ A queued follow-up runs after the current turn settles and must produce a new cl
147
147
 
148
148
  After a coordinator interruption, run `/subagent recover` once. It acquires the repository productive lease before changing any orphaned `pending` or `running` state, preserves unreadable state files and retained candidates, and reports blockers and existing continuations to Main without replaying prompts, merges, promotion, or cleanup. If another productive run holds the lease, it only reads status and reports the live owner; wait rather than taking over. Repeating the command is safe. Main uses `subagent_status` for exact identities and the recovery tools in the interface table above for deliberate decisions; the command never stages or promotes. Direct Herdr tab recovery remains in `/subagent`.
149
149
 
150
+ If an isolated worker produced no candidate, `subagent_abort` terminates it and removes only proven unchanged, clean owned resources. Cleanup includes ignored-file checks and never discards unique commits. If productive work is still settling, termination is uncertain, or cleanup fails, the request stays aborted with retained-resource evidence. Inspect `subagent_status`, resolve the reported blocker, then repeat `subagent_abort` to reconcile cleanup; it does not replay worker prompts. Dirty work, unique commits, and uncertain allocations remain for deliberate manual recovery.
151
+
150
152
  The following details describe exact identity and retained-work requirements.
151
153
 
152
- A checked candidate stays retained if Main is dirty. Staging, combined validation, and promotion require Main to be clean at the pinned committed identity; clean your local edits deliberately before using those actions. No dirty work is replayed or merged automatically. Pass the exact `generation`, candidate (`taskId`, `attempt`, `candidate`) and `expectedTip` identities reported by status to stage; pass the generation and combined `expectedTip` to integrate. If Main advances cleanly before promotion, call `subagent_integrate refresh` with the recorded `expectedMain`, old `expectedTip`, and new clean `newMain`. Main must restage selected immutable candidates into the new generation and rerun combined checks. Dirty or divergent Main blocks refresh. Stale identities fail. If integration allocation was uncertain, `subagent_stage resolve` first proves the owned checkout is still at its base before starting the selected merge; otherwise it retains the intent. At most two integration worktrees may remain unreleased per request; a superseded generation is read-only. To release a superseded *clean* integration checkout, call `subagent_integrate` with `{ id, action: "release", generation, expectedTip }`, using that generation's last staged tip (or integration base when no stage completed). To release a rejected candidate after its worker has terminated and all generations using it are released, pass `{ id, action: "release", generation: <latest generation number, or 1>, taskId, attempt, expectedTip: <candidate tip> }`. Release persists each host/checkout/branch step, verifies ownership and cleanliness (including ignored files), and deletes the branch only if its ref still names the exact tip. Dirty, conflicted, changed, or uncertain resources remain for manual inspection; retry the same release after resolving the obstacle. Released checkouts free a retained slot; abort is available after every retained resource is released. Rejection can invalidate dependent work; if fresh dependent execution is impossible, start a new request instead of forcing promotion. Productive requests have no whole-run wall-clock deadline. Resume does not reset the recorded policy or correction count. Child limits, abort signals, process I/O, status inspection, exact termination, and cleanup retain finite safety bounds.
154
+ A checked candidate stays retained if Main is dirty. Staging, combined validation, and promotion require Main to be clean at the pinned committed identity; clean your local edits deliberately before using those actions. No dirty work is replayed or merged automatically. Pass the exact `generation`, candidate (`taskId`, `attempt`, `candidate`) and `expectedTip` identities reported by status to stage; pass the generation and combined `expectedTip` to integrate. If Main advances cleanly before promotion, call `subagent_integrate refresh` with the recorded `expectedMain`, old `expectedTip`, and new clean `newMain`. Main must restage selected immutable candidates into the new generation and rerun combined checks. Dirty or divergent Main blocks refresh. Stale identities fail. If integration allocation was uncertain, `subagent_stage resolve` first proves the owned checkout is still at its base before starting the selected merge; otherwise it retains the intent. At most two integration worktrees may remain unreleased per request; a superseded generation is read-only. To release a superseded *clean* integration checkout, call `subagent_integrate` with `{ id, action: "release", generation, expectedTip }`, using that generation's last staged tip (or integration base when no stage completed). To release a rejected candidate after its worker has terminated and all generations using it are released, pass `{ id, action: "release", generation: <latest generation number, or 1>, taskId, attempt, expectedTip: <candidate tip> }`. Release persists each host/checkout/branch step, verifies ownership and cleanliness (including ignored files), and deletes the branch only if its ref still names the exact tip. Dirty, conflicted, changed, or uncertain resources remain for manual inspection; retry the same release after resolving the obstacle. Released checkouts free a retained slot; abort is available after every retained resource is released. Rejection can invalidate dependent work; if fresh dependent execution is impossible, start a new request instead of forcing promotion. Productive requests have no whole-run wall-clock deadline. Resume does not reset the recorded policy or correction count. Text tasks have two dispatch attempts; inspection failures before dispatch do not consume one. Verification requires passing preliminary checks and any required judgment; it cannot override failed review. Child limits, abort signals, process I/O, status inspection, exact termination, and cleanup retain finite safety bounds.
153
155
 
154
156
  The extension never pushes, opens a pull request, publishes, deploys, force-cleans recoverable work, or silently falls back to Main.
155
157
 
@@ -197,7 +199,7 @@ Children disable ambient extension and Skill discovery. Only declared resources
197
199
  | `prepareRoleLaunch`, `finalizeRoleLaunch` | functions | Prepare Role prompts, arguments, and tool policy. |
198
200
  | `createEphemeralSubagentExecutor` | function | Run bounded no-session Pi children through a FIFO pool. |
199
201
  | `createChildWorktree`, `finalizeChildWorktree` | functions | Create and conservatively finalize child worktrees. |
200
- | `prepareExactReviewEvidence`, `prepareWorkingChangeEvidence` | functions | Prepare private exact review or working-change evidence. |
202
+ | `prepareExactReviewEvidence` | function | Prepare private exact base-to-tip review evidence. |
201
203
  | `ExecuteRequestSchema`, `StageRequestSchema`, `IntegrationActionSchema` | schemas | Validate checked request and integration inputs. |
202
204
  | `IsolatedRunner`, `FileRunStore` | classes | Run checked work and persist durable request state. |
203
205
  | `RoleLaunchRuntime`, `HerdrHostRuntime`, `CheckedGitRuntime` | classes | Supply launch, host, and Git behavior to the runner. |
@@ -95,6 +95,7 @@ export declare class HerdrHostRuntime implements HostRuntime {
95
95
  outcome: "unknown";
96
96
  failure: string;
97
97
  }>;
98
+ private proveAbsentAgentForCleanup;
98
99
  cleanupHost(input: {
99
100
  requestId: ExecuteRequest["id"];
100
101
  kind: HostCleanupKind;
@@ -643,9 +643,41 @@ export class HerdrHostRuntime {
643
643
  return { outcome: "unknown", failure: safeText(error) };
644
644
  }
645
645
  }
646
+ async proveAbsentAgentForCleanup(allocation, attempt, context) {
647
+ requireIntentIdentity(allocation, attempt);
648
+ assertAgentIntent(allocation, attempt);
649
+ this.assertLeasePath(allocation.leasePath, attempt.correlationToken);
650
+ await this.assertPrivateLeaseDirectories(allocation.leasePath, true);
651
+ await this.assertPrivateLease(allocation.leasePath, true);
652
+ const agents = (await this.listAgents(allocation.worktreeCwd, context)).map((agent) => ({
653
+ name: typeof agent.name === "string" ? agent.name : undefined,
654
+ paneId: exactString(agent.pane_id, "Herdr listed agent pane ID"),
655
+ tabId: exactString(agent.tab_id, "Herdr listed agent tab ID"),
656
+ }));
657
+ if (agents.some((agent) => agent.name === allocation.agentName
658
+ || agent.paneId === allocation.paneId || agent.tabId === allocation.tabId)) {
659
+ throw new Error("The exact absent-agent allocation now has a matching Herdr agent.");
660
+ }
661
+ const paneIsAbsent = await this.paneAbsent(allocation.paneId, allocation.worktreeCwd, context);
662
+ if (!paneIsAbsent)
663
+ await this.assertStartableAgentPane(allocation, context, { requireExclusiveTty: true });
664
+ if ((await this.scanLease(allocation.leasePath, allocation.worktreeCwd, context, undefined, true)).length) {
665
+ throw new Error("The absent-agent process lease still has holders.");
666
+ }
667
+ await this.delay(50, context.signal);
668
+ if ((await this.scanLease(allocation.leasePath, allocation.worktreeCwd, context, undefined, true)).length) {
669
+ throw new Error("The absent-agent process lease did not remain empty for two consecutive scans.");
670
+ }
671
+ return paneIsAbsent;
672
+ }
646
673
  async cleanupHost(input, context) {
647
- if (input.attempt.termination?.status !== "terminated") {
648
- return { outcome: "blocked", failure: "Host cleanup requires exact recorded worker termination." };
674
+ const terminationProven = input.attempt.termination?.status === "terminated";
675
+ const agentIntents = input.attempt.allocations.filter((intent) => intent.kind === "agent");
676
+ const latestAgent = agentIntents.at(-1);
677
+ const neverOwned = !input.attempt.termination && latestAgent?.status === "absent"
678
+ && agentIntents.every((intent) => intent.status === "absent");
679
+ if (!terminationProven && !neverOwned) {
680
+ return { outcome: "blocked", failure: "Host cleanup requires exact worker termination or a definitive absent-agent allocation." };
649
681
  }
650
682
  try {
651
683
  if (input.kind === "worker_tab") {
@@ -663,8 +695,19 @@ export class HerdrHostRuntime {
663
695
  if (tabId === allocation.workspaceRootTabId)
664
696
  throw new Error("Saved worker tab aliases the workspace root tab.");
665
697
  const paneId = exactString(allocation.paneId, "saved worker pane ID");
666
- if (!await this.paneAbsent(paneId, allocation.worktreeCwd, context)) {
667
- return { outcome: "blocked", failure: "The exact saved worker pane still exists after termination." };
698
+ const paneIsAbsent = neverOwned
699
+ ? await this.proveAbsentAgentForCleanup(latestAgent, input.attempt, context)
700
+ : await this.paneAbsent(paneId, allocation.worktreeCwd, context);
701
+ if (!paneIsAbsent) {
702
+ if (!neverOwned)
703
+ return { outcome: "blocked", failure: "The exact saved worker pane still exists after termination." };
704
+ const closed = await this.herdr.exec(["pane", "close", paneId], this.processOptions(allocation.worktreeCwd, context, HERDR_OPERATION_CAP_MS));
705
+ if (closed.code !== 0 || closed.killed)
706
+ return { outcome: "blocked", failure: safeText(herdrCommandFailure(["pane", "close"], closed)) };
707
+ requireOkResponse(closed.stdout, "Herdr pane close response");
708
+ if (!await this.paneAbsent(paneId, allocation.worktreeCwd, context)) {
709
+ return { outcome: "blocked", failure: "The exact unstarted worker pane still exists after close." };
710
+ }
668
711
  }
669
712
  const workspaceIsAbsent = await this.workspaceAbsent(workspaceId, workspace, context);
670
713
  const tab = await this.getTab(tabId, allocation.worktreeCwd, context);
@@ -686,6 +729,9 @@ export class HerdrHostRuntime {
686
729
  const workerCleanup = input.attempt.cleanup.find((step) => step.kind === "worker_tab");
687
730
  if (workerCleanup?.status !== "completed")
688
731
  return { outcome: "blocked", failure: "Workspace cleanup must follow worker-tab reconciliation." };
732
+ if (neverOwned && !await this.proveAbsentAgentForCleanup(latestAgent, input.attempt, context)) {
733
+ return { outcome: "blocked", failure: "The exact unstarted worker pane remains after worker-tab cleanup." };
734
+ }
689
735
  const allocation = ownedIntent(input.attempt, "workspace");
690
736
  requireIntentIdentity(allocation, input.attempt);
691
737
  assertWorkspaceIntent(allocation, input.attempt);
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.d.ts CHANGED
@@ -334,6 +334,7 @@ export declare class IsolatedRunner {
334
334
  private cleanupPromoted;
335
335
  private requireExactIntegration;
336
336
  abort(id: string, root: string, outerSignal?: AbortSignal): Promise<RunResponse>;
337
+ private cleanupAborted;
337
338
  status(id: string, root: string, outerSignal?: AbortSignal): Promise<RunResponse>;
338
339
  private run;
339
340
  private dispatchTask;