pi-subagents 0.42.1 → 0.44.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 (59) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +3 -5
  3. package/package.json +1 -1
  4. package/skills/pi-subagents/SKILL.md +2 -2
  5. package/skills/pi-subagents/references/constraints-and-recipes.md +35 -32
  6. package/skills/pi-subagents/references/execution-controls.md +30 -44
  7. package/skills/pi-subagents/references/management-authoring-rpc.md +20 -3
  8. package/skills/pi-subagents/references/prompting-and-roles.md +16 -49
  9. package/src/agents/agent-refinements.ts +624 -0
  10. package/src/agents/agents.ts +0 -2
  11. package/src/agents/proactive-skills.ts +1 -1
  12. package/src/api/delegation.ts +1 -2
  13. package/src/extension/config.ts +28 -7
  14. package/src/extension/control-notices.ts +2 -2
  15. package/src/extension/fanout-child.ts +46 -22
  16. package/src/extension/index.ts +32 -13
  17. package/src/extension/public-execution.ts +71 -0
  18. package/src/extension/rpc.ts +7 -8
  19. package/src/extension/schemas.ts +14 -14
  20. package/src/extension/tool-description.ts +14 -14
  21. package/src/missions/actions.ts +47 -11
  22. package/src/missions/goal-driver.ts +162 -0
  23. package/src/missions/lifecycle.ts +44 -12
  24. package/src/missions/store.ts +68 -3
  25. package/src/missions/types.ts +25 -3
  26. package/src/missions/workflow-state.ts +250 -0
  27. package/src/profiles/profiles.ts +1 -3
  28. package/src/runs/background/async-execution.ts +5 -1
  29. package/src/runs/background/async-job-tracker.ts +2 -17
  30. package/src/runs/background/async-status.ts +2 -0
  31. package/src/runs/background/control-channel.ts +50 -6
  32. package/src/runs/background/retained-children.ts +68 -0
  33. package/src/runs/background/run-id-resolver.ts +71 -0
  34. package/src/runs/background/run-status.ts +3 -2
  35. package/src/runs/background/scheduled-runs.ts +17 -13
  36. package/src/runs/background/steering.ts +7 -5
  37. package/src/runs/background/subagent-runner.ts +50 -23
  38. package/src/runs/foreground/async-steering-action.ts +36 -10
  39. package/src/runs/foreground/chain-clarify.ts +7 -3
  40. package/src/runs/foreground/execution.ts +29 -19
  41. package/src/runs/foreground/subagent-executor.ts +282 -77
  42. package/src/runs/shared/acceptance.ts +154 -9
  43. package/src/runs/shared/subagent-prompt-runtime.ts +97 -25
  44. package/src/shared/settings.ts +10 -6
  45. package/src/shared/types.ts +22 -2
  46. package/src/slash/delegation-adapters.ts +0 -13
  47. package/src/slash/prompt-template-bridge.ts +14 -12
  48. package/src/slash/prompt-workflows.ts +10 -4
  49. package/src/slash/slash-bridge.ts +8 -6
  50. package/src/slash/slash-commands.ts +24 -9
  51. package/src/slash/slash-live-state.ts +4 -2
  52. package/src/tui/fleet-status.ts +8 -4
  53. package/src/tui/fleet.ts +15 -5
  54. package/src/tui/render.ts +10 -29
  55. package/src/workflows/scripted-workflow.ts +91 -6
  56. package/agents/context-builder.md +0 -46
  57. package/agents/planner.md +0 -56
  58. package/prompts/parallel-context-build.md +0 -55
  59. package/prompts/parallel-handoff-plan.md +0 -61
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { spawnSync } from "node:child_process";
3
+ import { createHash } from "node:crypto";
3
4
  import * as fs from "node:fs";
4
5
  import * as path from "node:path";
