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
@@ -1,3 +1,4 @@
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 { getAgentDir } from "../../shared/utils.ts";
@@ -5,6 +6,7 @@ import { getAgentDir } from "../../shared/utils.ts";
5
6
  export interface RunEntry {
6
7
  agent: string;
7
8
  task: string;
9
+ taskHash?: string;
8
10
  ts: number;
9
11
  status: "ok" | "error";
10
12
  duration: number;
@@ -13,24 +15,103 @@ export interface RunEntry {
13
15
 
14
16
  const ROTATE_READ_THRESHOLD = 1200;
15
17
  const ROTATE_KEEP = 1000;
18
+ const PRIVATE_DIR_MODE = 0o700;
19
+ const PRIVATE_FILE_MODE = 0o600;
20
+ const REDACTED_TASK = "[redacted]";
16
21
 
17
22
  function getHistoryPath(): string {
18
23
  return path.join(getAgentDir(), "run-history.jsonl");
19
24
  }
20
25
 
26
+ function hashTask(task: string): string {
27
+ return createHash("sha256").update(task).digest("hex");
28
+ }
29
+
30
+ function hardenHistoryStorage(historyPath: string): void {
31
+ const historyDir = path.dirname(historyPath);
32
+ fs.mkdirSync(historyDir, { recursive: true, mode: PRIVATE_DIR_MODE });
33
+ try { fs.chmodSync(historyDir, PRIVATE_DIR_MODE); } catch {}
34
+ try { if (fs.existsSync(historyPath)) fs.chmodSync(historyPath, PRIVATE_FILE_MODE); } catch {}
35
+ }
36
+
37
+ function sanitizeHistoryLine(line: string): string | undefined {
38
+ let value: unknown;
39
+ try {
40
+ value = JSON.parse(line);
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ if (!value || typeof value !== "object") return undefined;
45
+
46
+ const record = value as Record<string, unknown>;
47
+ const task = typeof record.task === "string" ? record.task : "";
48
+ const taskHash = typeof record.taskHash === "string" && record.taskHash
49
+ ? record.taskHash
50
+ : task && task !== REDACTED_TASK
51
+ ? hashTask(task)
52
+ : undefined;
53
+
54
+ return JSON.stringify({
55
+ ...record,
56
+ task: REDACTED_TASK,
57
+ ...(taskHash ? { taskHash } : {}),
58
+ });
59
+ }
60
+
61
+ function sanitizeHistoryLines(raw: string): { lines: string[]; changed: boolean } {
62
+ const lines: string[] = [];
63
+ let changed = false;
64
+ for (const line of raw.split("\n")) {
65
+ const trimmed = line.trim();
66
+ if (!trimmed) continue;
67
+ const sanitized = sanitizeHistoryLine(trimmed);
68
+ if (!sanitized) {
69
+ changed = true;
70
+ continue;
71
+ }
72
+ if (sanitized !== trimmed) changed = true;
73
+ lines.push(sanitized);
74
+ }
75
+ return { lines, changed };
76
+ }
77
+
78
+ function writePrivateHistory(historyPath: string, lines: string[]): void {
79
+ fs.writeFileSync(historyPath, lines.length ? `${lines.join("\n")}\n` : "", { encoding: "utf-8", mode: PRIVATE_FILE_MODE });
80
+ try { fs.chmodSync(historyPath, PRIVATE_FILE_MODE); } catch {}
81
+ }
82
+
83
+ function sanitizeHistoryFile(historyPath: string): void {
84
+ if (!fs.existsSync(historyPath)) return;
85
+ const raw = fs.readFileSync(historyPath, "utf-8");
86
+ const { lines, changed } = sanitizeHistoryLines(raw);
87
+ if (changed) writePrivateHistory(historyPath, lines);
88
+ }
89
+
90
+ function appendPrivateHistoryLine(historyPath: string, line: string): void {
91
+ const fd = fs.openSync(historyPath, fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_WRONLY, PRIVATE_FILE_MODE);
92
+ try {
93
+ fs.writeSync(fd, `${line}\n`);
94
+ } finally {
95
+ fs.closeSync(fd);
96
+ }
97
+ try { fs.chmodSync(historyPath, PRIVATE_FILE_MODE); } catch {}
98
+ }
99
+
21
100
  export function recordRun(agent: string, task: string, exitCode: number, durationMs: number): void {
22
101
  try {
23
102
  const entry: RunEntry = {
24
103
  agent,
25
- task: task.slice(0, 200),
104
+ task: REDACTED_TASK,
105
+ taskHash: hashTask(task),
26
106
  ts: Math.floor(Date.now() / 1000),
27
107
  status: exitCode === 0 ? "ok" : "error",
28
108
  duration: durationMs,
29
109
  ...(exitCode !== 0 ? { exit: exitCode } : {}),
30
110
  };
31
111
  const historyPath = getHistoryPath();
32
- fs.mkdirSync(path.dirname(historyPath), { recursive: true });
33
- fs.appendFileSync(historyPath, `${JSON.stringify(entry)}\n`);
112
+ hardenHistoryStorage(historyPath);
113
+ try { sanitizeHistoryFile(historyPath); } catch {}
114
+ appendPrivateHistoryLine(historyPath, JSON.stringify(entry));
34
115
  } catch {
35
116
  // Best-effort — never crash the execution flow for history recording
36
117
  }
@@ -38,6 +119,7 @@ export function recordRun(agent: string, task: string, exitCode: number, duratio
38
119
 
39
120
  export function loadRunsForAgent(agent: string): RunEntry[] {
40
121
  const historyPath = getHistoryPath();
122
+ try { hardenHistoryStorage(historyPath); } catch {}
41
123
  if (!fs.existsSync(historyPath)) return [];
42
124
  let raw: string;
43
125
  try {
@@ -46,11 +128,14 @@ export function loadRunsForAgent(agent: string): RunEntry[] {
46
128
  return [];
47
129
  }
48
130
 
49
- let lines = raw.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
131
+ let { lines, changed } = sanitizeHistoryLines(raw);
50
132
 
51
133
  if (lines.length > ROTATE_READ_THRESHOLD) {
52
134
  lines = lines.slice(-ROTATE_KEEP);
53
- try { fs.writeFileSync(historyPath, `${lines.join("\n")}\n`, "utf-8"); } catch {}
135
+ changed = true;
136
+ }
137
+ if (changed) {
138
+ try { writePrivateHistory(historyPath, lines); } catch {}
54
139
  }
55
140
 
56
141
  return lines
@@ -1,11 +1,14 @@
1
1
  import * as fs from "node:fs";
2
+ import { createRequire } from "node:module";
2
3
  import * as os from "node:os";
3
4
  import * as path from "node:path";
4
- import { Compile } from "typebox/compile";
5
+ import { pathToFileURL } from "node:url";
6
+ import { PI_CODING_AGENT_PACKAGE_ROOT_ENV } from "../../shared/utils.ts";
5
7
  import type { JsonSchemaObject } from "../../shared/types.ts";
6
8
 
7
9
  export const STRUCTURED_OUTPUT_SCHEMA_ENV = "PI_SUBAGENT_STRUCTURED_OUTPUT_SCHEMA";
8
10
  export const STRUCTURED_OUTPUT_CAPTURE_ENV = "PI_SUBAGENT_STRUCTURED_OUTPUT_CAPTURE";
11
+ export const MISSING_STRUCTURED_OUTPUT_CALL_ERROR = "Missing structured_output call; this step has outputSchema and must finish by calling structured_output.";
9
12
 
10
13
  export interface StructuredOutputRuntime {
11
14
  schema: JsonSchemaObject;
@@ -13,11 +16,108 @@ export interface StructuredOutputRuntime {
13
16
  outputPath: string;
14
17
  }
15
18
 
19
+ const SCHEMA_MAP_KEYWORDS = ["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"] as const;
20
+ const SCHEMA_SINGLE_KEYWORDS = ["additionalItems", "additionalProperties", "contains", "not", "propertyNames", "if", "then", "else", "unevaluatedItems", "unevaluatedProperties", "contentSchema"] as const;
21
+ const SCHEMA_ARRAY_KEYWORDS = ["allOf", "anyOf", "oneOf", "prefixItems"] as const;
22
+
23
+ function rewriteLocalJsonPointerRefs(schema: unknown, pointerPrefix: string, inheritsWrapperResource = true): unknown {
24
+ if (typeof schema === "boolean" || !schema || typeof schema !== "object" || Array.isArray(schema)) return schema;
25
+ const source = schema as Record<string, unknown>;
26
+ const rewritten: Record<string, unknown> = { ...source };
27
+ const sharesWrapperResource = inheritsWrapperResource && typeof source.$id !== "string";
28
+ if (sharesWrapperResource) {
29
+ for (const keyword of ["$ref", "$dynamicRef", "$recursiveRef"] as const) {
30
+ const ref = source[keyword];
31
+ if (ref === "#") rewritten[keyword] = pointerPrefix;
32
+ else if (typeof ref === "string" && ref.startsWith("#/")) rewritten[keyword] = `${pointerPrefix}${ref.slice(1)}`;
33
+ }
34
+ }
35
+ for (const keyword of SCHEMA_MAP_KEYWORDS) {
36
+ const entries = source[keyword];
37
+ if (!entries || typeof entries !== "object" || Array.isArray(entries)) continue;
38
+ rewritten[keyword] = Object.fromEntries(Object.entries(entries).map(([name, nested]) => [
39
+ name,
40
+ rewriteLocalJsonPointerRefs(nested, pointerPrefix, sharesWrapperResource),
41
+ ]));
42
+ }
43
+ const items = source.items;
44
+ if (Array.isArray(items)) rewritten.items = items.map((nested) => rewriteLocalJsonPointerRefs(nested, pointerPrefix, sharesWrapperResource));
45
+ else if (items !== undefined) rewritten.items = rewriteLocalJsonPointerRefs(items, pointerPrefix, sharesWrapperResource);
46
+ for (const keyword of SCHEMA_SINGLE_KEYWORDS) {
47
+ if (source[keyword] !== undefined) rewritten[keyword] = rewriteLocalJsonPointerRefs(source[keyword], pointerPrefix, sharesWrapperResource);
48
+ }
49
+ for (const keyword of SCHEMA_ARRAY_KEYWORDS) {
50
+ if (Array.isArray(source[keyword])) rewritten[keyword] = source[keyword].map((nested) => rewriteLocalJsonPointerRefs(nested, pointerPrefix, sharesWrapperResource));
51
+ }
52
+ const dependencies = source.dependencies;
53
+ if (dependencies && typeof dependencies === "object" && !Array.isArray(dependencies)) {
54
+ rewritten.dependencies = Object.fromEntries(Object.entries(dependencies).map(([name, nested]) => [
55
+ name,
56
+ Array.isArray(nested) ? nested : rewriteLocalJsonPointerRefs(nested, pointerPrefix, sharesWrapperResource),
57
+ ]));
58
+ }
59
+ return rewritten;
60
+ }
61
+
62
+ export function createStructuredOutputToolParameters(schema: JsonSchemaObject): JsonSchemaObject {
63
+ return {
64
+ type: "object",
65
+ properties: { value: rewriteLocalJsonPointerRefs(schema, "#/properties/value") },
66
+ required: ["value"],
67
+ additionalProperties: false,
68
+ };
69
+ }
70
+
16
71
  interface CompiledJsonSchema {
17
72
  Check(value: unknown): boolean;
18
73
  Errors(value: unknown): Iterable<{ instancePath?: string; message?: string }>;
19
74
  }
20
75
 
76
+ type CompileJsonSchema = (schema: unknown) => CompiledJsonSchema;
77
+
78
+ let cachedCompile: Promise<CompileJsonSchema> | undefined;
79
+
80
+ export async function resolveCompileFromPackageRoot(packageRoot: string): Promise<CompileJsonSchema | undefined> {
81
+ const requireFromRoot = createRequire(path.join(packageRoot, "package.json"));
82
+ const resolved = requireFromRoot.resolve("typebox/compile");
83
+ const mod = (await import(pathToFileURL(resolved).href)) as { Compile?: unknown };
84
+ return typeof mod.Compile === "function" ? (mod.Compile as CompileJsonSchema) : undefined;
85
+ }
86
+
87
+ async function importCompile(): Promise<CompileJsonSchema> {
88
+ const failures: string[] = [];
89
+ try {
90
+ const mod = (await import("typebox/compile")) as { Compile?: unknown };
91
+ if (typeof mod.Compile === "function") return mod.Compile as CompileJsonSchema;
92
+ failures.push("typebox/compile did not export a Compile function");
93
+ } catch (error) {
94
+ failures.push(`direct import failed: ${error instanceof Error ? error.message : String(error)}`);
95
+ }
96
+ const packageRoot = process.env[PI_CODING_AGENT_PACKAGE_ROOT_ENV];
97
+ if (packageRoot) {
98
+ try {
99
+ const compile = await resolveCompileFromPackageRoot(packageRoot);
100
+ if (compile) return compile;
101
+ failures.push("Pi package root typebox/compile did not export a Compile function");
102
+ } catch (error) {
103
+ failures.push(`Pi package root import failed: ${error instanceof Error ? error.message : String(error)}`);
104
+ }
105
+ } else {
106
+ failures.push(`${PI_CODING_AGENT_PACKAGE_ROOT_ENV} is not set`);
107
+ }
108
+ throw new Error(`Cannot load typebox/compile for structured output validation (${failures.join("; ")})`);
109
+ }
110
+
111
+ function loadCompile(): Promise<CompileJsonSchema> {
112
+ if (!cachedCompile) {
113
+ cachedCompile = importCompile().catch((error) => {
114
+ cachedCompile = undefined;
115
+ throw error;
116
+ });
117
+ }
118
+ return cachedCompile;
119
+ }
120
+
21
121
  export function assertJsonSchemaObject(schema: unknown, label = "outputSchema"): asserts schema is JsonSchemaObject {
22
122
  if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
23
123
  throw new Error(`${label} must be a JSON Schema object.`);
@@ -35,10 +135,11 @@ export function createStructuredOutputRuntime(schema: JsonSchemaObject, baseDir?
35
135
  return { schema, schemaPath, outputPath };
36
136
  }
37
137
 
38
- export function validateStructuredOutputValue(schema: JsonSchemaObject, value: unknown): { status: "valid" } | { status: "invalid"; message: string } {
138
+ export async function validateStructuredOutputValue(schema: JsonSchemaObject, value: unknown): Promise<{ status: "valid" } | { status: "invalid"; message: string }> {
139
+ const compile = await loadCompile();
39
140
  let validator: CompiledJsonSchema;
40
141
  try {
41
- validator = (Compile as (schema: unknown) => CompiledJsonSchema)(schema);
142
+ validator = compile(schema);
42
143
  } catch (error) {
43
144
  return { status: "invalid", message: `invalid outputSchema: ${error instanceof Error ? error.message : String(error)}` };
44
145
  }
@@ -52,9 +153,9 @@ export function validateStructuredOutputValue(schema: JsonSchemaObject, value: u
52
153
  return { status: "invalid", message: errors.join("; ") || "schema validation failed" };
53
154
  }
54
155
 
55
- export function readStructuredOutput(runtime: StructuredOutputRuntime): { value?: unknown; error?: string } {
156
+ export async function readStructuredOutput(runtime: StructuredOutputRuntime): Promise<{ value?: unknown; error?: string }> {
56
157
  if (!fs.existsSync(runtime.outputPath)) {
57
- return { error: "Missing structured_output call; this step has outputSchema and must finish by calling structured_output." };
158
+ return { error: MISSING_STRUCTURED_OUTPUT_CALL_ERROR };
58
159
  }
59
160
  let value: unknown;
60
161
  try {
@@ -62,8 +163,12 @@ export function readStructuredOutput(runtime: StructuredOutputRuntime): { value?
62
163
  } catch (error) {
63
164
  return { error: `Failed to read structured output: ${error instanceof Error ? error.message : String(error)}` };
64
165
  }
65
- const validation = validateStructuredOutputValue(runtime.schema, value);
66
- if (validation.status === "invalid") return { error: `Structured output validation failed: ${validation.message}` };
166
+ try {
167
+ const validation = await validateStructuredOutputValue(runtime.schema, value);
168
+ if (validation.status === "invalid") return { error: `Structured output validation failed: ${validation.message}` };
169
+ } catch (error) {
170
+ return { error: `Failed to validate structured output: ${error instanceof Error ? error.message : String(error)}` };
171
+ }
67
172
  return { value };
68
173
  }
69
174
 
@@ -193,11 +193,15 @@ export function formatControlNoticeMessage(event: ControlEvent, childIntercomTar
193
193
  ].filter((line): line is string => Boolean(line)).join("\n");
194
194
  }
195
195
 
196
+ const supervisorHint = event.reason === "supervisor_request"
197
+ ? "Supervisor request: reply to the pending request. If subagent_supervisor pending is empty, check intercom pending because an external intercom tool may own the request."
198
+ : undefined;
196
199
  return [
197
200
  `Subagent needs attention: ${event.agent}`,
198
201
  `Run: ${runTarget}${event.index !== undefined ? ` step ${event.index + 1}` : ""}`,
199
202
  `Signal: ${event.message}`,
200
203
  event.recentFailureSummary ? `Recent failures: ${event.recentFailureSummary}` : undefined,
204
+ supervisorHint,
201
205
  "Hint: Inspect status first unless the run is clearly blocked. Use steer for a top-level live async child, routed resume for a live nested child, or resume to revive a paused/completed/failed child.",
202
206
  `Top-level live async nudge: ${steerCommand}`,
203
207
  `Routed live nested nudge: ${nestedResumeCommand}`,
@@ -4,15 +4,14 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { registerNativeSupervisorClient } from "../../intercom/native-supervisor-channel.ts";
5
5
  import { consumeSteerRequestsFromDir, steerAckPathFromDir, writeSteerAckAt, writeSteerCapabilityAt, writeSteerRequestToDir, type SteerRequest } from "../background/control-channel.ts";
6
6
  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";
7
- import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV, validateStructuredOutputValue } from "./structured-output.ts";
7
+ import { createStructuredOutputToolParameters, STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV, validateStructuredOutputValue } from "./structured-output.ts";
8
8
  import {
9
9
  CHILD_TOOL_DIAGNOSTIC_PATH_ENV,
10
- formatChildToolDiagnostic,
11
10
  REQUIRED_CHILD_TOOLS_ENV,
12
11
  writeChildToolDiagnostic,
13
12
  type ChildToolDiagnostic,
14
13
  } from "./tool-availability.ts";
15
- import { TOOL_BUDGET_ENV, decodeToolBudgetEnv, shouldBlockToolForBudget, toolBudgetBlockedMessage, toolBudgetSoftNudge } from "./tool-budget.ts";
14
+ import { TOOL_BUDGET_ENV, TOOL_BUDGET_ZERO_AUTH_ENV, decodeToolBudgetEnv, shouldBlockToolForBudget, toolBudgetBlockedMessage, toolBudgetSoftNudge } from "./tool-budget.ts";
16
15
  import type { JsonSchemaObject, ResolvedToolBudget, SubagentState } from "../../shared/types.ts";
17
16
  import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
18
17
  import { resolveWatchPath } from "../../shared/utils.ts";
@@ -69,14 +68,20 @@ function readBooleanEnv(name: string): boolean | undefined {
69
68
  return value !== "0";
70
69
  }
71
70
 
72
- function refreshChildToolDiagnostic(pi: ExtensionAPI): ChildToolDiagnostic | undefined {
73
- const filePath = process.env[CHILD_TOOL_DIAGNOSTIC_PATH_ENV]?.trim();
71
+ function readRequiredChildTools(): string[] | undefined {
74
72
  const encoded = process.env[REQUIRED_CHILD_TOOLS_ENV]?.trim();
75
- if (!filePath || !encoded) return undefined;
73
+ if (!encoded) return undefined;
76
74
  const required = JSON.parse(encoded) as unknown;
77
75
  if (!Array.isArray(required) || required.some((name) => typeof name !== "string" || !name)) {
78
76
  throw new Error(`Invalid ${REQUIRED_CHILD_TOOLS_ENV} payload.`);
79
77
  }
78
+ return required;
79
+ }
80
+
81
+ function refreshChildToolDiagnostic(pi: ExtensionAPI): ChildToolDiagnostic | undefined {
82
+ const filePath = process.env[CHILD_TOOL_DIAGNOSTIC_PATH_ENV]?.trim();
83
+ const required = readRequiredChildTools();
84
+ if (!filePath || !required) return undefined;
80
85
  const available = pi.getAllTools().map((tool) => tool.name);
81
86
  return writeChildToolDiagnostic(filePath, required, available, process.env[SUBAGENT_CHILD_AGENT_ENV]?.trim());
82
87
  }
@@ -329,7 +334,7 @@ export function registerSteeringInbox(
329
334
 
330
335
  export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
331
336
  registerSteeringInbox(pi);
332
- registerToolBudget(pi, decodeToolBudgetEnv(process.env[TOOL_BUDGET_ENV]));
337
+ registerToolBudget(pi, decodeToolBudgetEnv(process.env[TOOL_BUDGET_ENV], { allowZero: process.env[TOOL_BUDGET_ZERO_AUTH_ENV] === "1" }));
333
338
  registerChildWatchdog(pi);
334
339
  const waitToolEnabled = resolveWaitToolConfig().enabled;
335
340
  const waitState = {
@@ -365,6 +370,9 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
365
370
  const sessionManager = (ctx as { sessionManager?: Parameters<typeof resolveCurrentSessionId>[0] } | undefined)?.sessionManager;
366
371
  waitState.currentSessionId = sessionManager ? resolveCurrentSessionId(sessionManager) : null;
367
372
  registerNativeSupervisorClientOnce();
373
+ if (readRequiredChildTools()?.includes("intercom")) registerNativeSupervisorFallbackOnce();
374
+ });
375
+ onRuntimeEvent("agent_start", () => {
368
376
  refreshChildToolDiagnostic(pi);
369
377
  });
370
378
  onRuntimeEvent("agent_end", async (_event: unknown, ctx: unknown) => {
@@ -375,12 +383,7 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
375
383
  const structuredSchemaPath = process.env[STRUCTURED_OUTPUT_SCHEMA_ENV];
376
384
  if (structuredOutputPath && structuredSchemaPath) {
377
385
  const schema = JSON.parse(fs.readFileSync(structuredSchemaPath, "utf-8")) as JsonSchemaObject;
378
- const parameters = {
379
- type: "object",
380
- properties: { value: schema },
381
- required: ["value"],
382
- additionalProperties: false,
383
- };
386
+ const parameters = createStructuredOutputToolParameters(schema);
384
387
  const registerTool = pi.registerTool as unknown as (tool: {
385
388
  name: string;
386
389
  label: string;
@@ -394,7 +397,7 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
394
397
  description: "Submit the required final structured output for this subagent step. This terminates the step.",
395
398
  parameters: parameters as never,
396
399
  async execute(_id: string, params: { value: unknown }) {
397
- const validation = validateStructuredOutputValue(schema, params.value);
400
+ const validation = await validateStructuredOutputValue(schema, params.value);
398
401
  if (validation.status === "invalid") {
399
402
  throw new Error(`Structured output validation failed: ${validation.message}`);
400
403
  }
@@ -417,7 +420,6 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
417
420
 
418
421
  onRuntimeEvent("before_agent_start", async (event: { systemPrompt: string }) => {
419
422
  registerNativeSupervisorFallbackOnce();
420
- const toolDiagnostic = refreshChildToolDiagnostic(pi);
421
423
  const intercomSessionName = process.env[SUBAGENT_INTERCOM_SESSION_NAME_ENV]?.trim();
422
424
  if (intercomSessionName && typeof pi.setSessionName === "function") {
423
425
  pi.setSessionName(intercomSessionName);
@@ -434,9 +436,6 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
434
436
  fanoutChild: fanoutChild === true,
435
437
  });
436
438
  }
437
- if (toolDiagnostic) {
438
- rewritten = `${formatChildToolDiagnostic(toolDiagnostic)}\nDo not claim tool-dependent work succeeded; report this configuration error to the parent.\n\n${rewritten}`;
439
- }
440
439
  if (rewritten === event.systemPrompt) return;
441
440
  return { systemPrompt: rewritten };
442
441
  });
@@ -25,10 +25,11 @@ const REVIEW_ONLY_PATTERNS = [
25
25
  ];
26
26
 
27
27
  const REVIEWER_REQUIRED_EDIT_PATTERNS = [
28
- /\bmust\s+(?:edit|modify|change|fix|patch|apply)\b/i,
29
- /\brequired\s+to\s+(?:edit|modify|change|fix|patch|apply)\b/i,
28
+ /\bmust\s+(?:edit|modify|change|fix|patch|apply|implement)\b/i,
29
+ /\brequired\s+to\s+(?:edit|modify|change|fix|patch|apply|implement)\b/i,
30
+ /(?:^|[.!?\n]\s*)implement\s+(?:the\s+)?(?:approved|requested|specified|file|code|source|fix(?:es)?|changes?)\b/i,
30
31
  /\bregardless\s+of\s+findings\b/i,
31
- /\balways\s+(?:edit|modify|change|fix|patch|apply)\b/i,
32
+ /\balways\s+(?:edit|modify|change|fix|patch|apply|implement)\b/i,
32
33
  /\bapply\s+(?:the\s+)?fix(?:es)?\s+directly\b/i,
33
34
  /\bmake\s+(?:the\s+)?code\s+changes\b/i,
34
35
  ];
@@ -134,8 +135,12 @@ function taskHasReadOnlyDeliverable(taskText: string): boolean {
134
135
  return READ_ONLY_DELIVERABLE_PATTERNS.some((pattern) => pattern.test(taskText));
135
136
  }
136
137
 
138
+ function isReviewerStyleAgent(agent: string): boolean {
139
+ return /\b(?:advisor|reviewer|oracle)\b/i.test(agent);
140
+ }
141
+
137
142
  function hasImplementationIntent(agent: string, taskText: string): boolean {
138
- if (/\breviewer\b/i.test(agent)) return REVIEWER_REQUIRED_EDIT_PATTERNS.some((pattern) => pattern.test(taskText));
143
+ if (isReviewerStyleAgent(agent)) return REVIEWER_REQUIRED_EDIT_PATTERNS.some((pattern) => pattern.test(taskText));
139
144
  if (agent === "worker") return WORKER_IMPLEMENTATION_PATTERNS.some((pattern) => pattern.test(taskText));
140
145
  return GENERAL_IMPLEMENTATION_PATTERNS.some((pattern) => pattern.test(taskText));
141
146
  }
@@ -151,7 +156,7 @@ export function classifyTaskMutationIntent(agent: string, task: string): TaskMut
151
156
 
152
157
  if (RESEARCH_AGENT_PATTERNS.some((pattern) => pattern.test(agent))) return { kind: "read-only" };
153
158
  if (hasImplementationIntent(agent, taskText)) return { kind: "implementation" };
154
- if (/\breviewer\b/i.test(agent)) return { kind: "read-only" };
159
+ if (isReviewerStyleAgent(agent)) return { kind: "read-only" };
155
160
  return taskHasReadOnlyDeliverable(taskTextWithoutScopedConstraints) ? { kind: "read-only" } : { kind: "unknown" };
156
161
  }
157
162
 
@@ -11,13 +11,15 @@ export interface ChildToolDiagnostic {
11
11
  missing: string[];
12
12
  }
13
13
 
14
+ const PI_CORE_CHILD_TOOLS = new Set(["bash", "edit", "find", "grep", "ls", "read", "write"]);
15
+
14
16
  export function writeChildToolDiagnostic(
15
17
  filePath: string,
16
18
  required: string[],
17
19
  available: string[],
18
20
  agent?: string,
19
21
  ): ChildToolDiagnostic | undefined {
20
- const availableNames = new Set(available);
22
+ const availableNames = new Set([...available, ...PI_CORE_CHILD_TOOLS]);
21
23
  const missing = required.filter((name) => !availableNames.has(name));
22
24
  if (missing.length === 0) {
23
25
  fs.rmSync(filePath, { force: true });
@@ -2,6 +2,7 @@ import type { ResolvedToolBudget, ToolBudgetConfig, ToolBudgetState } from "../.
2
2
 
3
3
  export const DEFAULT_TOOL_BUDGET_BLOCK = ["read", "grep", "find", "ls"] as const;
4
4
  export const TOOL_BUDGET_ENV = "PI_SUBAGENT_TOOL_BUDGET";
5
+ export const TOOL_BUDGET_ZERO_AUTH_ENV = "PI_SUBAGENT_TOOL_BUDGET_ZERO_AUTH";
5
6
 
6
7
  export function normalizeToolBudgetBlock(block: ToolBudgetConfig["block"] | undefined): "*" | string[] {
7
8
  if (block === "*") return "*";
@@ -9,12 +10,17 @@ export function normalizeToolBudgetBlock(block: ToolBudgetConfig["block"] | unde
9
10
  return [...new Set(block.map((tool) => tool.trim()).filter(Boolean))];
10
11
  }
11
12
 
12
- export function validateToolBudgetConfig(raw: unknown, label = "toolBudget"): { budget?: ResolvedToolBudget; error?: string } {
13
+ export function validateToolBudgetConfig(
14
+ raw: unknown,
15
+ label = "toolBudget",
16
+ options: { minimumHard?: 0 | 1 } = {},
17
+ ): { budget?: ResolvedToolBudget; error?: string } {
13
18
  if (raw === undefined) return {};
14
19
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { error: `${label} must be an object with hard and optional soft/block.` };
15
20
  const value = raw as ToolBudgetConfig;
16
- if (typeof value.hard !== "number" || !Number.isInteger(value.hard) || value.hard < 1) {
17
- return { error: `${label}.hard must be an integer >= 1.` };
21
+ const minimumHard = options.minimumHard ?? 1;
22
+ if (typeof value.hard !== "number" || !Number.isInteger(value.hard) || value.hard < minimumHard) {
23
+ return { error: `${label}.hard must be an integer >= ${minimumHard}.` };
18
24
  }
19
25
  if (value.soft !== undefined && (typeof value.soft !== "number" || !Number.isInteger(value.soft) || value.soft < 1)) {
20
26
  return { error: `${label}.soft must be an integer >= 1 when provided.` };
@@ -65,10 +71,10 @@ export function encodeToolBudgetEnv(budget: ResolvedToolBudget | undefined): str
65
71
  return budget ? JSON.stringify(budget) : undefined;
66
72
  }
67
73
 
68
- export function decodeToolBudgetEnv(value: string | undefined): ResolvedToolBudget | undefined {
74
+ export function decodeToolBudgetEnv(value: string | undefined, options: { allowZero?: boolean } = {}): ResolvedToolBudget | undefined {
69
75
  if (!value?.trim()) return undefined;
70
76
  const parsed = JSON.parse(value) as unknown;
71
- const normalized = validateToolBudgetConfig(parsed, TOOL_BUDGET_ENV);
77
+ const normalized = validateToolBudgetConfig(parsed, TOOL_BUDGET_ENV, options.allowZero ? { minimumHard: 0 } : undefined);
72
78
  if (normalized.error) throw new Error(normalized.error);
73
79
  return normalized.budget;
74
80
  }
@@ -88,10 +88,11 @@ export function turnBudgetDecision(
88
88
  turnCount: number,
89
89
  terminalAssistantStop: boolean,
90
90
  toolWorkActiveOrStarting: boolean,
91
+ enforceHardLimit = false,
91
92
  ): "continue" | "defer" | "abort" {
92
93
  const hardLimit = budget.maxTurns + budget.graceTurns;
93
94
  if (turnCount < hardLimit) return "continue";
94
- if (toolWorkActiveOrStarting) return "defer";
95
+ if (toolWorkActiveOrStarting && !enforceHardLimit) return "defer";
95
96
  if (turnCount === hardLimit && terminalAssistantStop) return "continue";
96
97
  return "abort";
97
98
  }
@@ -18,7 +18,7 @@ interface WorktreeInfo {
18
18
  syntheticPaths: string[];
19
19
  }
20
20
 
21
- interface WorktreeDiff {
21
+ export interface WorktreeDiff {
22
22
  index: number;
23
23
  agent: string;
24
24
  branch: string;
@@ -27,6 +27,23 @@ interface WorktreeDiff {
27
27
  insertions: number;
28
28
  deletions: number;
29
29
  patchPath: string;
30
+ error?: string;
31
+ }
32
+
33
+ export interface WorktreeCleanupTask {
34
+ index: number;
35
+ path: string;
36
+ branch: string;
37
+ worktreeRemoved: boolean;
38
+ branchRemoved: boolean;
39
+ errors?: string[];
40
+ }
41
+
42
+ export interface WorktreeCleanupReport {
43
+ state: "complete" | "partial";
44
+ tasks: WorktreeCleanupTask[];
45
+ pruned: boolean;
46
+ errors?: string[];
30
47
  }
31
48
 
32
49
  interface WorktreeTaskCwdConflict {
@@ -426,7 +443,7 @@ function removeSyntheticPathsBeforeDiff(worktree: WorktreeInfo): void {
426
443
  }
427
444
  }
428
445
 
429
- function emptyDiff(index: number, agent: string, branch: string, patchPath: string): WorktreeDiff {
446
+ function emptyDiff(index: number, agent: string, branch: string, patchPath: string, error?: string): WorktreeDiff {
430
447
  return {
431
448
  index,
432
449
  agent,
@@ -436,6 +453,7 @@ function emptyDiff(index: number, agent: string, branch: string, patchPath: stri
436
453
  insertions: 0,
437
454
  deletions: 0,
438
455
  patchPath,
456
+ ...(error ? { error } : {}),
439
457
  };
440
458
  }
441
459
 
@@ -497,13 +515,30 @@ function writeEmptyPatch(patchPath: string): void {
497
515
  }
498
516
  }
499
517
 
500
- function cleanupSingleWorktree(repoCwd: string, worktree: WorktreeInfo): void {
501
- try { runGitChecked(repoCwd, ["worktree", "remove", "--force", worktree.path]); } catch {
502
- // Cleanup is best-effort to avoid masking caller errors.
518
+ function cleanupSingleWorktree(repoCwd: string, worktree: WorktreeInfo): WorktreeCleanupTask {
519
+ const errors: string[] = [];
520
+ let worktreeRemoved = false;
521
+ let branchRemoved = false;
522
+ try {
523
+ runGitChecked(repoCwd, ["worktree", "remove", "--force", worktree.path]);
524
+ worktreeRemoved = true;
525
+ } catch (error) {
526
+ errors.push(`worktree removal failed: ${error instanceof Error ? error.message : String(error)}`);
503
527
  }
504
- try { runGitChecked(repoCwd, ["branch", "-D", worktree.branch]); } catch {
505
- // Cleanup is best-effort to avoid masking caller errors.
528
+ try {
529
+ runGitChecked(repoCwd, ["branch", "-D", worktree.branch]);
530
+ branchRemoved = true;
531
+ } catch (error) {
532
+ errors.push(`branch removal failed: ${error instanceof Error ? error.message : String(error)}`);
506
533
  }
534
+ return {
535
+ index: worktree.index,
536
+ path: worktree.path,
537
+ branch: worktree.branch,
538
+ worktreeRemoved,
539
+ branchRemoved,
540
+ ...(errors.length ? { errors } : {}),
541
+ };
507
542
  }
508
543
 
509
544
  function hasWorktreeChanges(diff: WorktreeDiff): boolean {
@@ -560,23 +595,37 @@ export function diffWorktrees(setup: WorktreeSetup, agents: string[], diffsDir:
560
595
  const patchPath = path.join(diffsDir, `task-${index}-${safePatchAgentName(agent)}.patch`);
561
596
  try {
562
597
  diffs.push(captureWorktreeDiff(setup, worktree, agent, patchPath));
563
- } catch {
564
- // Preserve execution flow; failed diff capture maps to an empty per-task patch.
598
+ } catch (error) {
599
+ // Preserve execution flow while retaining the failed capture as handoff evidence.
565
600
  writeEmptyPatch(patchPath);
566
- diffs.push(emptyDiff(index, agent, worktree.branch, patchPath));
601
+ diffs.push(emptyDiff(index, agent, worktree.branch, patchPath, error instanceof Error ? error.message : String(error)));
567
602
  }
568
603
  }
569
604
 
570
605
  return diffs;
571
606
  }
572
607
 
573
- export function cleanupWorktrees(setup: WorktreeSetup): void {
608
+ export function cleanupWorktrees(setup: WorktreeSetup): WorktreeCleanupReport {
609
+ const tasks: WorktreeCleanupTask[] = [];
574
610
  for (let index = setup.worktrees.length - 1; index >= 0; index--) {
575
- cleanupSingleWorktree(setup.cwd, setup.worktrees[index]!);
611
+ tasks.push(cleanupSingleWorktree(setup.cwd, setup.worktrees[index]!));
576
612
  }
577
- try { runGitChecked(setup.cwd, ["worktree", "prune"]); } catch {
578
- // Pruning is best-effort cleanup.
613
+ tasks.sort((left, right) => left.index - right.index);
614
+ const errors: string[] = [];
615
+ let pruned = false;
616
+ try {
617
+ runGitChecked(setup.cwd, ["worktree", "prune"]);
618
+ pruned = true;
619
+ } catch (error) {
620
+ errors.push(`worktree prune failed: ${error instanceof Error ? error.message : String(error)}`);
579
621
  }
622
+ const state = tasks.every((task) => task.worktreeRemoved && task.branchRemoved) && pruned ? "complete" : "partial";
623
+ return {
624
+ state,
625
+ tasks,
626
+ pruned,
627
+ ...(errors.length ? { errors } : {}),
628
+ };
580
629
  }
581
630
 
582
631
  export function formatWorktreeDiffSummary(diffs: WorktreeDiff[]): string {