pi-subagents 0.42.0 → 0.43.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 (55) hide show
  1. package/CHANGELOG.md +35 -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 +27 -43
  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/control-notices.ts +2 -2
  14. package/src/extension/fanout-child.ts +46 -22
  15. package/src/extension/index.ts +29 -12
  16. package/src/extension/public-execution.ts +71 -0
  17. package/src/extension/rpc.ts +7 -8
  18. package/src/extension/schemas.ts +17 -17
  19. package/src/extension/tool-description.ts +14 -14
  20. package/src/missions/actions.ts +47 -11
  21. package/src/missions/goal-driver.ts +162 -0
  22. package/src/missions/lifecycle.ts +44 -12
  23. package/src/missions/store.ts +68 -3
  24. package/src/missions/types.ts +25 -3
  25. package/src/missions/workflow-state.ts +77 -0
  26. package/src/profiles/profiles.ts +1 -3
  27. package/src/runs/background/async-execution.ts +3 -0
  28. package/src/runs/background/async-job-tracker.ts +2 -17
  29. package/src/runs/background/control-channel.ts +50 -6
  30. package/src/runs/background/retained-children.ts +68 -0
  31. package/src/runs/background/scheduled-runs.ts +17 -13
  32. package/src/runs/background/steering.ts +7 -5
  33. package/src/runs/background/subagent-runner.ts +10 -4
  34. package/src/runs/foreground/async-steering-action.ts +36 -10
  35. package/src/runs/foreground/chain-clarify.ts +13 -16
  36. package/src/runs/foreground/execution.ts +4 -0
  37. package/src/runs/foreground/subagent-executor.ts +200 -59
  38. package/src/runs/shared/acceptance.ts +154 -9
  39. package/src/runs/shared/subagent-prompt-runtime.ts +94 -25
  40. package/src/shared/types.ts +17 -3
  41. package/src/slash/delegation-adapters.ts +0 -13
  42. package/src/slash/prompt-template-bridge.ts +14 -12
  43. package/src/slash/prompt-workflows.ts +10 -4
  44. package/src/slash/slash-bridge.ts +8 -6
  45. package/src/slash/slash-commands.ts +27 -12
  46. package/src/slash/slash-live-state.ts +4 -2
  47. package/src/tui/fleet-status.ts +8 -4
  48. package/src/tui/fleet.ts +15 -5
  49. package/src/tui/render.ts +13 -31
  50. package/src/workflows/chat-progress.ts +2 -2
  51. package/src/workflows/scripted-workflow.ts +97 -10
  52. package/agents/context-builder.md +0 -46
  53. package/agents/planner.md +0 -56
  54. package/prompts/parallel-context-build.md +0 -55
  55. 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")) {
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { registerNativeSupervisorClient } from "../../intercom/native-supervisor-channel.ts";
5
5
  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";
6
+ import { consumeSteerRequestsFromDir, MAX_STEER_QUEUE_SIZE, steerAckPathFromDir, writeSteerAckAt, writeSteerCapabilityAt, writeSteerRequestToDir, type SteerDeliveryStatus, type SteerRequest } from "../background/control-channel.ts";
7
7
  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
8
  import { RUNTIME_EXTENSION_ACK_EVENT, RUNTIME_EXTENSION_ACK_PATH_ENV, isRuntimeAcknowledgedExtensionId, writeRuntimeAcknowledgedExtensions } from "./runtime-acknowledged-extensions.ts";
9
9
  import { createStructuredOutputToolParameters, STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV, validateStructuredOutputValue } from "./structured-output.ts";
@@ -205,6 +205,38 @@ function isSubagentToolCallBlock(block: unknown): boolean {
205
205
  return b?.type === "toolCall" && b.name === "subagent";
206
206
  }
207
207
 
