pi-subagents 0.36.0 → 0.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +111 -16
  3. package/agents/delegate.md +2 -0
  4. package/agents/planner.md +2 -1
  5. package/agents/worker.md +2 -0
  6. package/package.json +6 -4
  7. package/skills/pi-subagents/SKILL.md +18 -973
  8. package/skills/pi-subagents/references/constraints-and-recipes.md +256 -0
  9. package/skills/pi-subagents/references/execution-controls.md +411 -0
  10. package/skills/pi-subagents/references/management-authoring-rpc.md +140 -0
  11. package/skills/pi-subagents/references/prompting-and-roles.md +268 -0
  12. package/src/agents/agent-management.ts +7 -1
  13. package/src/agents/agents.ts +99 -12
  14. package/src/api/capability-ceiling.ts +17 -0
  15. package/src/api/delegation.ts +6 -1
  16. package/src/api/preflight.ts +399 -0
  17. package/src/extension/index.ts +16 -7
  18. package/src/extension/rpc.ts +29 -2
  19. package/src/extension/schemas.ts +10 -4
  20. package/src/extension/tool-description.ts +4 -2
  21. package/src/intercom/intercom-bridge.ts +5 -2
  22. package/src/runs/background/async-execution.ts +125 -14
  23. package/src/runs/background/async-job-tracker.ts +19 -12
  24. package/src/runs/background/async-resume.ts +27 -9
  25. package/src/runs/background/async-status.ts +18 -0
  26. package/src/runs/background/notify.ts +3 -0
  27. package/src/runs/background/process-terminal.ts +280 -0
  28. package/src/runs/background/result-watcher.ts +9 -3
  29. package/src/runs/background/run-status.ts +7 -1
  30. package/src/runs/background/scheduled-runs.ts +6 -1
  31. package/src/runs/background/stale-run-reconciler.ts +6 -0
  32. package/src/runs/background/subagent-runner.ts +182 -12
  33. package/src/runs/foreground/chain-execution.ts +5 -0
  34. package/src/runs/foreground/execution.ts +59 -13
  35. package/src/runs/foreground/subagent-executor.ts +142 -47
  36. package/src/runs/shared/acceptance.ts +41 -30
  37. package/src/runs/shared/capability-ceiling.ts +177 -0
  38. package/src/runs/shared/dynamic-fanout.ts +1 -1
  39. package/src/runs/shared/mcp-direct-tool-allowlist.ts +56 -17
  40. package/src/runs/shared/model-fallback.ts +8 -0
  41. package/src/runs/shared/nested-events.ts +8 -1
  42. package/src/runs/shared/parallel-utils.ts +5 -0
  43. package/src/runs/shared/pi-args.ts +141 -58
  44. package/src/runs/shared/session-lease.ts +25 -5
  45. package/src/runs/shared/subagent-prompt-runtime.ts +14 -1
  46. package/src/runs/shared/task-intent.ts +1 -1
  47. package/src/runs/shared/tool-availability.ts +18 -2
  48. package/src/shared/launch-contract.ts +123 -0
  49. package/src/shared/types.ts +118 -8
  50. package/src/shared/utils.ts +17 -42
  51. package/src/slash/delegation-adapters.ts +6 -1
  52. package/src/slash/slash-commands.ts +1 -1
  53. package/src/tui/fleet-status.ts +62 -16
  54. package/src/tui/fleet.ts +1 -1
@@ -29,10 +29,9 @@ const LEVEL_RANK: Record<Exclude<AcceptanceLevel, "auto">, number> = {
29
29
  attested: 1,
30
30
  checked: 2,
31
31
  verified: 3,
32
- reviewed: 4,
33
32
  };
34
33
 
