pi-subagents 0.35.0 → 0.36.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 (76) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +132 -24
  3. package/agents/advisor.md +73 -0
  4. package/package.json +8 -12
  5. package/skills/pi-subagents/SKILL.md +22 -9
  6. package/src/agents/agents.ts +22 -5
  7. package/src/api/delegation.ts +125 -0
  8. package/src/extension/config.ts +7 -1
  9. package/src/extension/index.ts +50 -38
  10. package/src/extension/rpc.ts +27 -2
  11. package/src/extension/schemas.ts +22 -2
  12. package/src/extension/tool-description.ts +2 -2
  13. package/src/intercom/intercom-bridge.ts +1 -1
  14. package/src/intercom/native-supervisor-channel.ts +45 -6
  15. package/src/intercom/result-intercom.ts +7 -0
  16. package/src/runs/background/async-execution.ts +34 -4
  17. package/src/runs/background/async-job-tracker.ts +4 -0
  18. package/src/runs/background/async-resume.ts +27 -5
  19. package/src/runs/background/async-status.ts +76 -3
  20. package/src/runs/background/chain-append.ts +2 -0
  21. package/src/runs/background/completion-batcher.ts +6 -4
  22. package/src/runs/background/completion-dedupe.ts +2 -11
  23. package/src/runs/background/fleet-view.ts +9 -4
  24. package/src/runs/background/notify.ts +132 -120
  25. package/src/runs/background/result-watcher.ts +138 -78
  26. package/src/runs/background/run-status.ts +3 -1
  27. package/src/runs/background/subagent-runner.ts +225 -43
  28. package/src/runs/background/subagent-wait.ts +130 -4
  29. package/src/runs/background/wait-tool.ts +2 -2
  30. package/src/runs/foreground/chain-execution.ts +176 -111
  31. package/src/runs/foreground/execution.ts +90 -36
  32. package/src/runs/foreground/foreground-control.ts +90 -0
  33. package/src/runs/foreground/subagent-executor.ts +394 -163
  34. package/src/runs/shared/acceptance.ts +55 -13
  35. package/src/runs/shared/agent-contract.ts +38 -0
  36. package/src/runs/shared/child-protocol.ts +1 -1
  37. package/src/runs/shared/completion-guard.ts +36 -5
  38. package/src/runs/shared/context-mode.ts +44 -0
  39. package/src/runs/shared/dynamic-fanout.ts +4 -4
  40. package/src/runs/shared/long-running-guard.ts +4 -0
  41. package/src/runs/shared/nested-events.ts +27 -2
  42. package/src/runs/shared/parallel-handoff.ts +154 -0
  43. package/src/runs/shared/parallel-utils.ts +6 -0
  44. package/src/runs/shared/pi-args.ts +23 -14
  45. package/src/runs/shared/run-history.ts +90 -5
  46. package/src/runs/shared/structured-output.ts +112 -7
  47. package/src/runs/shared/subagent-control.ts +4 -0
  48. package/src/runs/shared/subagent-prompt-runtime.ts +17 -18
  49. package/src/runs/shared/task-intent.ts +10 -5
  50. package/src/runs/shared/tool-availability.ts +3 -1
  51. package/src/runs/shared/tool-budget.ts +11 -5
  52. package/src/runs/shared/turn-budget.ts +2 -1
  53. package/src/runs/shared/worktree.ts +63 -14
  54. package/src/shared/accessible-dir.ts +25 -0
  55. package/src/shared/artifacts.ts +37 -7
  56. package/src/shared/atomic-json.ts +14 -42
  57. package/src/shared/child-transcript.ts +52 -0
  58. package/src/shared/file-system-retry.ts +47 -0
  59. package/src/shared/settings.ts +9 -1
  60. package/src/shared/types.ts +211 -22
  61. package/src/slash/delegation-adapters.ts +152 -5
  62. package/src/slash/delegation-json.ts +108 -0
  63. package/src/slash/delegation-request.ts +182 -36
  64. package/src/slash/prompt-template-bridge.ts +222 -37
  65. package/src/slash/selector.ts +147 -0
  66. package/src/slash/slash-commands.ts +14 -5
  67. package/src/slash/slash-live-state.ts +2 -2
  68. package/src/slash/subagents-admin.ts +42 -42
  69. package/src/tui/fleet-status.ts +362 -0
  70. package/src/tui/fleet-transcript.ts +472 -0
  71. package/src/tui/fleet.ts +318 -59
  72. package/src/tui/render.ts +25 -15
  73. package/src/watchdog/change-signature.ts +105 -12
  74. package/src/watchdog/review.ts +7 -2
  75. package/src/watchdog/runtime.ts +5 -3
  76. package/src/slash/subagents-editor.ts +0 -86
