pi-subagents 0.36.0 → 0.37.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +93 -8
  3. package/agents/delegate.md +2 -0
  4. package/agents/worker.md +2 -0
  5. package/package.json +4 -2
  6. package/skills/pi-subagents/SKILL.md +20 -4
  7. package/src/agents/agent-management.ts +7 -1
  8. package/src/agents/agents.ts +99 -12
  9. package/src/api/capability-ceiling.ts +17 -0
  10. package/src/api/delegation.ts +3 -1
  11. package/src/api/preflight.ts +399 -0
  12. package/src/extension/index.ts +2 -0
  13. package/src/extension/rpc.ts +4 -0
  14. package/src/extension/schemas.ts +8 -2
  15. package/src/extension/tool-description.ts +2 -0
  16. package/src/runs/background/async-execution.ts +125 -14
  17. package/src/runs/background/async-resume.ts +24 -7
  18. package/src/runs/background/async-status.ts +18 -0
  19. package/src/runs/background/process-terminal.ts +280 -0
  20. package/src/runs/background/run-status.ts +7 -1
  21. package/src/runs/background/scheduled-runs.ts +6 -1
  22. package/src/runs/background/stale-run-reconciler.ts +6 -0
  23. package/src/runs/background/subagent-runner.ts +182 -12
  24. package/src/runs/foreground/chain-execution.ts +5 -0
  25. package/src/runs/foreground/execution.ts +59 -13
  26. package/src/runs/foreground/subagent-executor.ts +32 -2
  27. package/src/runs/shared/acceptance.ts +41 -30
  28. package/src/runs/shared/capability-ceiling.ts +177 -0
  29. package/src/runs/shared/dynamic-fanout.ts +1 -1
  30. package/src/runs/shared/mcp-direct-tool-allowlist.ts +12 -6
  31. package/src/runs/shared/nested-events.ts +8 -1
  32. package/src/runs/shared/parallel-utils.ts +5 -0
  33. package/src/runs/shared/pi-args.ts +141 -58
  34. package/src/runs/shared/session-lease.ts +25 -5
  35. package/src/runs/shared/subagent-prompt-runtime.ts +14 -1
  36. package/src/runs/shared/tool-availability.ts +18 -2
  37. package/src/shared/launch-contract.ts +123 -0
  38. package/src/shared/types.ts +104 -7
  39. package/src/shared/utils.ts +17 -42
  40. package/src/slash/delegation-adapters.ts +1 -1
  41. package/src/slash/slash-commands.ts +1 -1