208
+ const PORTABLE_TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
209
+ const COMPOSITE_TOOL_ID_APIS = new Set([
210
+ "azure-openai-responses",
211
+ "openai-codex-responses",
212
+ "openai-completions",
213
+ "openai-responses",
214
+ ]);
215
+
216
+ function portableToolId(id: string): string {
217
+ if (PORTABLE_TOOL_ID_PATTERN.test(id)) return id;
218
+ return `tool_${Buffer.from(id).toString("base64url") || "empty"}`;
219
+ }
220
+
221
+ function sanitizeToolHistoryMessage(message: unknown): unknown {
222
+ const m = message as { role?: string; content?: unknown; toolCallId?: unknown };
223
+ if (m?.role === "toolResult" && typeof m.toolCallId === "string") {
224
+ const toolCallId = portableToolId(m.toolCallId);
225
+ return toolCallId === m.toolCallId ? message : { ...m, toolCallId };
226
+ }
227
+ if (m?.role !== "assistant" || !Array.isArray(m.content)) return message;
228
+ let changed = false;
229
+ const content = m.content.map((block) => {
230
+ const b = block as { type?: string; id?: unknown };
231
+ if (b?.type !== "toolCall" || typeof b.id !== "string") return block;
232
+ const id = portableToolId(b.id);
233
+ if (id === b.id) return block;
234
+ changed = true;
235
+ return { ...b, id };
236
+ });
237
+ return changed ? { ...m, content } : message;
238
+ }
239
+
208
240
  function stripAssistantSubagentToolCallBlocks(message: unknown): unknown | undefined {
209
241
  const m = message as { role?: string; content?: unknown };
210
242
  if (m?.role !== "assistant" || !Array.isArray(m.content)) return message;
@@ -214,8 +246,9 @@ function stripAssistantSubagentToolCallBlocks(message: unknown): unknown | undef
214
246
  return { ...m, content: filteredContent };
215
247
  }
216
248
 
217
- export function stripParentOnlySubagentMessages(messages: unknown[]): unknown[] {
249
+ export function stripParentOnlySubagentMessages(messages: unknown[], options: { sanitizeToolIds?: boolean } = {}): unknown[] {
218
250
  const preserveCurrentFanoutToolHistory = process.env[SUBAGENT_FANOUT_CHILD_ENV] === "1";
251
+ const sanitizeToolIds = options.sanitizeToolIds ?? true;
219
252
  let changed = false;
220
253
  const filtered: unknown[] = [];
221
254
  for (const message of messages) {
@@ -228,15 +261,16 @@ export function stripParentOnlySubagentMessages(messages: unknown[]): unknown[]
228
261
  changed = true;
229
262
  continue;
230
263
  }
231
- if (stripped !== message) changed = true;
232
- filtered.push(stripped);
264
+ const sanitized = sanitizeToolIds ? sanitizeToolHistoryMessage(stripped) : stripped;
265
+ if (stripped !== message || sanitized !== stripped) changed = true;
266
+ filtered.push(sanitized);
233
267
  }
234
268
  return changed ? filtered : messages;
235
269
  }
236
270
 