35
- const VALID_LEVELS = new Set<AcceptanceLevel>(["auto", "none", "attested", "checked", "verified", "reviewed"]);
34
+ const VALID_LEVELS = new Set<AcceptanceLevel>(["auto", "none", "attested", "checked", "verified"]);
36
35
  const VALID_EVIDENCE = new Set<AcceptanceEvidenceKind>([
37
36
  "changed-files",
38
37
  "tests-added",
@@ -48,7 +47,7 @@ const ACCEPTANCE_CONFIG_KEYS = new Set(["level", "criteria", "evidence", "verify
48
47
  const ACCEPTANCE_GATE_KEYS = new Set(["id", "must", "evidence", "severity"]);
49
48
  const ACCEPTANCE_VERIFY_KEYS = new Set(["id", "command", "timeoutMs", "cwd", "env", "allowFailure"]);
50
49
  const ACCEPTANCE_REVIEW_KEYS = new Set(["agent", "focus", "required"]);
51
- const EXPLICIT_REVIEWED_UNAVAILABLE = "cannot be requested explicitly because this run cannot supply an independent reviewer result; use checked/verified and orchestrate the reviewer separately, or omit acceptance for read-only review tasks.";
50
+ const EXPLICIT_REVIEWED_UNAVAILABLE = "is an achieved status, not a requestable acceptance level. For a read-only reviewer call, omit acceptance. To require independent review of a writer result, use acceptance.review.required and orchestrate the reviewer separately.";
52
51
 
53
52
  function normalizeLevel(level: AcceptanceLevel | undefined): Exclude<AcceptanceLevel, "auto"> | "auto" {
54
53
  return level ?? "auto";
@@ -67,7 +66,6 @@ function requiredEvidenceForLevel(level: Exclude<AcceptanceLevel, "auto">): Acce
67
66
  case "checked":
68
67
  return ["changed-files", "tests-added", "commands-run", "residual-risks", "no-staged-files"];
69
68
  case "verified":
70
- case "reviewed":
71
69
  return ["changed-files", "tests-added", "commands-run", "validation-output", "residual-risks", "no-staged-files"];
72
70
  }
73
71
  }
@@ -110,10 +108,10 @@ function inferLevel(input: {
110
108
  reasons.push(input.async ? "async write-capable or risky run" : "risky write-capable run");
111
109
  if (input.dynamic || input.dynamicGroup) reasons.push("dynamic fanout context");
112
110
  return {
113
- level: "reviewed",
111
+ level: "checked",
114
112
  reasons,
115
113
  criteria: ["Implement the requested change without widening scope", "Return evidence sufficient for an independent acceptance review"],
116
- evidence: requiredEvidenceForLevel("reviewed"),
114
+ evidence: requiredEvidenceForLevel("checked"),
117
115
  review: { agent: "reviewer", required: true },
118
116
  };
119
117
  }
@@ -160,9 +158,9 @@ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"
160
158
  if (input === undefined) return errors;
161
159
  if (input === false) return errors;
162
160
  if (typeof input === "string") {
163
- if (!VALID_LEVELS.has(input as AcceptanceLevel)) errors.push(`${pathLabel} has invalid level '${input}'.`);
161
+ if (input === "reviewed") errors.push(`${pathLabel} ${EXPLICIT_REVIEWED_UNAVAILABLE}`);
162
+ else if (!VALID_LEVELS.has(input as AcceptanceLevel)) errors.push(`${pathLabel} has invalid level '${input}'.`);
164
163
  else if (input === "none") errors.push(`${pathLabel} level "none" requires a reason; use { level: "none", reason: "..." }.`);
165
- else if (input === "reviewed") errors.push(`${pathLabel} ${EXPLICIT_REVIEWED_UNAVAILABLE}`);
166
164
  return errors;
167
165
  }
168
166
  if (!input || typeof input !== "object" || Array.isArray(input)) {
@@ -173,13 +171,14 @@ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"
173
171
  for (const key of Object.keys(value)) {
174
172
  if (!ACCEPTANCE_CONFIG_KEYS.has(key)) errors.push(`${pathLabel}.${key} is not supported.`);
175
173
  }
176
- if (value.level !== undefined && (typeof value.level !== "string" || !VALID_LEVELS.has(value.level as AcceptanceLevel))) {
177
- errors.push(`${pathLabel}.level must be one of auto, none, attested, checked, verified, reviewed.`);
174
+ if (value.level === "reviewed") {
175
+ errors.push(`${pathLabel}.level ${EXPLICIT_REVIEWED_UNAVAILABLE}`);
176
+ } else if (value.level !== undefined && (typeof value.level !== "string" || !VALID_LEVELS.has(value.level as AcceptanceLevel))) {
177
+ errors.push(`${pathLabel}.level must be one of auto, none, attested, checked, verified.`);
178
178
  }
179
179
  if (value.level === "none" && (typeof value.reason !== "string" || !value.reason.trim())) {
180
180
  errors.push(`${pathLabel}.reason is required when level is none.`);
181
181
  }
182
- if (value.level === "reviewed") errors.push(`${pathLabel}.level ${EXPLICIT_REVIEWED_UNAVAILABLE}`);
183
182
  if (value.reason !== undefined && typeof value.reason !== "string") errors.push(`${pathLabel}.reason must be a string.`);
184
183
  if (value.criteria !== undefined && !Array.isArray(value.criteria)) errors.push(`${pathLabel}.criteria must be an array.`);
185
184
  if (Array.isArray(value.criteria)) {
@@ -362,10 +361,7 @@ export function resolveEffectiveAcceptance(input: {
362
361
  (explicit.criteria?.length ? explicit.criteria : inferred.criteria) as Array<string | { id?: string; must?: string; evidence?: AcceptanceEvidenceKind[]; severity?: "required" | "recommended" }>,
363
362
  evidence,
364
363
  );
365
- let review = explicit.review !== undefined ? explicit.review : inferred.review;
366
- if (level === "reviewed" && input.explicit !== undefined && explicitLevel !== "reviewed" && explicit.review === undefined && review && review !== false) {
367
- review = { ...review, required: false };
368
- }
364
+ const review = explicit.review !== undefined ? explicit.review : inferred.review;
369
365
  return {
370
366
  level,
371
367
  explicit: input.explicit !== undefined,
@@ -1061,8 +1057,10 @@ export async function evaluateAcceptance(input: {
1061
1057
  reportOptional?: boolean;
1062
1058
  }): Promise<AcceptanceLedger> {
1063
1059
  const acceptance = input.acceptance;
1060
+ const initialStatus = acceptance.level === "none" ? "not-required" : "claimed";
1064
1061
  const ledger: AcceptanceLedger = {
1065
- status: acceptance.level === "none" ? "not-required" : "claimed",
1062
+ status: initialStatus,
1063
+ evidenceStatus: initialStatus,
1066
1064
  explicit: acceptance.explicit,
1067
1065
  effectiveAcceptance: acceptance,
1068
1066
  inferredReason: acceptance.inferredReason,
@@ -1084,11 +1082,13 @@ export async function evaluateAcceptance(input: {
1084
1082
  if (parsed.report) {
1085
1083
  ledger.childReport = parsed.report;
1086
1084
  ledger.status = "attested";
1085
+ ledger.evidenceStatus = "attested";
1087
1086
  } else if (!input.reportOptional || needsReport || parsed.error !== ACCEPTANCE_REPORT_NOT_FOUND) {
1088
1087
  ledger.childReportParseError = parsed.error;
1089
1088
  ledger.runtimeChecks.push({ id: "attestation", status: "failed", message: parsed.error ?? "Structured acceptance report missing." });
1090
1089
  if (!input.reportOptional) {
1091
1090
  ledger.status = "rejected";
1091
+ ledger.evidenceStatus = "rejected";
1092
1092
  return ledger;
1093
1093
  }
1094
1094
  } else {
@@ -1103,6 +1103,7 @@ export async function evaluateAcceptance(input: {
1103
1103
  ];
1104
1104
  if (!ledger.runtimeChecks.some((check) => check.status === "failed")) {
1105
1105
  ledger.status = "checked";
1106
+ ledger.evidenceStatus = "checked";
1106
1107
  }
1107
1108
  }
1108
1109
 
@@ -1110,6 +1111,7 @@ export async function evaluateAcceptance(input: {
1110
1111
  if (acceptance.level === "verified" && acceptance.verify.length === 0) {
1111
1112
  ledger.runtimeChecks.push({ id: "verification-config", status: "failed", message: "verified acceptance requires runtime verify commands." });
1112
1113
  ledger.status = "rejected";
1114
+ ledger.evidenceStatus = "rejected";
1113
1115
  return ledger;
1114
1116
  }
1115
1117
  ledger.verifyRuns = [];
@@ -1119,34 +1121,42 @@ export async function evaluateAcceptance(input: {
1119
1121
  }
1120
1122
  if (ledger.verifyRuns.some((run) => run.status === "failed" || run.status === "timed-out")) {
1121
1123
  ledger.status = "rejected";
1124
+ ledger.evidenceStatus = "rejected";
1122
1125
  return ledger;
1123
1126
  }
1124
- if (!ledger.runtimeChecks.some((check) => check.status === "failed")) ledger.status = "verified";
1127
+ if (!ledger.runtimeChecks.some((check) => check.status === "failed")) {
1128
+ ledger.status = "verified";
1129
+ ledger.evidenceStatus = "verified";
1130
+ }
1125
1131
  }
1126
1132
 
1127
1133
  if (ledger.runtimeChecks.some((check) => check.status === "failed")) {
1128
1134
  ledger.status = "rejected";
1135
+ ledger.evidenceStatus = "rejected";
1129
1136
  return ledger;
1130
1137
  }
1131
- if (ledger.status === "claimed" && acceptance.level !== "reviewed") {
1138
+ if (ledger.status === "claimed") {
1132
1139
  ledger.status = acceptance.level === "verified" ? "verified" : acceptance.level;
1140
+ ledger.evidenceStatus = ledger.status;
1133
1141
  }
1134
1142
 
1135
- if (acceptance.level === "reviewed") {
1136
- if (input.reviewResult) {
1143
+ if (acceptance.review && acceptance.review !== false) {
1144
+ if (input.reviewResult?.status === "reviewed") {
1137
1145
  ledger.reviewResult = input.reviewResult;
1138
- ledger.status = input.reviewResult.status === "no-blockers" ? "reviewed" : "rejected";
1139
- } else {
1140
- const optionalReview = acceptance.review && acceptance.review !== false && acceptance.review.required === false;
1141
- ledger.reviewResult = {
1142
- status: "needs-parent-decision",
1146
+ ledger.status = "reviewed";
1147
+ } else if (input.reviewResult?.status === "blockers") {
1148
+ ledger.reviewResult = input.reviewResult;
1149
+ ledger.status = "rejected";
1150
+ } else if (acceptance.review.required !== false) {
1151
+ ledger.reviewResult = input.reviewResult ?? {
1152
+ status: "review-required",
1143
1153
  findings: [{
1144
- severity: acceptance.explicit && !optionalReview ? "blocker" : "non-blocking",
1145
- issue: "Reviewed acceptance requires an independent reviewer result.",
1154
+ severity: "non-blocking",
1155
+ issue: "Independent review has not been supplied.",
1146
1156
  rationale: "The run cannot be marked reviewed from child evidence alone.",
1147
1157
  }],
1148
1158
  };
1149
- if (acceptance.review === false || (acceptance.explicit && !optionalReview)) ledger.status = "rejected";
1159
+ ledger.status = "review-required";
1150
1160
  }
1151
1161
  }
1152
1162
 
@@ -1154,8 +1164,10 @@ export async function evaluateAcceptance(input: {
1154
1164
  }
1155
1165
 
1156
1166
  export function buildSkippedAcceptanceLedger(acceptance: ResolvedAcceptanceConfig, input: { id: string; message: string }): AcceptanceLedger {
1167
+ const status = acceptance.level === "none" ? "not-required" : "rejected";
1157
1168
  return {
1158
- status: acceptance.level === "none" ? "not-required" : "rejected",
1169
+ status,
1170
+ evidenceStatus: status,
1159
1171
  explicit: acceptance.explicit,
1160
1172
  effectiveAcceptance: acceptance,
1161
1173
  inferredReason: acceptance.inferredReason,
@@ -1173,7 +1185,6 @@ export function acceptanceFailureMessage(ledger: AcceptanceLedger): string | und
1173
1185
  if (failedCheck) return `Acceptance rejected: ${failedCheck.message}`;
1174
1186
  const failedVerify = ledger.verifyRuns.find((run) => run.status === "failed" || run.status === "timed-out");
1175
1187
  if (failedVerify) return `Acceptance verification '${failedVerify.id}' ${failedVerify.status}.`;
1176
- if (ledger.reviewResult?.status === "needs-parent-decision") return "Acceptance review required but no automatic reviewer result is available.";
1177
1188
  if (ledger.reviewResult?.status === "blockers") return "Acceptance review found blockers.";
1178
1189
  return "Acceptance rejected.";
1179
1190
  }
@@ -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"]);
@@ -27,6 +27,7 @@ type ImportKind = keyof typeof IMPORT_PATHS;
27
27
  interface ServerEntry {
28
28
  command?: string;
29
29
  args?: string[];
30
+ socket?: string;
30
31
  env?: Record<string, string>;
31
32
  cwd?: string;
32
33
  url?: string;
@@ -35,6 +36,7 @@ interface ServerEntry {
35
36
  bearerToken?: string;
36
37
  bearerTokenEnv?: string;
37
38
  exposeResources?: boolean;
39
+ includeTools?: string[];
38
40
  excludeTools?: string[];
39
41
  directTools?: boolean | string[];
40
42
  }
@@ -69,14 +71,16 @@ interface MetadataCache {
69
71
  servers: Record<string, ServerCacheEntry>;
70
72
  }
71
73
 
72
- export function resolveMcpDirectToolNames(mcpDirectTools: string[] | undefined, cwd = process.cwd()): string[] {
74
+ export interface ResolvedMcpDirectToolSelection { name: string; selector: string }
75
+
76
+ export function resolveMcpDirectToolSelections(mcpDirectTools: string[] | undefined, cwd = process.cwd()): ResolvedMcpDirectToolSelection[] {
73
77
  if (!mcpDirectTools?.length) return [];
74
78
 
75
79
  try {
76
80
  const config = loadMcpConfig(cwd);
77
81
  const cache = loadMetadataCache();
78
82
  if (!cache) return [];
79
- return resolveDirectToolNames(config, cache, getToolPrefix(config.settings?.toolPrefix), mcpDirectTools);
83
+ return resolveDirectToolSelections(config, cache, getToolPrefix(config.settings?.toolPrefix), mcpDirectTools);
80
84
  } catch {
81
85
  return [];
82
86
  }
@@ -195,8 +199,8 @@ function extractServers(config: unknown, kind: ImportKind): Record<string, Serve
195
199
  return servers && typeof servers === "object" && !Array.isArray(servers) ? servers as Record<string, ServerEntry> : {};
196
200
  }
197
201
 
198
- function resolveDirectToolNames(config: McpConfig, cache: MetadataCache, prefix: ToolPrefix, envOverride: string[]): string[] {
199
- const names: string[] = [];
202
+ function resolveDirectToolSelections(config: McpConfig, cache: MetadataCache, prefix: ToolPrefix, envOverride: string[]): ResolvedMcpDirectToolSelection[] {
203
+ const names: ResolvedMcpDirectToolSelection[] = [];
200
204
  const seenNames = new Set<string>();
201
205
  const { servers: selectedServers, tools: selectedTools } = parseSelections(envOverride);
202
206
 
@@ -216,7 +220,7 @@ function resolveDirectToolNames(config: McpConfig, cache: MetadataCache, prefix:
216
220
  const prefixedName = formatToolName(tool.name, serverName, prefix);
217
221
  if (BUILTIN_TOOL_NAMES.has(prefixedName) || seenNames.has(prefixedName)) continue;
218
222
  seenNames.add(prefixedName);
219
- names.push(prefixedName);
223
+ names.push({ name: prefixedName, selector: `${serverName}/${tool.name}` });
220
224
  }
221
225
 
222
226
  if (definition.exposeResources === false) continue;
@@ -228,13 +232,17 @@ function resolveDirectToolNames(config: McpConfig, cache: MetadataCache, prefix:
228
232
  const prefixedName = formatToolName(baseName, serverName, prefix);
229
233
  if (BUILTIN_TOOL_NAMES.has(prefixedName) || seenNames.has(prefixedName)) continue;
230
234
  seenNames.add(prefixedName);
231
- names.push(prefixedName);
235
+ names.push({ name: prefixedName, selector: `${serverName}/${baseName}` });
232
236
  }
233
237
  }
234
238
 
235
239
  return names;
236
240
  }
237
241
 
242
+ export function resolveMcpDirectToolNames(mcpDirectTools: string[] | undefined, cwd = process.cwd()): string[] {
243
+ return resolveMcpDirectToolSelections(mcpDirectTools, cwd).map((selection) => selection.name);
244
+ }
245
+
238
246
  function parseSelections(selections: string[]): { servers: Set<string>; tools: Map<string, Set<string>> } {
239
247
  const servers = new Set<string>();
240
248
  const tools = new Map<string, Set<string>>();
@@ -265,14 +273,16 @@ export function computeMcpServerHash(definition: ServerEntry): string {
265
273
  const identity: Record<string, unknown> = {
266
274
  command: definition.command,
267
275
  args: definition.args,
276
+ socket: resolveConfigPath(definition.socket),
268
277
  env: interpolateEnvRecord(definition.env),
269
278
  cwd: resolveConfigPath(definition.cwd),
270
- url: definition.url,
279
+ url: resolveServerUrl(definition),
271
280
  headers: interpolateEnvRecord(definition.headers),
272
281
  auth: definition.auth,
273
282
  bearerToken: resolveBearerToken(definition),
274
283
  bearerTokenEnv: definition.bearerTokenEnv,
275
284
  exposeResources: definition.exposeResources,
285
+ includeTools: definition.includeTools,
276
286
  excludeTools: definition.excludeTools,
277
287
  };
278
288
  return createHash("sha256").update(stableStringify(identity)).digest("hex");
@@ -327,22 +337,51 @@ function resourceNameToToolName(name: string): string {
327
337
  }
328
338
 
329
339
  function interpolateEnvRecord(values: Record<string, string> | undefined): Record<string, string> | undefined {
330
- if (!values || typeof values !== "object" || Array.isArray(values)) return undefined;
331
- const resolved: Record<string, string> = {};
332
- for (const [key, value] of Object.entries(values)) {
333
- if (typeof value === "string") resolved[key] = interpolateEnvVars(value);
334
- }
335
- return resolved;
340
+ if (!values) return undefined;
341
+ return Object.fromEntries(Object.entries(values).map(([key, value]) => [key, interpolateSecretExpression(value)]));
336
342
  }
337
343
 
338
344
  function interpolateEnvVars(value: string): string {
339
345
  return value
340
346
  .replace(/\$\{(\w+)\}/g, (_, name: string) => process.env[name] ?? "")
341
- .replace(/\$env:(\w+)/g, (_, name: string) => process.env[name] ?? "");
347
+ .replace(/\$env:(\w+)/g, (_, name: string) => process.env[name] ?? "")
348
+ .replace(/\{env:(\w+)\}/g, (_, name: string) => process.env[name] ?? "");
349
+ }
350
+
351
+ function interpolateSecretExpression(value: string): string {
352
+ if (value.startsWith("!!")) return interpolateEnvVars(value.slice(1));
353
+ return value.startsWith("!") ? value : interpolateEnvVars(value);
354
+ }
355
+
356
+ function getMissingEnvVars(value: string): string[] {
357
+ const missing = new Set<string>();
358
+ for (const match of value.matchAll(/\$\{(\w+)\}|\$env:(\w+)|\{env:(\w+)\}/g)) {
359
+ const name = match[1] ?? match[2] ?? match[3];
360
+ if (name && process.env[name] === undefined) missing.add(name);
361
+ }
362
+ return [...missing];
363
+ }
364
+
365
+ function resolveServerUrl(definition: Pick<ServerEntry, "url">): string | undefined {
366
+ if (definition.url == null) return undefined;
367
+ if (typeof definition.url !== "string") throw new Error("MCP server URL must be a string");
368
+
369
+ const missing = getMissingEnvVars(definition.url);
370
+ if (missing.length > 0) {
371
+ throw new Error(`Missing environment variable${missing.length === 1 ? "" : "s"} in MCP server URL: ${missing.join(", ")}`);
372
+ }
373
+
374
+ const resolved = interpolateEnvVars(definition.url);
375
+ try {
376
+ new URL(resolved);
377
+ } catch (error) {
378
+ throw new Error(`Invalid MCP server URL after environment interpolation: ${resolved}`, { cause: error });
379
+ }
380
+ return resolved;
342
381
  }
343
382
 
344
383
  function resolveConfigPath(value: string | undefined): string | undefined {
345
- if (typeof value !== "string") return undefined;
384
+ if (value === undefined) return undefined;
346
385
  const resolved = interpolateEnvVars(value);
347
386
  if (resolved === "~") return os.homedir();
348
387
  if (resolved.startsWith("~/") || resolved.startsWith("~\\")) return path.join(os.homedir(), resolved.slice(2));
@@ -350,8 +389,8 @@ function resolveConfigPath(value: string | undefined): string | undefined {
350
389
  }
351
390
 
352
391
  function resolveBearerToken(definition: Pick<ServerEntry, "bearerToken" | "bearerTokenEnv">): string | undefined {
353
- if (typeof definition.bearerToken === "string") return interpolateEnvVars(definition.bearerToken);
354
- return typeof definition.bearerTokenEnv === "string" ? process.env[definition.bearerTokenEnv] : undefined;
392
+ if (definition.bearerToken !== undefined) return interpolateSecretExpression(definition.bearerToken);
393
+ return definition.bearerTokenEnv ? process.env[definition.bearerTokenEnv] : undefined;
355
394
  }
356
395
 
357
396
  function stableStringify(value: unknown): string {
@@ -30,6 +30,14 @@ export interface ParentModel {
30
30
  id: string;
31
31
  }
32
32
 
33
+ export function normalizeParentModel(model: unknown): ParentModel | undefined {
34
+ if (!model || typeof model !== "object") return undefined;
35
+ const candidate = model as { provider?: unknown; id?: unknown };
36
+ if (typeof candidate.provider !== "string" || typeof candidate.id !== "string") return undefined;
37
+ if (!candidate.provider || !candidate.id) return undefined;
38
+ return { provider: candidate.provider, id: candidate.id };
39
+ }
40
+
33
41
  /**
34
42
  * Normalize a model id or provider segment for fuzzy comparison: case-fold,
35
43
  * treat dots/underscores as dashes (so `4.5` matches `4-5`), and collapse
@@ -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 {