5
6
  import type {
@@ -95,7 +96,7 @@ function inferLevel(input: {
95
96
  && /\bpatch\s+(?:(?:\.{0,2}[\\/])?(?:[\w.-]+[\\/])+[\w.-]+|[\w.-]+\.[a-z0-9]+\b|(?:the\s+)?parser\b)/.test(task);
96
97
  const taskMayWrite = readOnlyTask ? false : taskMayMutate(input.task ?? "") || intent.kind === "implementation" || rolePatchTask;
97
98
  const readOnlyAgent = input.acceptanceRole === "read-only"
98
- || (input.acceptanceRole === undefined && /\b(?:reviewer|oracle|scout|context-builder|researcher|analyst)\b/.test(agent));
99
+ || (input.acceptanceRole === undefined && /\b(?:reviewer|oracle|scout|researcher|analyst)\b/.test(agent));
99
100
  const writeTask = taskMayWrite
100
101
  || (input.acceptanceRole === "writer" && !readOnlyTask)
101
102
  || (input.acceptanceRole === undefined && /\bworker\b/.test(agent) && !readOnlyTask);
@@ -152,6 +153,17 @@ export function normalizeAcceptanceInput(input: AcceptanceInput | undefined): Ac
152
153
  return { ...input };
153
154
  }
154
155
 
156
+ type GateAcceptanceNormalizationResult =
157
+ | { ok: true; acceptance?: AcceptanceInput }
158
+ | { ok: false; error: string };
159
+
160
+ export function normalizeGateAcceptance(gate: unknown, acceptance: AcceptanceInput | undefined): GateAcceptanceNormalizationResult {
161
+ if (gate === undefined) return acceptance === undefined ? { ok: true } : { ok: true, acceptance };
162
+ if (typeof gate !== "string" || !gate.trim()) return { ok: false, error: "gate must be a non-empty command string." };
163
+ if (acceptance !== undefined) return { ok: false, error: "gate cannot be combined with acceptance; use one gate command or acceptance.verify." };
164
+ return { ok: true, acceptance: { level: "verified", verify: [{ id: "gate", command: gate.trim() }] } };
165
+ }
166
+
155
167
  function explicitAcceptanceCanDisable(explicit: AcceptanceConfig): boolean {
156
168
  return explicit.level === "none" && typeof explicit.reason === "string" && explicit.reason.trim().length > 0;
157
169
  }
@@ -959,6 +971,28 @@ function trimOutput(value: string): string | undefined {
959
971
  return trimmed.length > 12_000 ? `${trimmed.slice(0, 12_000)}\n...[truncated]` : trimmed;
960
972
  }
961
973
 
974
+ const SENSITIVE_ENV_KEY_PATTERN = /(?:^|_)(?:TOKEN|SECRET|PASSWORD|PASS|AUTH|CREDENTIAL|COOKIE|SESSION|PRIVATE|API_KEY|ACCESS_KEY)(?:_|$)/i;
975
+
976
+ function effectiveVerifyEnv(env: Record<string, string> | undefined): Record<string, string> {
977
+ const inherited = Object.fromEntries(Object.entries(process.env).flatMap(([key, value]) => {
978
+ return typeof value === "string" ? [[key, value]] : [];
979
+ }));
980
+ return { ...inherited, ...(env ?? {}) };
981
+ }
982
+
983
+ function verifyRedactionEnv(env: Record<string, string> | undefined): Record<string, string> {
984
+ return Object.fromEntries(Object.entries(effectiveVerifyEnv(env)).filter(([key, value]) => {
985
+ return value.length >= 4 && SENSITIVE_ENV_KEY_PATTERN.test(key);
986
+ }));
987
+ }
988
+
989
+ function redactVerifyEnv(value: string, env: Record<string, string> | undefined): string {
990
+ let redacted = value;
991
+ const secrets = [...new Set(Object.values(verifyRedactionEnv(env)).filter(Boolean))].sort((left, right) => right.length - left.length);
992
+ for (const secret of secrets) redacted = redacted.replaceAll(secret, "[REDACTED]");
993
+ return redacted;
994
+ }
995
+
962
996
  function uniqueStrings(items: Array<string | undefined>): string[] {
963
997
  return unique(items.map((item) => item?.trim()).filter((item): item is string => Boolean(item)));
964
998
  }
@@ -995,6 +1029,108 @@ export function aggregateAcceptanceReport(input: {
995
1029
  };
996
1030
  }
997
1031
 
1032
+ const DEFAULT_VERIFY_TIMEOUT_MS = 120_000;
1033
+
1034
+ function hash(value: string): string {
1035
+ return createHash("sha256").update(value).digest("hex");
1036
+ }
1037
+
1038
+ interface VerifyWorkspaceState {
1039
+ kind: "git-tracked";
1040
+ repoRoot: string;
1041
+ cwdRelative: string;
1042
+ head: string;
1043
+ diffHash: string;
1044
+ }
1045
+
1046
+ function readVerifyWorkspaceState(cwd: string): VerifyWorkspaceState | undefined {
1047
+ const repo = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf-8" });
1048
+ if (repo.status !== 0 || !repo.stdout.trim()) return undefined;
1049
+ const repoRoot = fs.realpathSync(repo.stdout.trim());
1050
+ const head = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf-8" });
1051
+ const diff = spawnSync("git", ["diff", "--binary", "--full-index", "HEAD", "--"], { cwd: repoRoot, encoding: "utf-8", maxBuffer: 50 * 1024 * 1024 });
1052
+ if (head.status !== 0 || diff.status !== 0 || !head.stdout.trim()) return undefined;
1053
+ return {
1054
+ kind: "git-tracked",
1055
+ repoRoot,
1056
+ cwdRelative: path.relative(repoRoot, fs.realpathSync(cwd)) || ".",
1057
+ head: head.stdout.trim(),
1058
+ diffHash: hash(diff.stdout),
1059
+ };
1060
+ }
1061
+
1062
+ function isCachedVerifyResult(value: unknown): value is AcceptanceVerifyResult {
1063
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1064
+ const result = value as Partial<AcceptanceVerifyResult>;
1065
+ return typeof result.id === "string"
1066
+ && typeof result.command === "string"
1067
+ && (typeof result.exitCode === "number" || result.exitCode === null)
1068
+ && (result.status === "passed" || result.status === "failed" || result.status === "timed-out" || result.status === "allowed-failure")
1069
+ && typeof result.durationMs === "number";
1070
+ }
1071
+
1072
+ async function runMemoizedVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string, options: {
1073
+ signal?: AbortSignal;
1074
+ abortMessage?: string;
1075
+ artifactsDir?: string;
1076
+ runId?: string;
1077
+ } = {}): Promise<AcceptanceVerifyResult> {
1078
+ const cwd = command.cwd ? path.resolve(defaultCwd, command.cwd) : defaultCwd;
1079
+ let workspaceState: VerifyWorkspaceState | undefined;
1080
+ try {
1081
+ workspaceState = readVerifyWorkspaceState(cwd);
1082
+ } catch {
1083
+ workspaceState = undefined;
1084
+ }
1085
+ if (!workspaceState || !options.artifactsDir || !options.runId) {
1086
+ return runVerifyCommand(command, defaultCwd, options);
1087
+ }
1088
+ const envKeys = Object.keys(command.env ?? {}).sort();
1089
+ const envHash = hash(JSON.stringify(Object.fromEntries(Object.entries(effectiveVerifyEnv(command.env)).sort(([left], [right]) => left.localeCompare(right)))));
1090
+ const timeoutMs = command.timeoutMs ?? DEFAULT_VERIFY_TIMEOUT_MS;
1091
+ const cacheKey = hash(JSON.stringify({
1092
+ version: 1,
1093
+ command: command.command,
1094
+ cwdRelative: workspaceState.cwdRelative,
1095
+ envKeys,
1096
+ envHash,
1097
+ timeoutMs,
1098
+ allowFailure: command.allowFailure === true,
1099
+ head: workspaceState.head,
1100
+ diffHash: workspaceState.diffHash,
1101
+ }));
1102
+ const artifactPath = path.join(options.artifactsDir, "acceptance", "verify", options.runId, `${cacheKey}.json`);
1103
+ try {
1104
+ const cached = JSON.parse(fs.readFileSync(artifactPath, "utf-8")) as { cacheKey?: unknown; result?: unknown };
1105
+ if (cached.cacheKey === cacheKey && isCachedVerifyResult(cached.result)) {
1106
+ return { ...cached.result, id: command.id, command: command.command, cwd, artifactPath, cacheKey, memoized: true, envKeys, envHash, workspaceState };
1107
+ }
1108
+ } catch {
1109
+ // A cache miss or unreadable artifact must not prevent host verification.
1110
+ }
1111
+ const result = await runVerifyCommand(command, defaultCwd, options);
1112
+ const evidenced: AcceptanceVerifyResult = { ...result, artifactPath, cacheKey, memoized: false, envKeys, envHash, workspaceState };
1113
+ try {
1114
+ fs.mkdirSync(path.dirname(artifactPath), { recursive: true });
1115
+ fs.writeFileSync(artifactPath, JSON.stringify({
1116
+ version: 1,
1117
+ cacheKey,
1118
+ command: command.command,
1119
+ cwdRelative: workspaceState.cwdRelative,
1120
+ envKeys,
1121
+ envHash,
1122
+ timeoutMs,
1123
+ allowFailure: command.allowFailure === true,
1124
+ workspaceState,
1125
+ result: evidenced,
1126
+ }, null, 2), "utf-8");
1127
+ } catch (error) {
1128
+ evidenced.artifactError = error instanceof Error ? error.message : String(error);
1129
+ delete evidenced.artifactPath;
1130
+ }
1131
+ return evidenced;
1132
+ }
1133
+
998
1134
  function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string, options: { signal?: AbortSignal; abortMessage?: string } = {}): Promise<AcceptanceVerifyResult> {
999
1135
  return new Promise((resolve) => {
1000
1136
  const startedAt = Date.now();
@@ -1006,7 +1142,7 @@ function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string,
1006
1142
  let hardKill: NodeJS.Timeout | undefined;
1007
1143
  const child = spawn(command.command, {
1008
1144
  cwd,
1009
- env: { ...process.env, ...(command.env ?? {}) },
1145
+ env: effectiveVerifyEnv(command.env),
1010
1146
  shell: true,
1011
1147
  stdio: ["ignore", "pipe", "pipe"],
1012
1148
  windowsHide: true,
@@ -1034,13 +1170,13 @@ function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string,
1034
1170
  finish({
1035
1171
  exitCode: null,
1036
1172
  status: "timed-out",
1037
- stdout: trimOutput(stdout),
1038
- stderr: trimOutput(stderr || options.abortMessage || "Acceptance verification timed out."),
1173
+ stdout: trimOutput(redactVerifyEnv(stdout, command.env)),
1174
+ stderr: trimOutput(redactVerifyEnv(stderr || options.abortMessage || "Acceptance verification timed out.", command.env)),
1039
1175
  });
1040
1176
  }, 1000);
1041
1177
  hardKill.unref?.();
1042
1178
  };
1043
- const timeout = setTimeout(abortVerification, command.timeoutMs ?? 120_000);
1179
+ const timeout = setTimeout(abortVerification, command.timeoutMs ?? DEFAULT_VERIFY_TIMEOUT_MS);
1044
1180
  timeout.unref?.();
1045
1181
  if (options.signal?.aborted) abortVerification();
1046
1182
  else options.signal?.addEventListener("abort", abortVerification, { once: true });
@@ -1055,15 +1191,17 @@ function runVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string,
1055
1191
  finish({
1056
1192
  exitCode,
1057
1193
  status: timedOut ? "timed-out" : passed ? "passed" : command.allowFailure ? "allowed-failure" : "failed",
1058
- stdout: trimOutput(stdout),
1059
- stderr: trimOutput(stderr || (timedOut ? options.abortMessage ?? "" : "")),
1194
+ stdout: trimOutput(redactVerifyEnv(stdout, command.env)),
1195
+ stderr: trimOutput(redactVerifyEnv(stderr || (timedOut ? options.abortMessage ?? "" : ""), command.env)),
1060
1196
  });
1061
1197
  });