237
271
  export function formatSteerMessage(request: SteerRequest): string {
238
272
  return [
239
- "Mid-run steering from the parent orchestrator:",
273
+ request.mode === "follow_up" ? "Queued follow-up from the parent orchestrator:" : "Mid-run steering from the parent orchestrator:",
240
274
  "",
241
275
  request.message,
242
276
  "",
@@ -299,22 +333,26 @@ export function registerSteeringInbox(
299
333
  if (!steerInbox) return;
300
334
  const capabilityPath = process.env[SUBAGENT_STEER_CAPABILITY_ENV]?.trim();
301
335
  const ackDir = process.env[SUBAGENT_STEER_ACK_DIR_ENV]?.trim();
302
- const sendUserMessage = (pi as { sendUserMessage?: (content: string, options: { deliverAs: "steer" }) => unknown }).sendUserMessage;
336
+ const sendUserMessage = (pi as { sendUserMessage?: (content: string, options?: { deliverAs: "steer" | "followUp" }) => unknown }).sendUserMessage;
303
337
  const childIndex = Number(process.env[SUBAGENT_CHILD_INDEX_ENV]);
304
- const pending = new Map<string, string[]>();
338
+ const pending = new Map<string, Array<{ request: SteerRequest; deliveryStatus: SteerDeliveryStatus }>>();
339
+ const queued: Array<{ request: SteerRequest; ready: boolean }> = [];
305
340
  let disposed = false;
341
+ let agentRunning = false;
342
+ let inTurn = false;
306
343
  let flushing = false;
307
344
  let started = false;
308
345
  let canSteer = typeof sendUserMessage === "function";
309
346
  let watcher: fs.FSWatcher | undefined;
310
347
  let interval: NodeJS.Timeout | undefined;
311
- const acknowledge = (request: SteerRequest, state: "delivered" | "failed", message: string): void => {
348
+ const acknowledge = (request: SteerRequest, state: "delivered" | "queued" | "failed", message: string, deliveryStatus?: SteerDeliveryStatus): void => {
312
349
  if (!ackDir || !Number.isInteger(childIndex) || childIndex < 0) return;
313
350
  writeSteerAckAt(steerAckPathFromDir(ackDir, request.id), {
314
351
  requestId: request.id,
315
352
  index: childIndex,
316
353
  ts: Date.now(),
317
354
  state,
355
+ ...(deliveryStatus ? { deliveryStatus } : {}),
318
356
  message,
319
357
  });
320
358
  };
@@ -333,15 +371,22 @@ export function registerSteeringInbox(
333
371
  acknowledge(request, "failed", "Child Pi session does not support sendUserMessage steering.");
334
372
  continue;
335
373
  }
374
+ const requestedMode = request.mode ?? "steer";
375
+ const delivery = requestedMode === "follow_up" || (requestedMode === "auto" && inTurn) ? "followUp" as const : "steer" as const;
376
+ const pendingFollowUps = [...pending.values()].reduce((count, entries) => count + entries.filter((entry) => entry.deliveryStatus === "queued").length, 0);
377
+ if (delivery === "followUp" && queued.length + pendingFollowUps >= MAX_STEER_QUEUE_SIZE) {
378
+ acknowledge(request, "failed", `Follow-up queue is full (${MAX_STEER_QUEUE_SIZE} messages).`);
379
+ continue;
380
+ }
336
381
  const formatted = formatSteerMessage(request);
337
- const ids = pending.get(formatted) ?? [];
338
- ids.push(request.id);
339
- pending.set(formatted, ids);
382
+ const entries = pending.get(formatted) ?? [];
383
+ entries.push({ request, deliveryStatus: delivery === "followUp" ? "queued" : "delivered" });
384
+ pending.set(formatted, entries);
340
385
  try {
341
- sendUserMessage(formatted, { deliverAs: "steer" });
386
+ sendUserMessage(formatted, requestedMode === "auto" && !agentRunning ? undefined : { deliverAs: delivery });
342
387
  } catch (error) {
343
- ids.pop();
344
- if (ids.length === 0) pending.delete(formatted);
388
+ entries.pop();
389
+ if (entries.length === 0) pending.delete(formatted);
345
390
  acknowledge(request, "failed", error instanceof Error ? error.message : String(error));
346
391
  for (const retry of requests.slice(index + 1)) writeSteerRequestToDir(steerInbox, retry);
347
392
  break;
@@ -354,14 +399,19 @@ export function registerSteeringInbox(
354
399
  const onInput = (event: unknown): undefined => {
355
400
  if (disposed || !event || typeof event !== "object") return undefined;
356
401
  const input = event as { source?: unknown; streamingBehavior?: unknown; text?: unknown; content?: unknown };
357
- if (input.source !== "extension" || input.streamingBehavior !== "steer") return undefined;
402
+ if (input.source !== "extension") return undefined;
358
403
  const text = typeof input.text === "string" ? input.text : typeof input.content === "string" ? input.content : undefined;
359
404
  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.");
405
+ const entries = pending.get(text);
406
+ const entry = entries?.shift();
407
+ if (!entry) return undefined;
408
+ if (entries?.length === 0) pending.delete(text);
409
+ if (entry.deliveryStatus === "queued") {
410
+ queued.push({ request: entry.request, ready: !inTurn });
411
+ acknowledge(entry.request, "queued", "Pi queued the correlated follow-up input.", "queued");
412
+ } else {
413
+ acknowledge(entry.request, "delivered", "Pi accepted the correlated steering input.", "delivered");
414
+ }
365
415
  return undefined;
366
416
  };
367
417
  const start = (): void => {
@@ -392,10 +442,27 @@ export function registerSteeringInbox(
392
442
  // Register input before the watcher so an accepted extension input cannot race request dispatch.
393
443
  onRuntimeEvent("input", onInput);
394
444
  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) {
445
+ onRuntimeEvent("agent_start", () => { agentRunning = true; return activate(); });
446
+ onRuntimeEvent("agent_end", () => { agentRunning = false; inTurn = false; return activate(); });
447
+ onRuntimeEvent("turn_start", () => {
448
+ inTurn = true;
449
+ const next = queued.findIndex((entry) => entry.ready);
450
+ if (next >= 0) {
451
+ const [entry] = queued.splice(next, 1);
452
+ if (entry) acknowledge(entry.request, "delivered", "Pi delivered the queued follow-up at a turn boundary.", "delivered");
453
+ }
454
+ return activate();
455
+ });
456
+ onRuntimeEvent("turn_end", () => {
457
+ inTurn = false;
458
+ for (const entry of queued) entry.ready = true;
459
+ return activate();
460
+ });
461
+ for (const eventName of ["message_start", "message_update", "message_end", "tool_execution_start", "tool_execution_end"] as const) {
396
462
  onRuntimeEvent(eventName, activate);
397
463
  }
398
464
  onRuntimeEvent("session_shutdown", () => {
465
+ for (const entry of queued) acknowledge(entry.request, "failed", "Run ended before queued follow-up delivery.", "queued");
399
466
  disposed = true;
400
467
  try { watcher?.close(); } catch {}
401
468
  if (interval) clearInterval(interval);
@@ -437,8 +504,8 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
437
504
  nativeSupervisorFallbackRegistered = true;
438
505
  registerNativeSupervisorClient(pi);
439
506
  };
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) => {
507
+ const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown, ctx?: ExtensionContext) => unknown) => void;
508
+ onRuntimeEvent("session_start", (_event: unknown, ctx?: ExtensionContext) => {
442
509
  const sessionManager = (ctx as { sessionManager?: Parameters<typeof resolveCurrentSessionId>[0] } | undefined)?.sessionManager;
443
510
  waitState.currentSessionId = sessionManager ? resolveCurrentSessionId(sessionManager) : null;
444
511
  registerNativeSupervisorClientOnce();
@@ -484,9 +551,11 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
484
551
  });
485
552
  }
486
553
 
487
- onRuntimeEvent("context", (event: unknown) => {
554
+ onRuntimeEvent("context", (event: unknown, ctx?: ExtensionContext) => {
488
555
  if (!event || typeof event !== "object" || !("messages" in event) || !Array.isArray(event.messages)) return undefined;
489
- const messages = stripParentOnlySubagentMessages(event.messages);
556
+ const messages = stripParentOnlySubagentMessages(event.messages, {
557
+ sanitizeToolIds: !COMPOSITE_TOOL_ID_APIS.has(ctx?.model?.api ?? ""),
558
+ });
490
559
  if (messages === event.messages) return undefined;
491
560
  return { messages };
492
561
  });
@@ -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 {
@@ -1007,7 +1021,7 @@ export interface Details {
1007
1021
  console: Array<{ level: "log" | "info" | "warn" | "error"; text: string }>;
1008
1022
  };
1009
1023
  chatProgress?: {
1010
- mode: "off" | "terminal" | "milestones" | "live-card";
1024
+ mode: "off" | "live-card";
1011
1025
  repoRelation: "same" | "other";
1012
1026
  repoLabel?: string;
1013
1027
  };
@@ -1868,7 +1882,7 @@ export const SLASH_SUBAGENT_CANCEL_EVENT = "subagent:slash:cancel";
1868
1882
  export const POLL_INTERVAL_MS = 250;
1869
1883
  export const MAX_WIDGET_JOBS = 4;
1870
1884
  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;
1885
+ 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
1886
 
1873
1887
  export const DEFAULT_FORK_PREAMBLE =
1874
1888
  "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;
@@ -213,13 +213,19 @@ function workflowParams(workflow: PromptWorkflow, args: string[], runtime: Retur
213
213
  return {
214
214
  agent: runtime.agentOverride ?? workflow.agent,
215
215
  task,
216
- clarify: false,
217
216
  agentScope: "both",
218
217
  ...(context ? { context } : {}),
219
218
  ...(workflow.model ? { model: workflow.model } : {}),
220
219
  ...(workflow.skill !== undefined ? { skill: workflow.skill } : {}),
221
220
  ...(workflow.cwd ? { cwd: workflow.cwd } : {}),
222
- ...(runtime.bg ? { async: true } : {}),
221
+ };
222
+ }
223
+
224
+ function promptWorkflowExecutionParams(workflows: PromptWorkflow[], args: string[], runtime: ReturnType<typeof parseRuntimeOptions>): SubagentParamsLike {
225
+ return {
226
+ workflowScript: promptWorkflowScript(workflows, args, runtime),
227
+ agentScope: "both",
228
+ async: runtime.bg ? true : false,
223
229
  };
224
230
  }
225
231
 
@@ -281,10 +287,10 @@ export function registerPromptWorkflowCommands(input: {
281
287
  if (!step) throw new Error(`Unknown prompt workflow in chain '${workflow.name}': ${stepName}`);
282
288
  return step;
283
289
  });
284
- await run({ workflowScript: promptWorkflowScript(chain, runtime.args, runtime), clarify: false, agentScope: "both", ...(runtime.bg ? { async: true } : {}) }, ctx);
290
+ await run(promptWorkflowExecutionParams(chain, runtime.args, runtime), ctx);
285
291
  return;
286
292
  }
287
- await run(workflowParams(workflow, runtime.args, runtime), ctx);
293
+ await run(promptWorkflowExecutionParams([workflow], runtime.args, runtime), ctx);
288
294
  } catch (error) {
289
295
  ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
290
296
  }
@@ -1,5 +1,6 @@
1
1
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { normalizePublicSubagentExecution } from "../extension/public-execution.ts";
3
4
  import type { SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
4
5
  import {
5
6
  SLASH_SUBAGENT_CANCEL_EVENT,
@@ -77,21 +78,22 @@ export function registerSlashSubagentBridge(options: SlashBridgeOptions): {
77
78
  if (!data || typeof data !== "object") return;
78
79
  const request = data as Partial<SlashSubagentRequest>;
79
80
  if (typeof request.requestId !== "string" || !request.params) return;
80
- const { requestId, params } = request as SlashSubagentRequest;
81
- if (params.tasks !== undefined || params.chain !== undefined || params.concurrency !== undefined || params.worktree !== undefined || params.chainDir !== undefined) {
82
- const message = "Legacy slash-event chain and parallel inputs were removed; use workflowScript.";
81
+ const { requestId } = request as SlashSubagentRequest;
82
+ const normalized = normalizePublicSubagentExecution((request as SlashSubagentRequest).params);
83
+ if (!normalized.ok) {
83
84
  options.events.emit(SLASH_SUBAGENT_RESPONSE_EVENT, {
84
85
  requestId,
85
86
  result: {
86
- content: [{ type: "text", text: message }],
87
+ content: [{ type: "text", text: normalized.error }],
87
88
  isError: true,
88
- details: { mode: "management", results: [] },
89
+ details: { mode: normalized.mode, results: [] },
89
90
  },
90
91
  isError: true,
91
- errorText: message,
92
+ errorText: normalized.error,
92
93
  } satisfies SlashSubagentResponse);
93
94
  return;
94
95
  }
96
+ const params = normalized.params;
95
97
 
96
98
  const ctx = request.ctx ?? options.getContext();
97
99
  if (!ctx) {