@@ -0,0 +1,177 @@
1
+ import { Buffer } from "node:buffer";
2
+
3
+ export const SUBAGENT_CAPABILITY_CEILING_VERSION = 1 as const;
4
+ export const SUBAGENT_CAPABILITY_CEILING_REGISTRY_KEY = "pi-subagents.capability-ceiling.v1";
5
+ export const SUBAGENT_CAPABILITY_CEILING_ENV = "PI_SUBAGENT_CAPABILITY_CEILING_V1";
6
+
7
+ export type SubagentCapabilityCeiling =
8
+ | { allowedTools: readonly string[]; denyExtensions?: boolean }
9
+ | { allowedTools?: readonly string[]; denyExtensions: boolean };
10
+
11
+ export interface ResolvedSubagentCapabilityCeiling {
12
+ version: typeof SUBAGENT_CAPABILITY_CEILING_VERSION;
13
+ allowedTools?: string[];
14
+ denyExtensions: boolean;
15
+ sources: string[];
16
+ }
17
+
18
+ export interface SubagentCapabilityAudit {
19
+ ceiling: ResolvedSubagentCapabilityCeiling;
20
+ requestedTools?: string[];
21
+ effectiveTools: string[];
22
+ removedTools: string[];
23
+ internalTools: string[];
24
+ extensionsDenied: boolean;
25
+ removedExtensionCount: number;
26
+ requestedMcpToolCount: number;
27
+ effectiveMcpTools: string[];
28
+ }
29
+
30
+ export interface RegisterSubagentCapabilityCeilingOptions {
31
+ sessionId: string;
32
+ source: string;
33
+ ceiling: SubagentCapabilityCeiling;
34
+ }
35
+
36
+ export interface SubagentCapabilityCeilingHandle {
37
+ update(ceiling: SubagentCapabilityCeiling): void;
38
+ dispose(): void;
39
+ }
40
+
41
+ type Registration = { source: string; ceiling: ResolvedSubagentCapabilityCeiling };
42
+ type Registry = Map<string, Map<symbol, Registration>>;
43
+
44
+ function registry(): Registry {
45
+ const key = Symbol.for(SUBAGENT_CAPABILITY_CEILING_REGISTRY_KEY);
46
+ const store = globalThis as typeof globalThis & { [key: symbol]: unknown };
47
+ const existing = store[key];
48
+ if (existing instanceof Map) return existing as Registry;
49
+ const created: Registry = new Map();
50
+ store[key] = created;
51
+ return created;
52
+ }
53
+
54
+ function validateText(value: unknown, field: string): string {
55
+ if (typeof value !== "string" || !value.trim() || /[\u0000-\u001f\u007f]/u.test(value) || Buffer.byteLength(value.trim(), "utf8") > 256) {
56
+ throw new Error(`Invalid capability ceiling ${field}; expected a non-empty string without control characters (max 256 UTF-8 bytes).`);
57
+ }
58
+ return value.trim();
59
+ }
60
+
61
+ function normalizeCeiling(ceiling: SubagentCapabilityCeiling): ResolvedSubagentCapabilityCeiling {
62
+ if (!ceiling || typeof ceiling !== "object" || Array.isArray(ceiling)) throw new Error("Invalid capability ceiling; expected an object.");
63
+ const hasAllowedTools = Object.hasOwn(ceiling, "allowedTools");
64
+ const hasDenyExtensions = Object.hasOwn(ceiling, "denyExtensions");
65
+ if (!hasAllowedTools && !hasDenyExtensions) throw new Error("Invalid capability ceiling; expected allowedTools or denyExtensions.");
66
+ if (hasDenyExtensions && typeof ceiling.denyExtensions !== "boolean") throw new Error("Invalid capability ceiling denyExtensions; expected a boolean.");
67
+ let allowedTools: string[] | undefined;
68
+ if (hasAllowedTools) {
69
+ if (!Array.isArray(ceiling.allowedTools)) throw new Error("Invalid capability ceiling allowedTools; expected an array.");
70
+ if (ceiling.allowedTools.length > 256) throw new Error("Invalid capability ceiling allowedTools; expected at most 256 names.");
71
+ allowedTools = [...new Set(ceiling.allowedTools.map((tool) => {
72
+ const name = validateText(tool, "allowedTools entry");
73
+ if (!/^[A-Za-z0-9_.:-]+$/u.test(name)) throw new Error(`Invalid capability ceiling allowedTools entry '${name}'.`);
74
+ if (Buffer.byteLength(name, "utf8") > 128) throw new Error(`Invalid capability ceiling allowedTools entry '${name}'; max 128 UTF-8 bytes.`);
75
+ return name;
76
+ }))].sort();
77
+ }
78
+ return {
79
+ version: SUBAGENT_CAPABILITY_CEILING_VERSION,
80
+ ...(allowedTools ? { allowedTools } : {}),
81
+ denyExtensions: ceiling.denyExtensions === true,
82
+ sources: [],
83
+ };
84
+ }
85
+
86
+ export function parseSubagentCapabilityCeiling(value: unknown, field = "capability ceiling"): ResolvedSubagentCapabilityCeiling {
87
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid ${field}; expected an object.`);
88
+ const record = value as Record<string, unknown>;
89
+ if (record.version !== SUBAGENT_CAPABILITY_CEILING_VERSION) throw new Error(`Invalid ${field} version.`);
90
+ const normalized = normalizeCeiling(record as SubagentCapabilityCeiling);
91
+ const sources = record.sources;
92
+ if (!Array.isArray(sources) || sources.some((source) => typeof source !== "string")) throw new Error(`Invalid ${field} sources; expected an array of strings.`);
93
+ normalized.sources = [...new Set(sources.map((source) => validateText(source, `${field} source`)))].sort();
94
+ return normalized;
95
+ }
96
+
97
+ export function registerSubagentCapabilityCeiling(options: RegisterSubagentCapabilityCeilingOptions): SubagentCapabilityCeilingHandle {
98
+ const sessionId = validateText(options.sessionId, "sessionId");
99
+ const source = validateText(options.source, "source");
100
+ let normalized = normalizeCeiling(options.ceiling);
101
+ const token = Symbol(source);
102
+ const store = registry();
103
+ let session = store.get(sessionId);
104
+ if (!session) {
105
+ session = new Map();
106
+ store.set(sessionId, session);
107
+ }
108
+ const setRegistration = () => {
109
+ normalized = normalizeCeiling(normalized);
110
+ normalized.sources = [source];
111
+ session!.set(token, { source, ceiling: normalized });
112
+ };
113
+ setRegistration();
114
+ let disposed = false;
115
+ return {
116
+ update(ceiling) {
117
+ if (disposed) throw new Error("Cannot update a disposed capability ceiling handle.");
118
+ normalized = normalizeCeiling(ceiling);
119
+ normalized.sources = [source];
120
+ session!.set(token, { source, ceiling: normalized });
121
+ },
122
+ dispose() {
123
+ if (disposed) return;
124
+ disposed = true;
125
+ session!.delete(token);
126
+ if (session!.size === 0) store.delete(sessionId);
127
+ },
128
+ };
129
+ }
130
+
131
+ export function intersectSubagentCapabilityCeilings(...ceilings: Array<ResolvedSubagentCapabilityCeiling | undefined>): ResolvedSubagentCapabilityCeiling | undefined {
132
+ const active = ceilings.filter((ceiling): ceiling is ResolvedSubagentCapabilityCeiling => ceiling !== undefined);
133
+ if (active.length === 0) return undefined;
134
+ const definedLists = active.filter((ceiling) => ceiling.allowedTools !== undefined).map((ceiling) => new Set(ceiling.allowedTools));
135
+ let allowedTools: string[] | undefined;
136
+ if (definedLists.length > 0) {
137
+ allowedTools = [...definedLists[0]!].filter((tool) => definedLists.every((list) => list.has(tool))).sort();
138
+ }
139
+ return {
140
+ version: SUBAGENT_CAPABILITY_CEILING_VERSION,
141
+ ...(allowedTools ? { allowedTools } : {}),
142
+ denyExtensions: active.some((ceiling) => ceiling.denyExtensions),
143
+ sources: [...new Set(active.flatMap((ceiling) => ceiling.sources))].sort(),
144
+ };
145
+ }
146
+
147
+ export function resolveSubagentCapabilityCeiling(sessionId: string | undefined, inherited?: ResolvedSubagentCapabilityCeiling): ResolvedSubagentCapabilityCeiling | undefined {
148
+ const active: ResolvedSubagentCapabilityCeiling[] = [];
149
+ if (sessionId) {
150
+ const registrations = registry().get(sessionId);
151
+ if (registrations) active.push(...Array.from(registrations.values(), ({ ceiling }) => ceiling));
152
+ }
153
+ return intersectSubagentCapabilityCeilings(inherited, ...active);
154
+ }
155
+
156
+ export function resolveCurrentSubagentCapabilityCeiling(sessionId: string | undefined): ResolvedSubagentCapabilityCeiling | undefined {
157
+ return resolveSubagentCapabilityCeiling(sessionId, decodeSubagentCapabilityCeiling(process.env[SUBAGENT_CAPABILITY_CEILING_ENV]));
158
+ }
159
+
160
+ export function encodeSubagentCapabilityCeiling(ceiling: ResolvedSubagentCapabilityCeiling | undefined): string | undefined {
161
+ if (!ceiling) return undefined;
162
+ return Buffer.from(JSON.stringify(ceiling), "utf8").toString("base64url");
163
+ }
164
+
165
+ export function decodeSubagentCapabilityCeiling(value: string | undefined): ResolvedSubagentCapabilityCeiling | undefined {
166
+ if (value === undefined || value === "") return undefined;
167
+ let parsed: unknown;
168
+ try {
169
+ parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
170
+ } catch (error) {
171
+ throw new Error(`Invalid inherited capability ceiling: ${error instanceof Error ? error.message : String(error)}`);
172
+ }
173
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || (parsed as { version?: unknown }).version !== SUBAGENT_CAPABILITY_CEILING_VERSION) {
174
+ throw new Error("Invalid inherited capability ceiling version.");
175
+ }
176
+ return parseSubagentCapabilityCeiling(parsed, "inherited capability ceiling");
177
+ }
@@ -50,7 +50,7 @@ const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outpu
50
50
  const RUNNER_DYNAMIC_PARALLEL_KEYS = new Set([
51
51
  ...DYNAMIC_PARALLEL_KEYS,
52
52
  "outputName", "structured", "inheritProjectContext", "inheritSkills", "skills", "outputPath", "namespaceOutputPath", "maxSubagentDepth", "waitToolEnabled",
53
- "structuredOutput", "structuredOutputSchema", "tools", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "completionGuard", "systemPrompt",
53
+ "structuredOutput", "structuredOutputSchema", "tools", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "capabilityCeiling", "completionGuard", "systemPrompt",
54
54
  "systemPromptMode", "thinking", "modelCandidates", "sessionFile", "effectiveAcceptance", "acceptanceInput", "acceptanceRole", "parentSessionId",
55
55
  ]);
56
56
  const DYNAMIC_COLLECT_KEYS = new Set(["as", "outputSchema"]);
@@ -69,14 +69,16 @@ interface MetadataCache {
69
69
  servers: Record<string, ServerCacheEntry>;
70
70
  }
71
71
 
72
- export function resolveMcpDirectToolNames(mcpDirectTools: string[] | undefined, cwd = process.cwd()): string[] {
72
+ export interface ResolvedMcpDirectToolSelection { name: string; selector: string }
73
+
74
+ export function resolveMcpDirectToolSelections(mcpDirectTools: string[] | undefined, cwd = process.cwd()): ResolvedMcpDirectToolSelection[] {
73
75
  if (!mcpDirectTools?.length) return [];
74
76
 
75
77
  try {
76
78
  const config = loadMcpConfig(cwd);
77
79
  const cache = loadMetadataCache();
78
80
  if (!cache) return [];
79
- return resolveDirectToolNames(config, cache, getToolPrefix(config.settings?.toolPrefix), mcpDirectTools);
81
+ return resolveDirectToolSelections(config, cache, getToolPrefix(config.settings?.toolPrefix), mcpDirectTools);
80
82
  } catch {
81
83
  return [];
82
84
  }
@@ -195,8 +197,8 @@ function extractServers(config: unknown, kind: ImportKind): Record<string, Serve
195
197
  return servers && typeof servers === "object" && !Array.isArray(servers) ? servers as Record<string, ServerEntry> : {};
196
198
  }
197
199
 
198
- function resolveDirectToolNames(config: McpConfig, cache: MetadataCache, prefix: ToolPrefix, envOverride: string[]): string[] {
199
- const names: string[] = [];
200
+ function resolveDirectToolSelections(config: McpConfig, cache: MetadataCache, prefix: ToolPrefix, envOverride: string[]): ResolvedMcpDirectToolSelection[] {
201
+ const names: ResolvedMcpDirectToolSelection[] = [];
200
202
  const seenNames = new Set<string>();
201
203
  const { servers: selectedServers, tools: selectedTools } = parseSelections(envOverride);
202
204
 
@@ -216,7 +218,7 @@ function resolveDirectToolNames(config: McpConfig, cache: MetadataCache, prefix:
216
218
  const prefixedName = formatToolName(tool.name, serverName, prefix);
217
219
  if (BUILTIN_TOOL_NAMES.has(prefixedName) || seenNames.has(prefixedName)) continue;
218
220
  seenNames.add(prefixedName);
219
- names.push(prefixedName);
221
+ names.push({ name: prefixedName, selector: `${serverName}/${tool.name}` });
220
222
  }
221
223
 
222
224
  if (definition.exposeResources === false) continue;
@@ -228,13 +230,17 @@ function resolveDirectToolNames(config: McpConfig, cache: MetadataCache, prefix:
228
230
  const prefixedName = formatToolName(baseName, serverName, prefix);
229
231
  if (BUILTIN_TOOL_NAMES.has(prefixedName) || seenNames.has(prefixedName)) continue;
230
232
  seenNames.add(prefixedName);
231
- names.push(prefixedName);
233
+ names.push({ name: prefixedName, selector: `${serverName}/${baseName}` });
232
234
  }
233
235
  }
234
236
 
235
237
  return names;
236
238
  }
237
239
 
240
+ export function resolveMcpDirectToolNames(mcpDirectTools: string[] | undefined, cwd = process.cwd()): string[] {
241
+ return resolveMcpDirectToolSelections(mcpDirectTools, cwd).map((selection) => selection.name);
242
+ }
243
+
238
244
  function parseSelections(selections: string[]): { servers: Set<string>; tools: Map<string, Set<string>> } {
239
245
  const servers = new Set<string>();
240
246
  const tools = new Map<string, Set<string>>();
@@ -27,6 +27,7 @@ import {
27
27
  SUBAGENT_PARENT_RUN_ID_ENV,
28
28
  } from "./pi-args.ts";
29
29
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
30
+ import { sanitizeProcessTerminal } from "../background/process-terminal.ts";
30
31
 
31
32
  export const NESTED_EVENTS_DIR = path.join(TEMP_ROOT_DIR, "nested-subagent-events");
32
33
  const ROUTE_FILE = "route.json";
@@ -873,6 +874,9 @@ export function nestedSummaryFromAsyncStatus(status: AsyncStatus, asyncDir: stri
873
874
  ...(status.pid ? { pid: status.pid } : {}),
874
875
  ...(status.sessionId ? { sessionId: status.sessionId } : {}),
875
876
  mode: status.mode ?? fallback.mode,
877
+ ...(status.processTerminal ? { processTerminal: sanitizeProcessTerminal(status.processTerminal, { runId: status.runId || fallback.id, runnerProcessInstanceId: status.processTerminal.runnerProcessInstanceId }, `${asyncDir}/status.json`) } : {}),
878
+ ...(status.capabilityCeiling ? { capabilityCeiling: status.capabilityCeiling } : {}),
879
+ ...(status.capabilityAudit ? { capabilityAudit: status.capabilityAudit } : {}),
876
880
  state: status.state,
877
881
  ...(status.currentStep !== undefined ? { currentStep: status.currentStep } : {}),
878
882
  ...(status.chainStepCount !== undefined ? { chainStepCount: status.chainStepCount } : {}),
@@ -896,7 +900,7 @@ export function nestedSummaryFromAsyncStatus(status: AsyncStatus, asyncDir: stri
896
900
  ...(status.endedAt !== undefined ? { endedAt: status.endedAt } : {}),
897
901
  lastUpdate: status.lastUpdate ?? fallback.ts,
898
902
  ...(status.sessionFile ? { sessionFile: status.sessionFile } : {}),
899
- ...(status.steps?.length ? { steps: status.steps.map((step) => ({
903
+ ...(status.steps?.length ? { steps: status.steps.map((step, index) => ({
900
904
  agent: step.agent,
901
905
  status: step.status,
902
906
  ...(step.sessionFile ? { sessionFile: step.sessionFile } : {}),
@@ -915,6 +919,9 @@ export function nestedSummaryFromAsyncStatus(status: AsyncStatus, asyncDir: stri
915
919
  ...(step.turnBudget ? { turnBudget: step.turnBudget } : {}),
916
920
  ...(step.turnBudgetExceeded !== undefined ? { turnBudgetExceeded: step.turnBudgetExceeded } : {}),
917
921
  ...(step.wrapUpRequested !== undefined ? { wrapUpRequested: step.wrapUpRequested } : {}),
922
+ ...(step.processTerminal ? { processTerminal: sanitizeProcessTerminal(step.processTerminal, { runId: status.runId || fallback.id, runnerProcessInstanceId: step.processTerminal.runnerProcessInstanceId }, `${asyncDir}/status.json step ${index}`) } : {}),
923
+ ...(step.capabilityCeiling ? { capabilityCeiling: step.capabilityCeiling } : {}),
924
+ ...(step.capabilityAudit ? { capabilityAudit: step.capabilityAudit } : {}),
918
925
  })).slice(0, MAX_STEPS) } : {}),
919
926
  };
920
927
  }
@@ -43,11 +43,16 @@ export interface RunnerSubagentStep {
43
43
  };
44
44
  structuredOutputSchema?: import("../../shared/types.ts").JsonSchemaObject;
45
45
  agentContract?: import("../../shared/types.ts").AgentContract;
46
+ definitionDigest?: string;
47
+ launchBindingTask?: string;
48
+ launchContractDigest?: string;
46
49
  effectiveAcceptance?: import("../../shared/types.ts").ResolvedAcceptanceConfig;
47
50
  acceptanceInput?: import("../../shared/types.ts").AcceptanceInput;
48
51
  acceptanceRole?: import("../../shared/types.ts").AcceptanceRole;
49
52
  gateOn?: import("../../shared/types.ts").ChainGateLayer;
50
53
  toolBudget?: import("../../shared/types.ts").ResolvedToolBudget;
54
+ capabilityCeiling?: import("./capability-ceiling.ts").ResolvedSubagentCapabilityCeiling;
55
+ capabilityAudit?: import("./capability-ceiling.ts").SubagentCapabilityAudit;
51
56
  }
52
57
 
53
58
  export interface ParallelStepGroup {
@@ -3,16 +3,17 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { encodeNestedPathEnv, parseNestedPathEnv, type NestedPathEntry } from "./nested-path.ts";
6
- import { resolveMcpDirectToolNames } from "./mcp-direct-tool-allowlist.ts";
6
+ import { resolveMcpDirectToolSelections, type ResolvedMcpDirectToolSelection } from "./mcp-direct-tool-allowlist.ts";
7
7
  import { resolvePiPackageRoot } from "./pi-spawn.ts";
8
8
  import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV } from "./structured-output.ts";
9
9
  import { TEMP_ROOT_DIR, type JsonSchemaObject, type ResolvedToolBudget } from "../../shared/types.ts";
10
10
  import { THINKING_LEVELS } from "../../shared/model-info.ts";
11
11
  import { TOOL_BUDGET_ENV, TOOL_BUDGET_ZERO_AUTH_ENV, encodeToolBudgetEnv } from "./tool-budget.ts";
12
- import { CHILD_TOOL_DIAGNOSTIC_PATH_ENV, REQUIRED_CHILD_TOOLS_ENV } from "./tool-availability.ts";
12
+ import { CHILD_TOOL_DIAGNOSTIC_PATH_ENV, MCP_DIRECT_CHILD_TOOLS_ENV, REQUIRED_CHILD_TOOLS_ENV } from "./tool-availability.ts";
13
13
  import { CHILD_WATCHDOG_CONFIG_ENV, encodeChildWatchdogConfig, type ChildWatchdogConfig } from "../../watchdog/child-status.ts";
14
14
  import { WAIT_TOOL_ENABLED_ENV } from "../background/wait-config.ts";
15
15
  import { PI_CODING_AGENT_PACKAGE_ROOT_ENV } from "../../shared/utils.ts";
16
+ import { SUBAGENT_CAPABILITY_CEILING_ENV, decodeSubagentCapabilityCeiling, encodeSubagentCapabilityCeiling, intersectSubagentCapabilityCeilings, type ResolvedSubagentCapabilityCeiling, type SubagentCapabilityAudit } from "./capability-ceiling.ts";
16
17
 
17
18
  const TASK_ARG_LIMIT = 8000;
18
19
  const PROMPT_RUNTIME_EXTENSION_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "subagent-prompt-runtime.ts");
@@ -38,7 +39,7 @@ export const SUBAGENT_STEER_INBOX_ENV = "PI_SUBAGENT_STEER_INBOX";
38
39
  export const SUBAGENT_STEER_CAPABILITY_ENV = "PI_SUBAGENT_STEER_CAPABILITY";
39
40
  export const SUBAGENT_STEER_ACK_DIR_ENV = "PI_SUBAGENT_STEER_ACK_DIR";
40
41
 
41
- interface BuildPiArgsInput {
42
+ export interface BuildPiArgsInput {
42
43
  parentSessionId?: string;
43
44
  baseArgs: string[];
44
45
  task: string;
@@ -83,13 +84,15 @@ interface BuildPiArgsInput {
83
84
  allowZeroToolBudget?: boolean;
84
85
  childWatchdog?: ChildWatchdogConfig;
85
86
  waitToolEnabled?: boolean;
87
+ capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
86
88
  }
87
89
 
88
- interface BuildPiArgsResult {
90
+ export interface BuildPiArgsResult {
89
91
  args: string[];
90
92
  env: Record<string, string | undefined>;
91
93
  tempDir?: string;
92
94
  toolDiagnosticPath?: string;
95
+ capabilityAudit?: SubagentCapabilityAudit;
93
96
  }
94
97
 
95
98
  function sanitizeSupervisorChannelSegment(value: string): string {
@@ -109,6 +112,106 @@ export function applyThinkingSuffix(model: string | undefined, thinking: string
109
112
  return `${model}:${thinking}`;
110
113
  }
111
114
 
115
+ export interface ResolvePiLaunchToolPlanInput {
116
+ tools?: string[];
117
+ extensions?: string[];
118
+ subagentOnlyExtensions?: string[];
119
+ mcpDirectTools?: string[];
120
+ cwd?: string;
121
+ requireReadTool?: boolean;
122
+ structuredOutput?: boolean;
123
+ capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
124
+ inheritedCapabilityCeiling?: ResolvedSubagentCapabilityCeiling;
125
+ }
126
+
127
+ export interface PiLaunchToolPlan {
128
+ capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
129
+ requestedBuiltinTools: string[];
130
+ declaredBuiltinTools: string[];
131
+ toolExtensionPaths: string[];
132
+ resolvedMcpSelections: ResolvedMcpDirectToolSelection[];
133
+ effectiveMcpSelections: ResolvedMcpDirectToolSelection[];
134
+ effectiveMcpTools: string[];
135
+ explicitToolAllowlist: boolean;
136
+ internalTools: string[];
137
+ effectiveToolAllowlist: string[];
138
+ requiredChildTools: string[];
139
+ fanoutAuthorized: boolean;
140
+ runtimeExtensions: string[];
141
+ configuredExtensions: string[];
142
+ extensionArgs: string[];
143
+ disableAmbientExtensions: boolean;
144
+ capabilityAudit?: SubagentCapabilityAudit;
145
+ }
146
+
147
+ export function resolvePiLaunchToolPlan(input: ResolvePiLaunchToolPlanInput): PiLaunchToolPlan {
148
+ const capabilityCeiling = intersectSubagentCapabilityCeilings(input.capabilityCeiling, input.inheritedCapabilityCeiling);
149
+ const allowedToolSet = capabilityCeiling?.allowedTools === undefined ? undefined : new Set(capabilityCeiling.allowedTools);
150
+ const requestedBuiltinTools = input.tools?.filter((tool) => !(tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js"))) ?? [];
151
+ if (input.requireReadTool && allowedToolSet && !allowedToolSet.has("read")) {
152
+ throw new Error(`Capability ceiling from ${capabilityCeiling?.sources.join(", ") || "unknown source"} excludes required tool 'read' for lazy skill loading.`);
153
+ }
154
+ const declaredBuiltinTools = input.tools === undefined
155
+ ? (allowedToolSet ? [...allowedToolSet] : [])
156
+ : (input.requireReadTool && requestedBuiltinTools.length > 0 && !requestedBuiltinTools.includes("read") && !allowedToolSet
157
+ ? ["read", ...requestedBuiltinTools]
158
+ : requestedBuiltinTools).filter((tool) => !allowedToolSet || allowedToolSet.has(tool));
159
+ const fanoutAuthorized = declaredBuiltinTools.includes("subagent");
160
+ const toolExtensionPaths: string[] = capabilityCeiling?.denyExtensions ? [] : (input.tools ?? []).filter((tool) => !requestedBuiltinTools.includes(tool) && (tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js")));
161
+ const resolvedMcpSelections = capabilityCeiling?.denyExtensions ? [] : resolveMcpDirectToolSelections(input.mcpDirectTools, input.cwd);
162
+ const effectiveMcpSelections = resolvedMcpSelections.filter((selection) => !allowedToolSet || allowedToolSet.has(selection.name));
163
+ const effectiveMcpTools = effectiveMcpSelections.map((selection) => selection.name);
164
+ const explicitToolAllowlist = input.tools !== undefined || (input.mcpDirectTools?.length ?? 0) > 0 || allowedToolSet !== undefined;
165
+ const internalTools = input.structuredOutput ? ["structured_output"] : [];
166
+ const effectiveToolAllowlist = [...new Set([...declaredBuiltinTools, ...effectiveMcpTools, ...internalTools])];
167
+ const requiredChildTools = explicitToolAllowlist ? [...new Set([
168
+ ...(input.tools !== undefined ? declaredBuiltinTools : []),
169
+ ...(input.mcpDirectTools?.length ? effectiveMcpTools : []),
170
+ ...internalTools,
171
+ ])] : [];
172
+ const runtimeExtensions = fanoutAuthorized
173
+ ? [PROMPT_RUNTIME_EXTENSION_PATH, FANOUT_CHILD_EXTENSION_PATH]
174
+ : [PROMPT_RUNTIME_EXTENSION_PATH];
175
+ const disableAmbientExtensions = capabilityCeiling?.denyExtensions === true || input.extensions !== undefined;
176
+ const configuredExtensions = capabilityCeiling?.denyExtensions ? [] : [...toolExtensionPaths, ...(input.extensions ?? []), ...(input.subagentOnlyExtensions ?? [])];
177
+ const extensionArgs = disableAmbientExtensions
178
+ ? [...new Set([...runtimeExtensions, ...configuredExtensions])]
179
+ : [...new Set([...runtimeExtensions, ...toolExtensionPaths, ...(input.subagentOnlyExtensions ?? [])])];
180
+ const requestedToolNames = input.tools !== undefined
181
+ ? [...new Set([...requestedBuiltinTools, ...resolvedMcpSelections.map((selection) => selection.name)])]
182
+ : undefined;
183
+ const capabilityAudit = capabilityCeiling ? {
184
+ ceiling: capabilityCeiling,
185
+ ...(requestedToolNames ? { requestedTools: requestedToolNames } : {}),
186
+ effectiveTools: effectiveToolAllowlist,
187
+ removedTools: requestedToolNames?.filter((tool) => !effectiveToolAllowlist.includes(tool)) ?? [],
188
+ internalTools,
189
+ extensionsDenied: capabilityCeiling.denyExtensions,
190
+ removedExtensionCount: capabilityCeiling.denyExtensions ? (input.extensions?.length ?? 0) + (input.subagentOnlyExtensions?.length ?? 0) + ((input.tools ?? []).filter((tool) => tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js")).length) : 0,
191
+ requestedMcpToolCount: input.mcpDirectTools?.length ?? 0,
192
+ effectiveMcpTools,
193
+ } satisfies SubagentCapabilityAudit : undefined;
194
+ return {
195
+ ...(capabilityCeiling ? { capabilityCeiling } : {}),
196
+ requestedBuiltinTools,
197
+ declaredBuiltinTools,
198
+ toolExtensionPaths,
199
+ resolvedMcpSelections,
200
+ effectiveMcpSelections,
201
+ effectiveMcpTools,
202
+ explicitToolAllowlist,
203
+ internalTools,
204
+ effectiveToolAllowlist,
205
+ requiredChildTools,
206
+ fanoutAuthorized,
207
+ runtimeExtensions,
208
+ configuredExtensions,
209
+ extensionArgs,
210
+ disableAmbientExtensions,
211
+ ...(capabilityAudit ? { capabilityAudit } : {}),
212
+ };
213
+ }
214
+
112
215
  export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
113
216
  const args = [...input.baseArgs];
114
217
 
@@ -130,46 +233,25 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
130
233
  args.push("--model", modelArg);
131
234
  }
132
235
 
133
- const explicitToolAllowlist = input.tools !== undefined || (input.mcpDirectTools?.length ?? 0) > 0;
134
- const declaredBuiltinToolsBase = input.tools?.filter((tool) => !(tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js"))) ?? [];
135
- const declaredBuiltinTools = input.requireReadTool && declaredBuiltinToolsBase.length > 0 && !declaredBuiltinToolsBase.includes("read")
136
- ? ["read", ...declaredBuiltinToolsBase]
137
- : declaredBuiltinToolsBase;
138
- const fanoutAuthorized = declaredBuiltinTools.includes("subagent");
139
- const toolExtensionPaths: string[] = [];
140
- let requiredChildTools: string[] = [];
141
- for (const tool of input.tools ?? []) {
142
- if (!declaredBuiltinTools.includes(tool) && (tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js"))) {
143
- toolExtensionPaths.push(tool);
144
- }
236
+ const toolPlan = resolvePiLaunchToolPlan({
237
+ tools: input.tools,
238
+ extensions: input.extensions,
239
+ subagentOnlyExtensions: input.subagentOnlyExtensions,
240
+ mcpDirectTools: input.mcpDirectTools,
241
+ cwd: input.cwd,
242
+ requireReadTool: input.requireReadTool,
243
+ structuredOutput: input.structuredOutput,
244
+ capabilityCeiling: input.capabilityCeiling,
245
+ inheritedCapabilityCeiling: decodeSubagentCapabilityCeiling(process.env[SUBAGENT_CAPABILITY_CEILING_ENV]),
246
+ });
247
+ if (toolPlan.explicitToolAllowlist) {
248
+ args.push(toolPlan.effectiveToolAllowlist.length > 0 ? "--tools" : "--no-tools");
249
+ if (toolPlan.effectiveToolAllowlist.length > 0) args.push(toolPlan.effectiveToolAllowlist.join(","));
145
250
  }
146
- if (explicitToolAllowlist) {
147
- const allowedTools = [
148
- ...declaredBuiltinTools,
149
- ...resolveMcpDirectToolNames(input.mcpDirectTools, input.cwd),
150
- ...(input.structuredOutput ? ["structured_output"] : []),
151
- ];
152
- requiredChildTools = [...new Set(allowedTools)];
153
- if (requiredChildTools.length > 0) {
154
- args.push("--tools", requiredChildTools.join(","));
155
- } else {
156
- args.push("--no-tools");
157
- }
158
- }
159
-
160
- const runtimeExtensions = fanoutAuthorized
161
- ? [PROMPT_RUNTIME_EXTENSION_PATH, FANOUT_CHILD_EXTENSION_PATH]
162
- : [PROMPT_RUNTIME_EXTENSION_PATH];
163
- if (input.extensions !== undefined) {
251
+ if (toolPlan.disableAmbientExtensions) {
164
252
  args.push("--no-extensions");
165
- for (const extPath of [...new Set([...runtimeExtensions, ...toolExtensionPaths, ...input.extensions, ...(input.subagentOnlyExtensions ?? [])])]) {
166
- args.push("--extension", extPath);
167
- }
168
- } else {
169
- for (const extPath of [...new Set([...runtimeExtensions, ...toolExtensionPaths, ...(input.subagentOnlyExtensions ?? [])])]) {
170
- args.push("--extension", extPath);
171
- }
172
253
  }
254
+ for (const extPath of toolPlan.extensionArgs) args.push("--extension", extPath);
173
255
 
174
256
  if (!input.inheritSkills) {
175
257
  args.push("--no-skills");
@@ -199,14 +281,15 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
199
281
  const piPackageRoot = process.env[PI_CODING_AGENT_PACKAGE_ROOT_ENV] ?? resolvePiPackageRoot();
200
282
  if (piPackageRoot) env[PI_CODING_AGENT_PACKAGE_ROOT_ENV] = piPackageRoot;
201
283
  let toolDiagnosticPath: string | undefined;
202
- if (requiredChildTools.length > 0) {
284
+ if (toolPlan.requiredChildTools.length > 0) {
203
285
  if (!tempDir) tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
204
286
  toolDiagnosticPath = path.join(tempDir, "tool-diagnostic.json");
205
- env[REQUIRED_CHILD_TOOLS_ENV] = JSON.stringify(requiredChildTools);
287
+ env[REQUIRED_CHILD_TOOLS_ENV] = JSON.stringify(toolPlan.requiredChildTools);
206
288
  env[CHILD_TOOL_DIAGNOSTIC_PATH_ENV] = toolDiagnosticPath;
207
289
  }
290
+ env[MCP_DIRECT_CHILD_TOOLS_ENV] = toolPlan.effectiveMcpTools.length > 0 ? JSON.stringify(toolPlan.effectiveMcpTools) : undefined;
208
291
  env[SUBAGENT_CHILD_ENV] = "1";
209
- env[SUBAGENT_FANOUT_CHILD_ENV] = fanoutAuthorized ? "1" : "0";
292
+ env[SUBAGENT_FANOUT_CHILD_ENV] = toolPlan.fanoutAuthorized ? "1" : "0";
210
293
  if (input.waitToolEnabled !== undefined) {
211
294
  env[WAIT_TOOL_ENABLED_ENV] = input.waitToolEnabled ? "true" : "false";
212
295
  }
@@ -227,20 +310,20 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
227
310
  ...(input.childAgentName ? { agent: input.childAgentName } : {}),
228
311
  }] : []),
229
312
  ];
230
- env[SUBAGENT_PARENT_EVENT_SINK_ENV] = fanoutAuthorized
313
+ env[SUBAGENT_PARENT_EVENT_SINK_ENV] = toolPlan.fanoutAuthorized
231
314
  ? input.parentEventSink ?? process.env[SUBAGENT_PARENT_EVENT_SINK_ENV] ?? ""
232
315
  : "";
233
- env[SUBAGENT_PARENT_CONTROL_INBOX_ENV] = fanoutAuthorized
316
+ env[SUBAGENT_PARENT_CONTROL_INBOX_ENV] = toolPlan.fanoutAuthorized
234
317
  ? input.parentControlInbox ?? process.env[SUBAGENT_PARENT_CONTROL_INBOX_ENV] ?? ""
235
318
  : "";
236
- env[SUBAGENT_PARENT_ROOT_RUN_ID_ENV] = fanoutAuthorized
319
+ env[SUBAGENT_PARENT_ROOT_RUN_ID_ENV] = toolPlan.fanoutAuthorized
237
320
  ? input.parentRootRunId ?? process.env[SUBAGENT_PARENT_ROOT_RUN_ID_ENV] ?? input.runId ?? ""
238
321
  : "";
239
- env[SUBAGENT_PARENT_RUN_ID_ENV] = fanoutAuthorized ? parentRunId : "";
240
- env[SUBAGENT_PARENT_CHILD_INDEX_ENV] = fanoutAuthorized ? parentChildIndex : "";
241
- env[SUBAGENT_PARENT_DEPTH_ENV] = fanoutAuthorized ? String(parentDepth) : "";
242
- env[SUBAGENT_PARENT_PATH_ENV] = fanoutAuthorized ? encodeNestedPathEnv(parentPath) : "";
243
- env[SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV] = fanoutAuthorized
322
+ env[SUBAGENT_PARENT_RUN_ID_ENV] = toolPlan.fanoutAuthorized ? parentRunId : "";
323
+ env[SUBAGENT_PARENT_CHILD_INDEX_ENV] = toolPlan.fanoutAuthorized ? parentChildIndex : "";
324
+ env[SUBAGENT_PARENT_DEPTH_ENV] = toolPlan.fanoutAuthorized ? String(parentDepth) : "";
325
+ env[SUBAGENT_PARENT_PATH_ENV] = toolPlan.fanoutAuthorized ? encodeNestedPathEnv(parentPath) : "";
326
+ env[SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV] = toolPlan.fanoutAuthorized
244
327
  ? input.parentCapabilityToken ?? process.env[SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV] ?? ""
245
328
  : "";
246
329
  env.PI_SUBAGENT_INHERIT_PROJECT_CONTEXT = input.inheritProjectContext ? "1" : "0";
@@ -270,11 +353,11 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
270
353
  if (input.childIndex !== undefined) {
271
354
  env[SUBAGENT_CHILD_INDEX_ENV] = String(input.childIndex);
272
355
  }
273
- if (input.mcpDirectTools?.length) {
274
- env.MCP_DIRECT_TOOLS = input.mcpDirectTools.join(",");
275
- } else {
276
- env.MCP_DIRECT_TOOLS = "__none__";
277
- }
356
+ if (!toolPlan.capabilityCeiling && input.mcpDirectTools?.length) env.MCP_DIRECT_TOOLS = input.mcpDirectTools.join(",");
357
+ else if (toolPlan.capabilityCeiling && toolPlan.effectiveMcpSelections.length && !toolPlan.capabilityCeiling.denyExtensions) env.MCP_DIRECT_TOOLS = toolPlan.effectiveMcpSelections.map((selection) => selection.selector).join(",");
358
+ else env.MCP_DIRECT_TOOLS = "__none__";
359
+ const encodedCapabilityCeiling = encodeSubagentCapabilityCeiling(toolPlan.capabilityCeiling);
360
+ if (encodedCapabilityCeiling) env[SUBAGENT_CAPABILITY_CEILING_ENV] = encodedCapabilityCeiling;
278
361
  if (input.structuredOutput) {
279
362
  env[STRUCTURED_OUTPUT_CAPTURE_ENV] = input.structuredOutput.outputPath;
280
363
  env[STRUCTURED_OUTPUT_SCHEMA_ENV] = input.structuredOutput.schemaPath;
@@ -292,7 +375,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
292
375
 
293
376
  env[SUBAGENT_PARENT_SESSION_ENV] = input.parentSessionId ?? process.env[SUBAGENT_PARENT_SESSION_ENV] ?? "";
294
377
 
295
- return { args, env, tempDir, toolDiagnosticPath };
378
+ return { args, env, tempDir, toolDiagnosticPath, capabilityAudit: toolPlan.capabilityAudit };
296
379
  }
297
380
 
298
381
  export const parseParentPathEnv = parseNestedPathEnv;
@@ -37,9 +37,14 @@ export interface SessionLeaseHandle {
37
37
  leaseDir: string;
38
38
  owner: SessionLeaseOwner;
39
39
  updateWriter(writer: { state: "none" | "spawning" } | { state: "running"; pid: number }): void;
40
- release(): void;
40
+ release(): boolean;
41
41
  }
42
42
 
43
+ export type SessionLeaseState =
44
+ | { state: "free"; canonicalSessionFile: string; canonicalSessionId: string }
45
+ | { state: "owned"; canonicalSessionFile: string; canonicalSessionId: string; owner: SessionLeaseOwner }
46
+ | { state: "unreadable"; canonicalSessionFile: string; canonicalSessionId: string };
47
+
43
48
  interface SessionLeaseOptions {
44
49
  rootDir?: string;
45
50
  now?: () => number;
@@ -98,11 +103,25 @@ export function canonicalSessionFilePath(sessionFile: string): string {
98
103
  return fs.realpathSync.native(path.resolve(sessionFile));
99
104
  }
100
105
 
101
- export function sessionLeaseDir(sessionFile: string, rootDir = SESSION_LEASES_DIR): string {
106
+ export function canonicalSessionId(sessionFile: string): string {
102
107
  const canonical = canonicalSessionFilePath(sessionFile);
103
108
  const key = process.platform === "win32" ? canonical.toLowerCase() : canonical;
104
- const digest = createHash("sha256").update(key).digest("hex");
105
- return path.join(rootDir, digest);
109
+ return createHash("sha256").update(key).digest("hex");
110
+ }
111
+
112
+ export function sessionLeaseDir(sessionFile: string, rootDir = SESSION_LEASES_DIR): string {
113
+ return path.join(rootDir, canonicalSessionId(sessionFile));
114
+ }
115
+
116
+ export function inspectSessionLease(sessionFile: string, rootDir = SESSION_LEASES_DIR): SessionLeaseState {
117
+ const canonicalSessionFile = canonicalSessionFilePath(sessionFile);
118
+ const canonicalSessionIdValue = canonicalSessionId(canonicalSessionFile);
119
+ const leaseDir = path.join(rootDir, canonicalSessionIdValue);
120
+ if (!fs.existsSync(leaseDir)) return { state: "free", canonicalSessionFile, canonicalSessionId: canonicalSessionIdValue };
121
+ const owner = readLeaseOwner(leaseDir);
122
+ return owner
123
+ ? { state: "owned", canonicalSessionFile, canonicalSessionId: canonicalSessionIdValue, owner }
124
+ : { state: "unreadable", canonicalSessionFile, canonicalSessionId: canonicalSessionIdValue };
106
125
  }
107
126
 
108
127
  function parseOwner(value: unknown): SessionLeaseOwner | undefined {
@@ -251,8 +270,9 @@ export function acquireSessionLease(request: SessionLeaseRequest, options: Sessi
251
270
  },
252
271
  release() {
253
272
  const currentOwner = readLeaseOwner(leaseDir);
254
- if (!currentOwner || currentOwner.token !== owner.token) return;
273
+ if (!currentOwner || currentOwner.token !== owner.token) return false;
255
274
  fs.rmSync(leaseDir, { recursive: true, force: true });
275
+ return !fs.existsSync(leaseDir);
256
276
  },
257
277
  };
258
278
  }