1062
1198
  child.on("error", (error) => {
1063
1199
  finish({
1064
1200
  exitCode: timedOut ? null : 1,
1065
1201
  status: timedOut ? "timed-out" : command.allowFailure ? "allowed-failure" : "failed",
1066
- stderr: timedOut ? trimOutput(stderr || options.abortMessage || "Acceptance verification timed out.") : error instanceof Error ? error.message : String(error),
1202
+ stderr: timedOut
1203
+ ? trimOutput(redactVerifyEnv(stderr || options.abortMessage || "Acceptance verification timed out.", command.env))
1204
+ : redactVerifyEnv(error instanceof Error ? error.message : String(error), command.env),
1067
1205
  });
1068
1206
  });
1069
1207
  });
@@ -1085,6 +1223,8 @@ export async function evaluateAcceptance(input: {
1085
1223
  signal?: AbortSignal;
1086
1224
  abortMessage?: string;
1087
1225
  reportOptional?: boolean;
1226
+ artifactsDir?: string;
1227
+ runId?: string;
1088
1228
  }): Promise<AcceptanceLedger> {
1089
1229
  const acceptance = input.acceptance;
1090
1230
  const initialStatus = acceptance.level === "none" ? "not-required" : "claimed";
@@ -1146,7 +1286,12 @@ export async function evaluateAcceptance(input: {
1146
1286
  }
1147
1287
  ledger.verifyRuns = [];
1148
1288
  for (const command of acceptance.verify) {
1149
- ledger.verifyRuns.push(await runVerifyCommand(command, input.cwd, { signal: input.signal, abortMessage: input.abortMessage }));
1289
+ ledger.verifyRuns.push(await runMemoizedVerifyCommand(command, input.cwd, {
1290
+ signal: input.signal,
1291
+ abortMessage: input.abortMessage,
1292
+ artifactsDir: input.artifactsDir,
1293
+ runId: input.runId,
1294
+ }));
1150
1295
  if (input.signal?.aborted) break;
1151
1296
  }
1152
1297
  if (ledger.verifyRuns.some((run) => run.status === "failed" || run.status === "timed-out")) {
@@ -1,9 +1,10 @@
1
+ import { createHash } from "node:crypto";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
3
4
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
5
  import { registerNativeSupervisorClient } from "../../intercom/native-supervisor-channel.ts";
5
6
  import { decodePermissionRules, permissionDecision, PERMISSION_AUDIT_PATH_ENV, PERMISSION_POLICY_ENV } from "./permissions.ts";
6
- import { consumeSteerRequestsFromDir, steerAckPathFromDir, writeSteerAckAt, writeSteerCapabilityAt, writeSteerRequestToDir, type SteerRequest } from "../background/control-channel.ts";
7
+ import { consumeSteerRequestsFromDir, MAX_STEER_QUEUE_SIZE, steerAckPathFromDir, writeSteerAckAt, writeSteerCapabilityAt, writeSteerRequestToDir, type SteerDeliveryStatus, type SteerRequest } from "../background/control-channel.ts";
7
8
  import { SUBAGENT_CHILD_AGENT_ENV, SUBAGENT_CHILD_INDEX_ENV, SUBAGENT_FANOUT_CHILD_ENV, SUBAGENT_STEER_ACK_DIR_ENV, SUBAGENT_STEER_CAPABILITY_ENV, SUBAGENT_STEER_INBOX_ENV } from "./pi-args.ts";
8
9
  import { RUNTIME_EXTENSION_ACK_EVENT, RUNTIME_EXTENSION_ACK_PATH_ENV, isRuntimeAcknowledgedExtensionId, writeRuntimeAcknowledgedExtensions } from "./runtime-acknowledged-extensions.ts";
9
10
  import { createStructuredOutputToolParameters, STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV, validateStructuredOutputValue } from "./structured-output.ts";
@@ -205,6 +206,40 @@ function isSubagentToolCallBlock(block: unknown): boolean {
205
206
  return b?.type === "toolCall" && b.name === "subagent";
206
207
  }
207
208
 
209
+ const PORTABLE_TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
210
+ const MAX_PORTABLE_TOOL_ID_LENGTH = 64;
211
+ const COMPOSITE_TOOL_ID_APIS = new Set([
212
+ "azure-openai-responses",
213
+ "openai-completions",
214
+ "openai-responses",
215
+ ]);
216
+
217
+ function portableToolId(id: string): string {
218
+ if (PORTABLE_TOOL_ID_PATTERN.test(id) && id.length <= MAX_PORTABLE_TOOL_ID_LENGTH) return id;
219
+ const encoded = `tool_${Buffer.from(id).toString("base64url") || "empty"}`;
220
+ if (encoded.length <= MAX_PORTABLE_TOOL_ID_LENGTH) return encoded;
221
+ return `tool_${createHash("sha256").update(id).digest("base64url")}`;
222
+ }
223
+
224
+ function sanitizeToolHistoryMessage(message: unknown): unknown {
225
+ const m = message as { role?: string; content?: unknown; toolCallId?: unknown };
226
+ if (m?.role === "toolResult" && typeof m.toolCallId === "string") {
227
+ const toolCallId = portableToolId(m.toolCallId);
228
+ return toolCallId === m.toolCallId ? message : { ...m, toolCallId };
229
+ }
230
+ if (m?.role !== "assistant" || !Array.isArray(m.content)) return message;
231
+ let changed = false;
232
+ const content = m.content.map((block) => {
233
+ const b = block as { type?: string; id?: unknown };
234
+ if (b?.type !== "toolCall" || typeof b.id !== "string") return block;
235
+ const id = portableToolId(b.id);
236
+ if (id === b.id) return block;
237
+ changed = true;
238
+ return { ...b, id };
239
+ });
240
+ return changed ? { ...m, content } : message;
241
+ }
242
+
208
243
  function stripAssistantSubagentToolCallBlocks(message: unknown): unknown | undefined {
209
244
  const m = message as { role?: string; content?: unknown };
210
245
  if (m?.role !== "assistant" || !Array.isArray(m.content)) return message;
@@ -214,8 +249,9 @@ function stripAssistantSubagentToolCallBlocks(message: unknown): unknown | undef
214
249
  return { ...m, content: filteredContent };
215
250
  }
216
251
 
217
- export function stripParentOnlySubagentMessages(messages: unknown[]): unknown[] {
252
+ export function stripParentOnlySubagentMessages(messages: unknown[], options: { sanitizeToolIds?: boolean } = {}): unknown[] {
218
253
  const preserveCurrentFanoutToolHistory = process.env[SUBAGENT_FANOUT_CHILD_ENV] === "1";
254
+ const sanitizeToolIds = options.sanitizeToolIds ?? true;
219
255
  let changed = false;
220
256
  const filtered: unknown[] = [];
221
257
  for (const message of messages) {
@@ -228,15 +264,16 @@ export function stripParentOnlySubagentMessages(messages: unknown[]): unknown[]
228
264
  changed = true;
229
265
  continue;
230
266
  }
231
- if (stripped !== message) changed = true;
232
- filtered.push(stripped);
267
+ const sanitized = sanitizeToolIds ? sanitizeToolHistoryMessage(stripped) : stripped;
268
+ if (stripped !== message || sanitized !== stripped) changed = true;
269
+ filtered.push(sanitized);
233
270
  }
234
271
  return changed ? filtered : messages;
235
272
  }
236
273
 
237
274
  export function formatSteerMessage(request: SteerRequest): string {
238
275
  return [
239
- "Mid-run steering from the parent orchestrator:",
276
+ request.mode === "follow_up" ? "Queued follow-up from the parent orchestrator:" : "Mid-run steering from the parent orchestrator:",
240
277
  "",
241
278
  request.message,
242
279
  "",
@@ -299,22 +336,26 @@ export function registerSteeringInbox(
299
336
  if (!steerInbox) return;
300
337
  const capabilityPath = process.env[SUBAGENT_STEER_CAPABILITY_ENV]?.trim();
301
338
  const ackDir = process.env[SUBAGENT_STEER_ACK_DIR_ENV]?.trim();
302
- const sendUserMessage = (pi as { sendUserMessage?: (content: string, options: { deliverAs: "steer" }) => unknown }).sendUserMessage;
339
+ const sendUserMessage = (pi as { sendUserMessage?: (content: string, options?: { deliverAs: "steer" | "followUp" }) => unknown }).sendUserMessage;
303
340
  const childIndex = Number(process.env[SUBAGENT_CHILD_INDEX_ENV]);
304
- const pending = new Map<string, string[]>();
341
+ const pending = new Map<string, Array<{ request: SteerRequest; deliveryStatus: SteerDeliveryStatus }>>();
342
+ const queued: Array<{ request: SteerRequest; ready: boolean }> = [];
305
343
  let disposed = false;
344
+ let agentRunning = false;
345
+ let inTurn = false;
306
346
  let flushing = false;
307
347
  let started = false;
308
348
  let canSteer = typeof sendUserMessage === "function";
309
349
  let watcher: fs.FSWatcher | undefined;
310
350
  let interval: NodeJS.Timeout | undefined;
311
- const acknowledge = (request: SteerRequest, state: "delivered" | "failed", message: string): void => {
351
+ const acknowledge = (request: SteerRequest, state: "delivered" | "queued" | "failed", message: string, deliveryStatus?: SteerDeliveryStatus): void => {
312
352
  if (!ackDir || !Number.isInteger(childIndex) || childIndex < 0) return;
313
353
  writeSteerAckAt(steerAckPathFromDir(ackDir, request.id), {
314
354
  requestId: request.id,
315
355
  index: childIndex,
316
356
  ts: Date.now(),
317
357
  state,
358
+ ...(deliveryStatus ? { deliveryStatus } : {}),
318
359
  message,
319
360
  });
320
361
  };
@@ -333,15 +374,22 @@ export function registerSteeringInbox(
333
374
  acknowledge(request, "failed", "Child Pi session does not support sendUserMessage steering.");
334
375
  continue;
335
376
  }
377
+ const requestedMode = request.mode ?? "steer";
378
+ const delivery = requestedMode === "follow_up" || (requestedMode === "auto" && inTurn) ? "followUp" as const : "steer" as const;
379
+ const pendingFollowUps = [...pending.values()].reduce((count, entries) => count + entries.filter((entry) => entry.deliveryStatus === "queued").length, 0);
380
+ if (delivery === "followUp" && queued.length + pendingFollowUps >= MAX_STEER_QUEUE_SIZE) {
381
+ acknowledge(request, "failed", `Follow-up queue is full (${MAX_STEER_QUEUE_SIZE} messages).`);
382
+ continue;
383
+ }
336
384
  const formatted = formatSteerMessage(request);
337
- const ids = pending.get(formatted) ?? [];
338
- ids.push(request.id);
339
- pending.set(formatted, ids);
385
+ const entries = pending.get(formatted) ?? [];
386
+ entries.push({ request, deliveryStatus: delivery === "followUp" ? "queued" : "delivered" });
387
+ pending.set(formatted, entries);
340
388
  try {
341
- sendUserMessage(formatted, { deliverAs: "steer" });
389
+ sendUserMessage(formatted, requestedMode === "auto" && !agentRunning ? undefined : { deliverAs: delivery });
342
390
  } catch (error) {
343
- ids.pop();
344
- if (ids.length === 0) pending.delete(formatted);
391
+ entries.pop();
392
+ if (entries.length === 0) pending.delete(formatted);
345
393
  acknowledge(request, "failed", error instanceof Error ? error.message : String(error));
346
394
  for (const retry of requests.slice(index + 1)) writeSteerRequestToDir(steerInbox, retry);
347
395
  break;
@@ -354,14 +402,19 @@ export function registerSteeringInbox(
354
402
  const onInput = (event: unknown): undefined => {
355
403
  if (disposed || !event || typeof event !== "object") return undefined;
356
404
  const input = event as { source?: unknown; streamingBehavior?: unknown; text?: unknown; content?: unknown };
357
- if (input.source !== "extension" || input.streamingBehavior !== "steer") return undefined;
405
+ if (input.source !== "extension") return undefined;
358
406
  const text = typeof input.text === "string" ? input.text : typeof input.content === "string" ? input.content : undefined;
359
407
  if (!text) return undefined;
360
- const ids = pending.get(text);
361
- const requestId = ids?.shift();
362
- if (!requestId) return undefined;
363
- if (ids?.length === 0) pending.delete(text);
364
- acknowledge({ type: "steer", id: requestId, ts: Date.now(), message: text }, "delivered", "Pi accepted the correlated steering input.");
408
+ const entries = pending.get(text);
409
+ const entry = entries?.shift();
410
+ if (!entry) return undefined;
411
+ if (entries?.length === 0) pending.delete(text);
412
+ if (entry.deliveryStatus === "queued") {
413
+ queued.push({ request: entry.request, ready: !inTurn });
414
+ acknowledge(entry.request, "queued", "Pi queued the correlated follow-up input.", "queued");
415
+ } else {
416
+ acknowledge(entry.request, "delivered", "Pi accepted the correlated steering input.", "delivered");
417
+ }
365
418
  return undefined;
366
419
  };
367
420
  const start = (): void => {
@@ -392,10 +445,27 @@ export function registerSteeringInbox(
392
445
  // Register input before the watcher so an accepted extension input cannot race request dispatch.
393
446
  onRuntimeEvent("input", onInput);
394
447
  onRuntimeEvent("session_start", () => start());
395
- for (const eventName of ["message_start", "message_update", "message_end", "tool_execution_start", "tool_execution_end", "turn_end"] as const) {
448
+ onRuntimeEvent("agent_start", () => { agentRunning = true; return activate(); });
449
+ onRuntimeEvent("agent_end", () => { agentRunning = false; inTurn = false; return activate(); });
450
+ onRuntimeEvent("turn_start", () => {
451
+ inTurn = true;
452
+ const next = queued.findIndex((entry) => entry.ready);
453
+ if (next >= 0) {
454
+ const [entry] = queued.splice(next, 1);
455
+ if (entry) acknowledge(entry.request, "delivered", "Pi delivered the queued follow-up at a turn boundary.", "delivered");
456
+ }
457
+ return activate();
458
+ });
459
+ onRuntimeEvent("turn_end", () => {
460
+ inTurn = false;
461
+ for (const entry of queued) entry.ready = true;
462
+ return activate();
463
+ });
464
+ for (const eventName of ["message_start", "message_update", "message_end", "tool_execution_start", "tool_execution_end"] as const) {
396
465
  onRuntimeEvent(eventName, activate);
397
466
  }
398
467
  onRuntimeEvent("session_shutdown", () => {
468
+ for (const entry of queued) acknowledge(entry.request, "failed", "Run ended before queued follow-up delivery.", "queued");
399
469
  disposed = true;
400
470
  try { watcher?.close(); } catch {}
401
471
  if (interval) clearInterval(interval);
@@ -437,8 +507,8 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
437
507
  nativeSupervisorFallbackRegistered = true;
438
508
  registerNativeSupervisorClient(pi);
439
509
  };
440
- const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown, ctx?: unknown) => unknown) => void;
441
- onRuntimeEvent("session_start", (_event: unknown, ctx: unknown) => {
510
+ const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown, ctx?: ExtensionContext) => unknown) => void;
511
+ onRuntimeEvent("session_start", (_event: unknown, ctx?: ExtensionContext) => {
442
512
  const sessionManager = (ctx as { sessionManager?: Parameters<typeof resolveCurrentSessionId>[0] } | undefined)?.sessionManager;
443
513
  waitState.currentSessionId = sessionManager ? resolveCurrentSessionId(sessionManager) : null;
444
514
  registerNativeSupervisorClientOnce();
@@ -484,9 +554,11 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
484
554
  });
485
555
  }
486
556
 
487
- onRuntimeEvent("context", (event: unknown) => {
557
+ onRuntimeEvent("context", (event: unknown, ctx?: ExtensionContext) => {
488
558
  if (!event || typeof event !== "object" || !("messages" in event) || !Array.isArray(event.messages)) return undefined;
489
- const messages = stripParentOnlySubagentMessages(event.messages);
559
+ const messages = stripParentOnlySubagentMessages(event.messages, {
560
+ sanitizeToolIds: !COMPOSITE_TOOL_ID_APIS.has(ctx?.model?.api ?? ""),
561
+ });
490
562
  if (messages === event.messages) return undefined;
491
563
  return { messages };
492
564
  });
@@ -23,8 +23,10 @@ export interface ResolvedStepBehavior {
23
23
  model?: string;
24
24
  }
25
25
 
26
+ export type OutputOverrideInput = string | boolean;
27
+
26
28
  export interface StepOverrides {
27
- output?: string | false;
29
+ output?: OutputOverrideInput;
28
30
  outputMode?: OutputMode;
29
31
  reads?: string[] | false;
30
32
  progress?: boolean;
@@ -32,8 +34,10 @@ export interface StepOverrides {
32
34
  model?: string;
33
35
  }
34
36
 
35
- function normalizeOutputOverride(output: string | false | undefined): string | false | undefined {
36
- return output === "false" ? false : output;
37
+ function normalizeOutputOverride(output: unknown): string | false | undefined {
38
+ if (output === false || output === "false") return false;
39
+ if (output === true || output === "true") return undefined;
40
+ return typeof output === "string" && output.length > 0 ? output : undefined;
37
41
  }
38
42
 
39
43
  // =============================================================================
@@ -49,7 +53,7 @@ export interface SequentialStep {
49
53
  as?: string;
50
54
  outputSchema?: JsonSchemaObject;
51
55
  cwd?: string;
52
- output?: string | false;
56
+ output?: OutputOverrideInput;
53
57
  outputMode?: OutputMode;
54
58
  reads?: string[] | false;
55
59
  progress?: boolean;
@@ -71,7 +75,7 @@ export interface ParallelTaskItem {
71
75
  outputSchema?: JsonSchemaObject;
72
76
  cwd?: string;
73
77
  count?: number;
74
- output?: string | false;
78
+ output?: OutputOverrideInput;
75
79
  outputMode?: OutputMode;
76
80
  reads?: string[] | false;
77
81
  progress?: boolean;
@@ -123,7 +127,7 @@ export interface CheckpointStep {
123
127
  agent?: string;
124
128
  task?: string;
125
129
  as?: string;
126
- output?: string | false;
130
+ output?: OutputOverrideInput;
127
131
  outputMode?: OutputMode;
128
132
  reads?: string[] | false;
129
133
  progress?: boolean;
@@ -400,7 +400,7 @@ export type ProcessTerminalV1 =
400
400
  });
401
401
 
402
402
  export type SteeringActionState = "delivered" | "scheduled" | "pending" | "partial" | "recovered" | "failed";
403
- export type SteeringTargetState = "scheduled" | "pending" | "routed" | "delivered" | "late" | "failed" | "recovered";
403
+ export type SteeringTargetState = "scheduled" | "pending" | "routed" | "queued" | "delivered" | "late" | "failed" | "recovered";
404
404
 
405
405
  export interface SteeringTargetStatus {
406
406
  index: number;
@@ -446,6 +446,7 @@ export interface SteerActionTarget {
446
446
  export interface SteerActionResult {
447
447
  requestId: string;
448
448
  state: SteeringActionState;
449
+ deliveryStatus: "delivered" | "queued";
449
450
  sourceRunId: string;
450
451
  replacementRunId?: string;
451
452
  targets: SteerActionTarget[];
@@ -741,6 +742,19 @@ export interface AcceptanceVerifyResult {
741
742
  stdout?: string;
742
743
  stderr?: string;
743
744
  durationMs: number;
745
+ artifactPath?: string;
746
+ cacheKey?: string;
747
+ memoized?: boolean;
748
+ envKeys?: string[];
749
+ envHash?: string;
750
+ workspaceState?: {
751
+ kind: "git-tracked";
752
+ repoRoot: string;
753
+ cwdRelative: string;
754
+ head: string;
755
+ diffHash: string;
756
+ };
757
+ artifactError?: string;
744
758
  }
745
759
 
746
760
  export interface AcceptanceReviewResult {
@@ -936,6 +950,8 @@ export interface SpawnBudgetSnapshot {
936
950
  export interface Details {
937
951
  mode: SubagentResultMode | "management";
938
952
  runId?: string;
953
+ /** Host tool-call id retained when it differs from the internal run id. */
954
+ toolCallId?: string;
939
955
  /** Run-level context summary. "mixed" when children resolved to different modes. */
940
956
  context?: "fresh" | "fork" | "mixed";
941
957
  results: SingleResult[];
@@ -1232,6 +1248,8 @@ export interface ExternalProcessStatus {
1232
1248
  export interface AsyncStatus {
1233
1249
  lifecycleArtifactVersion?: SubagentLifecycleArtifactVersion;
1234
1250
  runId: string;
1251
+ /** Host tool-call id retained when it differs from the internal run id. */
1252
+ toolCallId?: string;
1235
1253
  sessionId?: string;
1236
1254
  mode: SubagentRunMode;
1237
1255
  context?: "fresh" | "fork" | "mixed";
@@ -1722,6 +1740,8 @@ export type InlineToolDisplay = "rich" | "summary";
1722
1740
  export interface ScheduledRunsConfig {
1723
1741
  enabled?: boolean;
1724
1742
  maxPending?: number;
1743
+ /** Absolute or `~/` root for per-project durable schedules. */
1744
+ storeRoot?: string;
1725
1745
  }
1726
1746
 
1727
1747
  export type FleetViewPlacement = "aboveEditor" | "belowEditor";
@@ -1868,7 +1888,7 @@ export const SLASH_SUBAGENT_CANCEL_EVENT = "subagent:slash:cancel";
1868
1888
  export const POLL_INTERVAL_MS = 250;
1869
1889
  export const MAX_WIDGET_JOBS = 4;
1870
1890
  export const DEFAULT_SUBAGENT_MAX_DEPTH = 2;
1871
- export const SUBAGENT_ACTIONS = ["list", "get", "models", "create", "update", "delete", "eject", "disable", "enable", "reset", "mission.create", "mission.list", "mission.show", "mission.update", "mission.attach-run", "mission.close", "worktree.discard", "inspector.open", "inspector.status", "inspector.close", "project.open", "project.status", "project.close", "status", "grant-spawn-budget", "interrupt", "resume", "steer", "stop", "append-step", "approve-checkpoint", "reject-checkpoint", "doctor", "watchdog.status", "watchdog.check", "watchdog.configure", "watchdog.recommend-model", "schedule.create", "schedule.list", "schedule.show", "schedule.history", "schedule.pause", "schedule.resume", "schedule.run", "schedule.run-due", "schedule.delete"] as const;
1891
+ export const SUBAGENT_ACTIONS = ["list", "get", "models", "children.list", "create", "update", "delete", "eject", "disable", "enable", "reset", "mission.create", "mission.list", "mission.show", "mission.update", "mission.attach-run", "mission.close", "worktree.discard", "refine", "refine.show", "refine.rollback", "inspector.open", "inspector.status", "inspector.close", "project.open", "project.status", "project.close", "status", "grant-spawn-budget", "interrupt", "resume", "steer", "stop", "append-step", "approve-checkpoint", "reject-checkpoint", "doctor", "watchdog.status", "watchdog.check", "watchdog.configure", "watchdog.recommend-model", "schedule.create", "schedule.list", "schedule.show", "schedule.history", "schedule.pause", "schedule.resume", "schedule.run", "schedule.run-due", "schedule.delete"] as const;
1872
1892
 
1873
1893
  export const DEFAULT_FORK_PREAMBLE =
1874
1894
  "You are a delegated subagent running from a fork of the parent session. " +
@@ -286,19 +286,6 @@ export function toDelegationUpdate(requestId: string, update: PromptTemplateBrid
286
286
  };
287
287
  }
288
288
 
289
- export function toLegacyExecutionParams(request: PromptTemplateDelegationRequest): DelegatedSubagentExecutionParams {
290
- return {
291
- agent: request.agent,
292
- task: request.task,
293
- context: request.context,
294
- model: request.model,
295
- cwd: request.cwd,
296
- async: false,
297
- foregroundOnly: true,
298
- clarify: false,
299
- };
300
- }
301
-
302
289
  export function toSubagentDelegationExecutionParams(request: SubagentDelegationRequest): DelegatedSubagentExecutionParams {
303
290
  return {
304
291
  agent: request.agent,
@@ -12,7 +12,6 @@ import { parseSubagentDelegationRequest } from "./delegation-request.ts";
12
12
  import {
13
13
  parsePromptTemplateRequest,
14
14
  toDelegationUpdate,
15
- toLegacyExecutionParams,
16
15
  toPromptTemplateResponse,
17
16
  toSubagentDelegationExecutionParams,
18
17
  toSubagentDelegationResponse,
@@ -183,22 +182,25 @@ export function registerPromptTemplateDelegationBridge<Ctx extends { cwd?: strin
183
182
  } else {
184
183
  if (data && typeof data === "object" && !Array.isArray(data)) {
185
184
  const legacy = data as Record<string, unknown>;
186
- if (legacy.tasks !== undefined || legacy.worktree !== undefined) {
187
- if (typeof legacy.requestId === "string" && legacy.requestId) {
188
- options.events.emit(PROMPT_TEMPLATE_SUBAGENT_RESPONSE_EVENT, {
189
- requestId: legacy.requestId,
190
- messages: [],
191
- isError: true,
192
- errorText: "Legacy prompt-template tasks/worktree orchestration was removed; use workflowScript.",
193
- });
194
- }
185
+ if ((legacy.tasks !== undefined || legacy.worktree !== undefined) && typeof legacy.requestId === "string" && legacy.requestId) {
186
+ options.events.emit(PROMPT_TEMPLATE_SUBAGENT_RESPONSE_EVENT, {
187
+ requestId: legacy.requestId,
188
+ messages: [],
189
+ isError: true,
190
+ errorText: "Legacy prompt-template tasks/worktree orchestration was removed; use workflowScript.",
191
+ });
195
192
  return;
196
193
  }
197
194
  }
198
195
  legacyRequest = parsePromptTemplateRequest(data);
199
196
  if (!legacyRequest) return;
200
- requestId = legacyRequest.requestId;
201
- params = toLegacyExecutionParams(legacyRequest);
197
+ options.events.emit(PROMPT_TEMPLATE_SUBAGENT_RESPONSE_EVENT, {
198
+ ...legacyRequest,
199
+ messages: [],
200
+ isError: true,
201
+ errorText: "Legacy prompt-template direct delegation was removed; use workflowScript through the subagent tool or structured delegation.",
202
+ } satisfies PromptTemplateDelegationResponse);
203
+ return;
202
204
  }
203
205
 
204
206
  if (!structuredRequest && legacyControllers.has(requestId)) return;