@@ -6,6 +6,7 @@ import type {
6
6
  AcceptanceConfig,
7
7
  AcceptanceEvidenceKind,
8
8
  AcceptanceInput,
9
+ AgentContract,
9
10
  AcceptanceLedger,
10
11
  AcceptanceLevel,
11
12
  AcceptanceReport,
@@ -20,6 +21,7 @@ import type {
20
21
  SingleResult,
21
22
  SubagentRunMode,
22
23
  } from "../../shared/types.ts";
24
+ import { isAgentContractV1 } from "./agent-contract.ts";
23
25
  import { classifyTaskMutationIntent, taskMayMutate } from "./task-intent.ts";
24
26
 
25
27
  const LEVEL_RANK: Record<Exclude<AcceptanceLevel, "auto">, number> = {
@@ -90,9 +92,9 @@ function inferLevel(input: {
90
92
  && intent.kind !== "read-only"
91
93
  && !/\b(?:do not|don't|must not)\s+patch\b/.test(task)
92
94
  && /\bpatch\s+(?:(?:\.{0,2}[\\/])?(?:[\w.-]+[\\/])+[\w.-]+|[\w.-]+\.[a-z0-9]+\b|(?:the\s+)?parser\b)/.test(task);
93
- const taskMayWrite = taskMayMutate(input.task ?? "") || intent.kind === "implementation" || rolePatchTask;
95
+ const taskMayWrite = readOnlyTask ? false : taskMayMutate(input.task ?? "") || intent.kind === "implementation" || rolePatchTask;
94
96
  const readOnlyAgent = input.acceptanceRole === "read-only"
95
- || (input.acceptanceRole === undefined && /\b(?:reviewer|scout|context-builder|researcher|analyst)\b/.test(agent));
97
+ || (input.acceptanceRole === undefined && /\b(?:reviewer|oracle|scout|context-builder|researcher|analyst)\b/.test(agent));
96
98
  const writeTask = taskMayWrite
97
99
  || (input.acceptanceRole === "writer" && !readOnlyTask)
98
100
  || (input.acceptanceRole === undefined && /\bworker\b/.test(agent) && !readOnlyTask);
@@ -324,10 +326,32 @@ export function resolveEffectiveAcceptance(input: {
324
326
  async?: boolean;
325
327
  dynamic?: boolean;
326
328
  dynamicGroup?: boolean;
329
+ agentContract?: AgentContract;
327
330
  }): ResolvedAcceptanceConfig {
328
331
  const explicit = normalizeAcceptanceInput(input.explicit);
329
- const inferred = inferLevel(input);
330
332
  const explicitLevel = normalizeLevel(explicit.level);
333
+ if (isAgentContractV1(input.agentContract)) {
334
+ const level = explicitAcceptanceCanDisable(explicit) || explicitLevel === "auto"
335
+ ? "none"
336
+ : explicitLevel;
337
+ const evidence = unique(explicit.evidence ?? []);
338
+ const criteria = normalizeCriteria(
339
+ explicit.criteria as Array<string | { id?: string; must?: string; evidence?: AcceptanceEvidenceKind[]; severity?: "required" | "recommended" }> | undefined,
340
+ evidence,
341
+ );
342
+ return {
343
+ level,
344
+ explicit: input.explicit !== undefined,
345
+ inferredReason: [],
346
+ criteria,
347
+ evidence,
348
+ verify: explicit.verify ?? [],
349
+ review: explicit.review,
350
+ stopRules: explicit.stopRules ?? [],
351
+ reason: explicit.reason,
352
+ };
353
+ }
354
+ const inferred = inferLevel(input);
331
355
  const level = explicitAcceptanceCanDisable(explicit)
332
356
  ? "none"
333
357
  : explicitLevel === "auto"
@@ -355,8 +379,13 @@ export function resolveEffectiveAcceptance(input: {
355
379
  };
356
380
  }
357
381
 
358
- export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig): string {
382
+ function acceptanceRequiresChildReport(acceptance: ResolvedAcceptanceConfig): boolean {
383
+ return acceptance.criteria.length > 0 || acceptance.evidence.length > 0;
384
+ }
385
+
386
+ export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig, options: { reportOptional?: boolean } = {}): string {
359
387
  if (acceptance.level === "none") return "";
388
+ if (options.reportOptional && !acceptanceRequiresChildReport(acceptance)) return "";
360
389
  const lines = [
361
390
  "",
362
391
  "## Acceptance Contract",
@@ -1029,6 +1058,7 @@ export async function evaluateAcceptance(input: {
1029
1058
  reviewResult?: AcceptanceReviewResult;
1030
1059
  signal?: AbortSignal;
1031
1060
  abortMessage?: string;
1061
+ reportOptional?: boolean;
1032
1062
  }): Promise<AcceptanceLedger> {
1033
1063
  const acceptance = input.acceptance;
1034
1064
  const ledger: AcceptanceLedger = {
@@ -1050,26 +1080,30 @@ export async function evaluateAcceptance(input: {
1050
1080
  : { error: `Failed to parse acceptance-report: Invalid acceptance-report: ${validation.errors.join("; ")}` };
1051
1081
  })()
1052
1082
  : parseAcceptanceReportSources(input.output, input.fileOutput);
1083
+ const needsReport = acceptanceRequiresChildReport(acceptance);
1053
1084
  if (parsed.report) {
1054
1085
  ledger.childReport = parsed.report;
1055
1086
  ledger.status = "attested";
1056
- } else {
1087
+ } else if (!input.reportOptional || needsReport || parsed.error !== ACCEPTANCE_REPORT_NOT_FOUND) {
1057
1088
  ledger.childReportParseError = parsed.error;
1058
1089
  ledger.runtimeChecks.push({ id: "attestation", status: "failed", message: parsed.error ?? "Structured acceptance report missing." });
1059
- ledger.status = "rejected";
1060
- return ledger;
1090
+ if (!input.reportOptional) {
1091
+ ledger.status = "rejected";
1092
+ return ledger;
1093
+ }
1094
+ } else {
1095
+ ledger.childReportParseError = parsed.error;
1061
1096
  }
1062
1097
 
1063
- if (LEVEL_RANK[acceptance.level] >= LEVEL_RANK.checked) {
1098
+ if (parsed.report && LEVEL_RANK[acceptance.level] >= LEVEL_RANK.checked) {
1064
1099
  ledger.runtimeChecks = [
1100
+ ...ledger.runtimeChecks,
1065
1101
  ...checkCriteriaSatisfied(acceptance.criteria, parsed.report),
1066
1102
  ...runStructuralChecks(acceptance, parsed.report, input.cwd),
1067
1103
  ];
1068
- if (ledger.runtimeChecks.some((check) => check.status === "failed")) {
1069
- ledger.status = "rejected";
1070
- return ledger;
1104
+ if (!ledger.runtimeChecks.some((check) => check.status === "failed")) {
1105
+ ledger.status = "checked";
1071
1106
  }
1072
- ledger.status = "checked";
1073
1107
  }
1074
1108
 
1075
1109
  if (LEVEL_RANK[acceptance.level] >= LEVEL_RANK.verified && (acceptance.level === "verified" || acceptance.verify.length > 0)) {
@@ -1087,7 +1121,15 @@ export async function evaluateAcceptance(input: {
1087
1121
  ledger.status = "rejected";
1088
1122
  return ledger;
1089
1123
  }
1090
- ledger.status = "verified";
1124
+ if (!ledger.runtimeChecks.some((check) => check.status === "failed")) ledger.status = "verified";
1125
+ }
1126
+
1127
+ if (ledger.runtimeChecks.some((check) => check.status === "failed")) {
1128
+ ledger.status = "rejected";
1129
+ return ledger;
1130
+ }
1131
+ if (ledger.status === "claimed" && acceptance.level !== "reviewed") {
1132
+ ledger.status = acceptance.level === "verified" ? "verified" : acceptance.level;
1091
1133
  }
1092
1134
 
1093
1135
  if (acceptance.level === "reviewed") {
@@ -0,0 +1,38 @@
1
+ import type { AgentContract, EffectsProjection, ExecutionProjection, ReviewProjection, SingleResult } from "../../shared/types.ts";
2
+
3
+ export function isAgentContractV1(contract: AgentContract | undefined): boolean {
4
+ return contract?.version === 1;
5
+ }
6
+
7
+ export function buildExecutionProjection(result: Pick<SingleResult, "exitCode" | "error" | "interrupted" | "timedOut" | "stopped" | "detached">): ExecutionProjection {
8
+ if (result.detached) {
9
+ return { status: "detached", success: false, exitCode: result.exitCode, detached: true, ...(result.error ? { error: result.error } : {}) };
10
+ }
11
+ if (result.stopped) {
12
+ return { status: "stopped", success: false, exitCode: result.exitCode, stopped: true, ...(result.error ? { error: result.error } : {}) };
13
+ }
14
+ if (result.interrupted) {
15
+ return { status: "paused", success: true, exitCode: result.exitCode, interrupted: true, ...(result.error ? { error: result.error } : {}) };
16
+ }
17
+ const success = result.exitCode === 0 && !result.error && !result.timedOut;
18
+ return {
19
+ status: success ? "completed" : "failed",
20
+ success,
21
+ exitCode: result.exitCode,
22
+ ...(result.error ? { error: result.error } : {}),
23
+ ...(result.timedOut ? { timedOut: true } : {}),
24
+ };
25
+ }
26
+
27
+ export function buildReviewProjection(result: Pick<SingleResult, "acceptance">): ReviewProjection {
28
+ const review = result.acceptance?.reviewResult;
29
+ if (!review) return { status: "not-requested" };
30
+ return { status: review.status, findings: review.findings };
31
+ }
32
+
33
+ export function attachContractProjections<T extends SingleResult>(result: T): T {
34
+ result.execution = buildExecutionProjection(result);
35
+ result.review = buildReviewProjection(result);
36
+ if (!result.effects) result.effects = {} satisfies EffectsProjection;
37
+ return result;
38
+ }
@@ -3,7 +3,7 @@ import type { ProtocolOutputLimit } from "../../shared/types.ts";
3
3
 
4
4
  export type { ProtocolOutputLimit } from "../../shared/types.ts";
5
5
 
6
- export const MAX_CHILD_PENDING_LINE_BYTES = 4 * 1024 * 1024;
6
+ export const MAX_CHILD_PENDING_LINE_BYTES = 16 * 1024 * 1024;
7
7
  export const MAX_CHILD_STDERR_BYTES = 128 * 1024;
8
8
  const MAX_PROTOCOL_DIAGNOSTIC_BYTES = 4096;
9
9
 
@@ -1,5 +1,5 @@
1
1
  import type { Message } from "@earendil-works/pi-ai";
2
- import { isMutatingBashCommand } from "./long-running-guard.ts";
2
+ import { isMutatingTool } from "./long-running-guard.ts";
3
3
  import { expectsImplementationMutation } from "./task-intent.ts";
4
4
 
5
5
  export { expectsImplementationMutation };
@@ -16,6 +16,12 @@ const READ_ONLY_BUILTIN_TOOLS = new Set([
16
16
  "contact_supervisor",
17
17
  ]);
18
18
 
19
+ // Cursor native edit/write often land as thinking traces (inactive_trace /
20
+ // transcript_trace) rather than toolCall parts when native tool replay is off
21
+ // or the tool is inactive in context. Match pi-cursor-sdk display labels.
22
+ const CURSOR_FILE_MUTATION_THINKING =
23
+ /(?:^|\n)\s*Cursor (?:edit|write)\s*:/i;
24
+
19
25
  interface CompletionMutationGuardInput {
20
26
  agent: string;
21
27
  task: string;
@@ -31,21 +37,46 @@ interface CompletionMutationGuardResult {
31
37
  }
32
38
 
33
39
  export function hasMutationToolCapability(tools: string[] | undefined, mcpDirectTools: string[] | undefined): boolean {
34
- if (tools === undefined || tools.length === 0 || (mcpDirectTools?.length ?? 0) > 0) return true;
40
+ if ((mcpDirectTools?.length ?? 0) > 0) return true;
41
+ if (tools === undefined) return true;
35
42
  return !tools.every((tool) => READ_ONLY_BUILTIN_TOOLS.has(tool));
36
43
  }
37
44
 
45
+ function hasCheckpointMutationEvidence(message: Message): boolean {
46
+ const record = message as unknown as {
47
+ role?: string;
48
+ type?: string;
49
+ customType?: unknown;
50
+ data?: unknown;
51
+ details?: unknown;
52
+ };
53
+ if ((record.role !== "custom" && record.type !== "custom") || record.customType !== "pi-checkpoint") return false;
54
+ const details = typeof record.details === "object" && record.details !== null && !Array.isArray(record.details)
55
+ ? record.details as Record<string, unknown>
56
+ : undefined;
57
+ const data = typeof record.data === "object" && record.data !== null && !Array.isArray(record.data)
58
+ ? record.data as Record<string, unknown>
59
+ : typeof details?.beforeCommit === "string" || typeof details?.afterCommit === "string"
60
+ ? details
61
+ : details?.data && typeof details.data === "object" && !Array.isArray(details.data)
62
+ ? details.data as Record<string, unknown>
63
+ : undefined;
64
+ return typeof data?.beforeCommit === "string"
65
+ && typeof data.afterCommit === "string"
66
+ && data.beforeCommit !== data.afterCommit;
67
+ }
68
+
38
69
  export function hasMutationToolCall(messages: Message[]): boolean {
39
70
  for (const message of messages) {
71
+ if (hasCheckpointMutationEvidence(message)) return true;
40
72
  if (message.role !== "assistant") continue;
41
73
  for (const part of message.content) {
74
+ if (part.type === "thinking" && CURSOR_FILE_MUTATION_THINKING.test(part.thinking)) return true;
42
75
  if (part.type !== "toolCall") continue;
43
- if (part.name === "edit" || part.name === "write") return true;
44
- if (part.name !== "bash") continue;
45
76
  const args = typeof part.arguments === "object" && part.arguments !== null && !Array.isArray(part.arguments)
46
77
  ? part.arguments as Record<string, unknown>
47
78
  : {};
48
- if (typeof args.command === "string" && isMutatingBashCommand(args.command)) return true;
79
+ if (isMutatingTool(part.name, args)) return true;
49
80
  }
50
81
  }
51
82
  return false;
@@ -0,0 +1,44 @@
1
+ export type ContextMode = "fresh" | "fork";
2
+ export type ContextSummary = ContextMode | "mixed";
3
+
4
+ export function isContextMode(value: unknown): value is ContextMode {
5
+ return value === "fresh" || value === "fork";
6
+ }
7
+
8
+ export function isContextSummary(value: unknown): value is ContextSummary {
9
+ return isContextMode(value) || value === "mixed";
10
+ }
11
+
12
+ export function summarizeContextModes(modes: Array<ContextMode | undefined>): ContextSummary | undefined {
13
+ const resolved = modes.filter(isContextMode);
14
+ if (resolved.length === 0) return undefined;
15
+ const first = resolved[0]!;
16
+ return resolved.every((mode) => mode === first) ? first : "mixed";
17
+ }
18
+
19
+ export function contextModeLabel(mode: ContextMode | ContextSummary | undefined): string {
20
+ if (mode === "fork") return "[fork]";
21
+ if (mode === "fresh") return "[fresh]";
22
+ if (mode === "mixed") return "[mixed]";
23
+ return "";
24
+ }
25
+
26
+ export function contextModeBadge(
27
+ theme: { fg(name: string, text: string): string },
28
+ mode: ContextMode | ContextSummary | undefined,
29
+ ): string {
30
+ const label = contextModeLabel(mode);
31
+ if (!label) return "";
32
+ if (mode === "fork") return theme.fg("warning", ` ${label}`);
33
+ return theme.fg("dim", ` ${label}`);
34
+ }
35
+
36
+ export function contextModePrefix(
37
+ theme: { fg(name: string, text: string): string },
38
+ mode: ContextMode | ContextSummary | undefined,
39
+ ): string {
40
+ const label = contextModeLabel(mode);
41
+ if (!label) return "";
42
+ if (mode === "fork") return `${theme.fg("warning", label)} `;
43
+ return `${theme.fg("dim", label)} `;
44
+ }
@@ -42,11 +42,11 @@ const SAFE_OUTPUT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
42
42
  const ITEM_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
43
43
  const ITEM_REF_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)(?:\.([^{}]+))?\}/g;
44
44
  const RESERVED_TEMPLATE_NAMES = new Set(["task", "previous", "chain_dir", "outputs"]);
45
- const DYNAMIC_STEP_KEYS = new Set(["expand", "parallel", "collect", "concurrency", "failFast", "phase", "label", "acceptance"]);
45
+ const DYNAMIC_STEP_KEYS = new Set(["expand", "parallel", "collect", "concurrency", "failFast", "phase", "label", "acceptance", "agentContract", "gateOn"]);
46
46
  const RUNNER_DYNAMIC_STEP_KEYS = new Set([...DYNAMIC_STEP_KEYS, "effectiveAcceptance", "acceptanceInput", "acceptanceRole", "sessionFiles", "thinkingOverrides"]);
47
47
  const DYNAMIC_EXPAND_KEYS = new Set(["from", "item", "key", "maxItems", "onEmpty"]);
48
48
  const DYNAMIC_EXPAND_FROM_KEYS = new Set(["output", "path"]);
49
- const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outputSchema", "cwd", "output", "outputMode", "reads", "progress", "skill", "model", "toolBudget", "acceptance"]);
49
+ const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outputSchema", "cwd", "output", "outputMode", "reads", "progress", "skill", "model", "toolBudget", "acceptance", "agentContract", "gateOn"]);
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",
@@ -288,9 +288,9 @@ export function collectDynamicResults(
288
288
  });
289
289
  }
290
290
 
291
- export function validateDynamicCollection(schema: JsonSchemaObject | undefined, value: DynamicCollectedResult[]): void {
291
+ export async function validateDynamicCollection(schema: JsonSchemaObject | undefined, value: DynamicCollectedResult[]): Promise<void> {
292
292
  if (!schema) return;
293
- const validation = validateStructuredOutputValue(schema, value);
293
+ const validation = await validateStructuredOutputValue(schema, value);
294
294
  if (validation.status === "invalid") {
295
295
  throw new DynamicFanoutError(`Collected output validation failed: ${validation.message}`);
296
296
  }
@@ -103,6 +103,10 @@ export function isMutatingBashCommand(command: string): boolean {
103
103
  export function isMutatingTool(toolName: string | undefined, args: Record<string, unknown> | undefined): boolean {
104
104
  if (!toolName) return false;
105
105
  if (toolName === "edit" || toolName === "write") return true;
106
+ if (toolName === "cursor") {
107
+ const activityTitle = typeof args?.activityTitle === "string" ? args.activityTitle : "";
108
+ return /^Cursor (?:edit|write)\b/i.test(activityTitle);
109
+ }
106
110
  if (toolName !== "bash") return false;
107
111
  const command = typeof args?.command === "string" ? args.command : "";
108
112
  if (!command.trim()) return false;
@@ -612,7 +612,9 @@ export function projectNestedEvents(route: NestedRoute): NestedRegistry {
612
612
  } catch {
613
613
  continue;
614
614
  }
615
- for (const event of parseNestedEventRecords(content, route)) {
615
+ const records = parseNestedEventRecords(content, route);
616
+ if (records.length === 0) continue;
617
+ for (const event of records) {
616
618
  registry = applyNestedEvent(registry, event);
617
619
  changed = true;
618
620
  }
@@ -620,11 +622,34 @@ export function projectNestedEvents(route: NestedRoute): NestedRegistry {
620
622
  changed = true;
621
623
  }
622
624
  if (changed) {
623
- registry = { ...registry, processedEvents: [...seen].slice(-1000) };
625
+ const processedEvents = [...seen];
626
+ const retainedEvents = processedEvents.slice(-1000);
627
+ const evictedEvents = processedEvents.slice(0, -1000);
628
+ registry = { ...registry, processedEvents: retainedEvents };
624
629
  // Parent projection is the only writer to this sidecar registry. Child and
625
630
  // runner processes only create immutable event files, so parent status.json
626
631
  // remains owned by the existing runner writer and is never rewritten here.
627
632
  writeAtomicJson(registryPath(route), registry);
633
+
634
+ // The registry retains only a bounded filename cursor. Remove the old
635
+ // immutable status records after the cursor is durable; otherwise an evicted
636
+ // filename is rediscovered on the next projection and replayed forever.
637
+ for (const entry of evictedEvents) {
638
+ const eventPath = path.join(route.eventSink, entry);
639
+ if (!containedPath(route.eventSink, eventPath)) continue;
640
+ try {
641
+ const stat = fs.statSync(eventPath);
642
+ if (!stat.isFile() || stat.size > MAX_EVENT_BYTES) continue;
643
+ const content = fs.readFileSync(eventPath, "utf-8");
644
+ const hasStatusRecord = parseNestedEventRecords(content, route).length > 0;
645
+ const hasControlResult = content
646
+ .split("\n")
647
+ .some((line) => line.trim() && parseControlResult(line.trim(), route));
648
+ if (hasStatusRecord && !hasControlResult) fs.unlinkSync(eventPath);
649
+ } catch {
650
+ // A cleanup failure must not make a successfully projected status fail.
651
+ }
652
+ }
628
653
  }
629
654
  return registry;
630
655
  }
@@ -0,0 +1,154 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { writeAtomicJson } from "../../shared/atomic-json.ts";
4
+ import type {
5
+ ParallelHandoffGroup,
6
+ ParallelHandoffManifest,
7
+ ParallelHandoffReference,
8
+ SubagentResultStatus,
9
+ } from "../../shared/types.ts";
10
+ import type {
11
+ WorktreeCleanupReport,
12
+ WorktreeDiff,
13
+ WorktreeSetup,
14
+ } from "./worktree.ts";
15
+
16
+ export interface ParallelHandoffResult {
17
+ agent: string;
18
+ status: SubagentResultStatus;
19
+ summary: string;
20
+ outputPath?: string;
21
+ structuredOutput?: unknown;
22
+ structuredOutputPath?: string;
23
+ sessionPath?: string;
24
+ }
25
+
26
+ function readManifest(manifestPath: string): ParallelHandoffManifest | undefined {
27
+ if (!fs.existsSync(manifestPath)) return undefined;
28
+ const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as ParallelHandoffManifest;
29
+ if (parsed.version !== 1 || !Array.isArray(parsed.groups)) {
30
+ throw new Error(`Invalid parallel handoff manifest: ${manifestPath}`);
31
+ }
32
+ return parsed;
33
+ }
34
+
35
+ function referenceFor(manifestPath: string, manifest: ParallelHandoffManifest): ParallelHandoffReference {
36
+ const children = manifest.groups.flatMap((group) => group.children);
37
+ return {
38
+ version: 1,
39
+ path: manifestPath,
40
+ groupCount: manifest.groups.length,
41
+ childCount: children.length,
42
+ changedPatches: children.filter((child) => child.patch.changed).length,
43
+ cleanupState: manifest.groups.every((group) => group.cleanup.state === "complete") ? "complete" : "partial",
44
+ };
45
+ }
46
+
47
+ function safeHandoffAgentName(agent: string): string {
48
+ return agent.replace(/[^\w.-]/g, "_");
49
+ }
50
+
51
+ function missingDiff(input: { manifestPath: string; stepIndex: number; taskIndex: number; agent: string; branch?: string }): WorktreeDiff {
52
+ const patchPath = path.join(path.dirname(input.manifestPath), `missing-diff-step-${input.stepIndex}-task-${input.taskIndex}-${safeHandoffAgentName(input.agent)}.patch`);
53
+ try {
54
+ fs.mkdirSync(path.dirname(patchPath), { recursive: true });
55
+ fs.writeFileSync(patchPath, "", "utf-8");
56
+ } catch {
57
+ // Handoff records the artifact failure below; patch creation remains best-effort.
58
+ }
59
+ return {
60
+ index: input.taskIndex,
61
+ agent: input.agent,
62
+ branch: input.branch ?? "",
63
+ diffStat: "",
64
+ filesChanged: 0,
65
+ insertions: 0,
66
+ deletions: 0,
67
+ patchPath,
68
+ error: "diff artifact unavailable; no patch was captured",
69
+ };
70
+ }
71
+
72
+ export function writeParallelHandoffGroup(input: {
73
+ manifestPath: string;
74
+ runId: string;
75
+ mode: "parallel" | "chain";
76
+ source: "foreground" | "async";
77
+ cwd: string;
78
+ stepIndex: number;
79
+ flatStartIndex: number;
80
+ setup: WorktreeSetup;
81
+ diffs: WorktreeDiff[];
82
+ cleanup: WorktreeCleanupReport;
83
+ results: ParallelHandoffResult[];
84
+ now?: number;
85
+ }): ParallelHandoffReference {
86
+ const now = input.now ?? Date.now();
87
+ const existing = readManifest(input.manifestPath);
88
+ if (existing && (existing.runId !== input.runId || existing.mode !== input.mode || existing.source !== input.source)) {
89
+ throw new Error(`Parallel handoff manifest belongs to a different run: ${input.manifestPath}`);
90
+ }
91
+ const group: ParallelHandoffGroup = {
92
+ stepIndex: input.stepIndex,
93
+ baseCommit: input.setup.baseCommit,
94
+ repoRoot: input.setup.cwd,
95
+ children: input.results.map((result, taskIndex) => {
96
+ const diff = input.diffs[taskIndex] ?? missingDiff({
97
+ manifestPath: input.manifestPath,
98
+ stepIndex: input.stepIndex,
99
+ taskIndex,
100
+ agent: result.agent,
101
+ branch: input.setup.worktrees[taskIndex]?.branch,
102
+ });
103
+ return {
104
+ index: input.flatStartIndex + taskIndex,
105
+ taskIndex,
106
+ agent: result.agent,
107
+ status: result.status,
108
+ summary: result.summary,
109
+ ...(result.outputPath ? { outputPath: result.outputPath } : {}),
110
+ ...(result.structuredOutput !== undefined ? { structuredOutput: result.structuredOutput } : {}),
111
+ ...(result.structuredOutputPath ? { structuredOutputPath: result.structuredOutputPath } : {}),
112
+ ...(result.sessionPath ? { sessionPath: result.sessionPath } : {}),
113
+ patch: {
114
+ path: diff.patchPath,
115
+ branch: diff.branch,
116
+ changed: diff.filesChanged > 0 || diff.insertions > 0 || diff.deletions > 0 || diff.diffStat.trim().length > 0,
117
+ diffStat: diff.diffStat,
118
+ filesChanged: diff.filesChanged,
119
+ insertions: diff.insertions,
120
+ deletions: diff.deletions,
121
+ ...(diff.error ? { error: diff.error } : {}),
122
+ },
123
+ };
124
+ }),
125
+ cleanup: input.cleanup,
126
+ };
127
+ const groups = existing?.groups.filter((candidate) => candidate.stepIndex !== input.stepIndex) ?? [];
128
+ groups.push(group);
129
+ groups.sort((left, right) => left.stepIndex - right.stepIndex);
130
+ const manifest: ParallelHandoffManifest = {
131
+ version: 1,
132
+ runId: input.runId,
133
+ mode: input.mode,
134
+ source: input.source,
135
+ cwd: input.cwd,
136
+ createdAt: existing?.createdAt ?? now,
137
+ updatedAt: now,
138
+ groups,
139
+ };
140
+ writeAtomicJson(input.manifestPath, manifest);
141
+ return referenceFor(input.manifestPath, manifest);
142
+ }
143
+
144
+ export function parallelHandoffPath(baseDir: string, runId?: string): string {
145
+ return runId ? path.join(baseDir, "handoffs", `${runId}.json`) : path.join(baseDir, "handoff.json");
146
+ }
147
+
148
+ export function formatParallelHandoffReference(reference: ParallelHandoffReference): string {
149
+ return `Parallel handoff: ${reference.path} (${reference.childCount} children, ${reference.changedPatches} changed patches, cleanup ${reference.cleanupState})`;
150
+ }
151
+
152
+ export function formatParallelHandoffError(error: unknown): string {
153
+ return `Parallel handoff unavailable: ${error instanceof Error ? error.message : String(error)}`;
154
+ }
@@ -3,6 +3,8 @@ export interface RunnerSubagentStep {
3
3
  parentSessionId?: string;
4
4
  agent: string;
5
5
  task: string;
6
+ /** Resolved launch context for this child. */
7
+ context?: "fresh" | "fork";
6
8
  importAsyncRoot?: {
7
9
  runId: string;
8
10
  asyncDir: string;
@@ -40,9 +42,11 @@ export interface RunnerSubagentStep {
40
42
  outputPath: string;
41
43
  };
42
44
  structuredOutputSchema?: import("../../shared/types.ts").JsonSchemaObject;
45
+ agentContract?: import("../../shared/types.ts").AgentContract;
43
46
  effectiveAcceptance?: import("../../shared/types.ts").ResolvedAcceptanceConfig;
44
47
  acceptanceInput?: import("../../shared/types.ts").AcceptanceInput;
45
48
  acceptanceRole?: import("../../shared/types.ts").AcceptanceRole;
49
+ gateOn?: import("../../shared/types.ts").ChainGateLayer;
46
50
  toolBudget?: import("../../shared/types.ts").ResolvedToolBudget;
47
51
  }
48
52
 
@@ -66,6 +70,8 @@ export interface DynamicRunnerGroup {
66
70
  effectiveAcceptance?: import("../../shared/types.ts").ResolvedAcceptanceConfig;
67
71
  acceptanceInput?: import("../../shared/types.ts").AcceptanceInput;
68
72
  acceptanceRole?: import("../../shared/types.ts").AcceptanceRole;
73
+ agentContract?: import("../../shared/types.ts").AgentContract;
74
+ gateOn?: import("../../shared/types.ts").ChainGateLayer;
69
75
  }
70
76
 
71
77
  export type RunnerStep = RunnerSubagentStep | ParallelStepGroup | DynamicRunnerGroup;
@@ -4,13 +4,15 @@ import * as path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { encodeNestedPathEnv, parseNestedPathEnv, type NestedPathEntry } from "./nested-path.ts";
6
6
  import { resolveMcpDirectToolNames } from "./mcp-direct-tool-allowlist.ts";
7
+ import { resolvePiPackageRoot } from "./pi-spawn.ts";
7
8
  import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV } from "./structured-output.ts";
8
9
  import { TEMP_ROOT_DIR, type JsonSchemaObject, type ResolvedToolBudget } from "../../shared/types.ts";
9
10
  import { THINKING_LEVELS } from "../../shared/model-info.ts";
10
- import { TOOL_BUDGET_ENV, encodeToolBudgetEnv } from "./tool-budget.ts";
11
+ import { TOOL_BUDGET_ENV, TOOL_BUDGET_ZERO_AUTH_ENV, encodeToolBudgetEnv } from "./tool-budget.ts";
11
12
  import { CHILD_TOOL_DIAGNOSTIC_PATH_ENV, REQUIRED_CHILD_TOOLS_ENV } from "./tool-availability.ts";
12
13
  import { CHILD_WATCHDOG_CONFIG_ENV, encodeChildWatchdogConfig, type ChildWatchdogConfig } from "../../watchdog/child-status.ts";
13
14
  import { WAIT_TOOL_ENABLED_ENV } from "../background/wait-config.ts";
15
+ import { PI_CODING_AGENT_PACKAGE_ROOT_ENV } from "../../shared/utils.ts";
14
16
 
15
17
  const TASK_ARG_LIMIT = 8000;
16
18
  const PROMPT_RUNTIME_EXTENSION_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "subagent-prompt-runtime.ts");
@@ -78,6 +80,7 @@ interface BuildPiArgsInput {
78
80
  outputPath: string;
79
81
  };
80
82
  toolBudget?: ResolvedToolBudget;
83
+ allowZeroToolBudget?: boolean;
81
84
  childWatchdog?: ChildWatchdogConfig;
82
85
  waitToolEnabled?: boolean;
83
86
  }
@@ -127,27 +130,30 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
127
130
  args.push("--model", modelArg);
128
131
  }
129
132
 
133
+ const explicitToolAllowlist = input.tools !== undefined || (input.mcpDirectTools?.length ?? 0) > 0;
130
134
  const declaredBuiltinToolsBase = input.tools?.filter((tool) => !(tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js"))) ?? [];
131
- const declaredBuiltinTools = input.requireReadTool && input.tools?.length && !declaredBuiltinToolsBase.includes("read")
135
+ const declaredBuiltinTools = input.requireReadTool && declaredBuiltinToolsBase.length > 0 && !declaredBuiltinToolsBase.includes("read")
132
136
  ? ["read", ...declaredBuiltinToolsBase]
133
137
  : declaredBuiltinToolsBase;
134
138
  const fanoutAuthorized = declaredBuiltinTools.includes("subagent");
135
139
  const toolExtensionPaths: string[] = [];
136
140
  let requiredChildTools: string[] = [];
137
- if (input.tools?.length) {
138
- const allowedTools = [...declaredBuiltinTools];
139
- for (const tool of input.tools) {
140
- if (!declaredBuiltinTools.includes(tool) && (tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js"))) {
141
- toolExtensionPaths.push(tool);
142
- }
141
+ for (const tool of input.tools ?? []) {
142
+ if (!declaredBuiltinTools.includes(tool) && (tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js"))) {
143
+ toolExtensionPaths.push(tool);
143
144
  }
144
- if (allowedTools.length > 0) {
145
- if (input.mcpDirectTools?.length) {
146
- allowedTools.push(...resolveMcpDirectToolNames(input.mcpDirectTools, input.cwd));
147
- }
148
- if (input.structuredOutput) allowedTools.push("structured_output");
149
- requiredChildTools = [...new Set(allowedTools)];
145
+ }
146
+ if (explicitToolAllowlist) {
147
+ const allowedTools = [
148
+ ...declaredBuiltinTools,
149
+ ...resolveMcpDirectToolNames(input.mcpDirectTools, input.cwd),
150
+ ...(input.structuredOutput ? ["structured_output"] : []),
151
+ ];
152
+ requiredChildTools = [...new Set(allowedTools)];
153
+ if (requiredChildTools.length > 0) {
150
154
  args.push("--tools", requiredChildTools.join(","));
155
+ } else {
156
+ args.push("--no-tools");
151
157
  }
152
158
  }
153
159
 
@@ -190,6 +196,8 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
190
196
  }
191
197
 
192
198
  const env: Record<string, string | undefined> = {};
199
+ const piPackageRoot = process.env[PI_CODING_AGENT_PACKAGE_ROOT_ENV] ?? resolvePiPackageRoot();
200
+ if (piPackageRoot) env[PI_CODING_AGENT_PACKAGE_ROOT_ENV] = piPackageRoot;
193
201
  let toolDiagnosticPath: string | undefined;
194
202
  if (requiredChildTools.length > 0) {
195
203
  if (!tempDir) tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
@@ -278,6 +286,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
278
286
  if (input.steerAckDir) env[SUBAGENT_STEER_ACK_DIR_ENV] = input.steerAckDir;
279
287
  const encodedToolBudget = encodeToolBudgetEnv(input.toolBudget);
280
288
  if (encodedToolBudget) env[TOOL_BUDGET_ENV] = encodedToolBudget;
289
+ env[TOOL_BUDGET_ZERO_AUTH_ENV] = input.allowZeroToolBudget ? "1" : undefined;
281
290
  const encodedChildWatchdog = encodeChildWatchdogConfig(input.childWatchdog);
282
291
  if (encodedChildWatchdog) env[CHILD_WATCHDOG_CONFIG_ENV] = encodedChildWatchdog;
283
292