pi-subagents 0.34.0 → 0.35.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 (98) hide show
  1. package/CHANGELOG.md +78 -9
  2. package/README.md +213 -32
  3. package/index.ts +1 -0
  4. package/install.mjs +1 -1
  5. package/package.json +23 -8
  6. package/prompts/review-loop.md +3 -1
  7. package/skills/pi-subagents/SKILL.md +87 -25
  8. package/src/agents/agent-management.ts +82 -15
  9. package/src/agents/agent-serializer.ts +19 -0
  10. package/src/agents/agents.ts +91 -49
  11. package/src/agents/frontmatter.ts +67 -13
  12. package/src/agents/skills.ts +25 -12
  13. package/src/api/background-work.ts +197 -0
  14. package/src/api/delegation.ts +158 -0
  15. package/src/extension/chain-validation.ts +165 -0
  16. package/src/extension/doctor.ts +15 -0
  17. package/src/extension/fanout-child.ts +3 -1
  18. package/src/extension/index.ts +65 -124
  19. package/src/extension/rpc.ts +10 -2
  20. package/src/extension/schemas.ts +18 -14
  21. package/src/extension/steering-notices.ts +35 -0
  22. package/src/extension/tool-description.ts +20 -9
  23. package/src/intercom/intercom-bridge.ts +3 -2
  24. package/src/intercom/native-supervisor-channel.ts +9 -1
  25. package/src/intercom/result-intercom.ts +4 -0
  26. package/src/runs/background/async-execution.ts +293 -44
  27. package/src/runs/background/async-job-tracker.ts +56 -9
  28. package/src/runs/background/async-resume.ts +159 -52
  29. package/src/runs/background/async-status.ts +25 -18
  30. package/src/runs/background/auto-drain.ts +67 -0
  31. package/src/runs/background/chain-root-attachment.ts +16 -8
  32. package/src/runs/background/control-channel.ts +260 -13
  33. package/src/runs/background/fleet-view.ts +23 -2
  34. package/src/runs/background/notify.ts +79 -10
  35. package/src/runs/background/result-watcher.ts +12 -9
  36. package/src/runs/background/run-id-resolver.ts +14 -2
  37. package/src/runs/background/run-status.ts +23 -15
  38. package/src/runs/background/scheduled-runs.ts +3 -0
  39. package/src/runs/background/stale-run-reconciler.ts +32 -10
  40. package/src/runs/background/steering.ts +237 -0
  41. package/src/runs/background/subagent-runner.ts +898 -236
  42. package/src/runs/background/subagent-wait.ts +484 -0
  43. package/src/runs/background/top-level-async.ts +2 -1
  44. package/src/runs/background/wait-config.ts +36 -0
  45. package/src/runs/background/wait-tool.ts +26 -0
  46. package/src/runs/foreground/async-steering-action.ts +230 -0
  47. package/src/runs/foreground/chain-clarify.ts +22 -6
  48. package/src/runs/foreground/chain-execution.ts +50 -32
  49. package/src/runs/foreground/execution.ts +308 -94
  50. package/src/runs/foreground/subagent-executor.ts +592 -268
  51. package/src/runs/shared/acceptance.ts +355 -97
  52. package/src/runs/shared/child-protocol.ts +121 -0
  53. package/src/runs/shared/completion-guard.ts +8 -127
  54. package/src/runs/shared/dynamic-fanout.ts +6 -4
  55. package/src/runs/shared/model-fallback.ts +36 -0
  56. package/src/runs/shared/nested-events.ts +9 -4
  57. package/src/runs/shared/nested-render.ts +4 -1
  58. package/src/runs/shared/parallel-utils.ts +7 -0
  59. package/src/runs/shared/pi-args.ts +34 -7
  60. package/src/runs/shared/pi-spawn.ts +18 -12
  61. package/src/runs/shared/session-lease.ts +279 -0
  62. package/src/runs/shared/single-output.ts +61 -6
  63. package/src/runs/shared/spawn-budget.ts +128 -0
  64. package/src/runs/shared/subagent-control.ts +10 -6
  65. package/src/runs/shared/subagent-prompt-runtime.ts +127 -26
  66. package/src/runs/shared/task-intent.ts +176 -0
  67. package/src/runs/shared/tool-availability.ts +65 -0
  68. package/src/runs/shared/turn-budget.ts +49 -4
  69. package/src/shared/atomic-json.ts +4 -1
  70. package/src/shared/fork-context.ts +28 -3
  71. package/src/shared/model-info.ts +7 -4
  72. package/src/shared/status-format.ts +7 -1
  73. package/src/shared/types.ts +203 -25
  74. package/src/shared/utils.ts +35 -7
  75. package/src/slash/delegation-adapters.ts +457 -0
  76. package/src/slash/delegation-request.ts +103 -0
  77. package/src/slash/prompt-template-bridge.ts +167 -344
  78. package/src/slash/slash-commands.ts +239 -6
  79. package/src/slash/subagents-admin.ts +428 -0
  80. package/src/slash/subagents-editor.ts +86 -0
  81. package/src/tui/fleet.ts +405 -0
  82. package/src/tui/render.ts +90 -16
  83. package/src/watchdog/change-signature.ts +127 -0
  84. package/src/watchdog/child-status.ts +205 -0
  85. package/src/watchdog/emission-guard.ts +123 -0
  86. package/src/watchdog/lsp-diagnostics.ts +532 -0
  87. package/src/watchdog/model-selection.ts +167 -0
  88. package/src/watchdog/register-child.ts +117 -0
  89. package/src/watchdog/register-main.ts +433 -0
  90. package/src/watchdog/render.ts +54 -0
  91. package/src/watchdog/review.ts +293 -0
  92. package/src/watchdog/runtime.ts +712 -0
  93. package/src/watchdog/settings.ts +528 -0
  94. package/src/watchdog/tool-actions.ts +155 -0
  95. package/src/watchdog/turn-delta.ts +161 -0
  96. package/src/watchdog/types.ts +188 -0
  97. package/src/watchdog/warning-format.ts +73 -0
  98. package/src/runs/background/wait.ts +0 -394
@@ -2,11 +2,25 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { registerNativeSupervisorClient } from "../../intercom/native-supervisor-channel.ts";
5
- import { consumeSteerRequestsFromDir, writeSteerRequestToDir, type SteerRequest } from "../background/control-channel.ts";
6
- import { SUBAGENT_FANOUT_CHILD_ENV, SUBAGENT_STEER_INBOX_ENV } from "./pi-args.ts";
5
+ import { consumeSteerRequestsFromDir, steerAckPathFromDir, writeSteerAckAt, writeSteerCapabilityAt, writeSteerRequestToDir, type SteerRequest } from "../background/control-channel.ts";
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
7
  import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV, validateStructuredOutputValue } from "./structured-output.ts";
8
+ import {
9
+ CHILD_TOOL_DIAGNOSTIC_PATH_ENV,
10
+ formatChildToolDiagnostic,
11
+ REQUIRED_CHILD_TOOLS_ENV,
12
+ writeChildToolDiagnostic,
13
+ type ChildToolDiagnostic,
14
+ } from "./tool-availability.ts";
8
15
  import { TOOL_BUDGET_ENV, decodeToolBudgetEnv, shouldBlockToolForBudget, toolBudgetBlockedMessage, toolBudgetSoftNudge } from "./tool-budget.ts";
9
- import type { JsonSchemaObject, ResolvedToolBudget } from "../../shared/types.ts";
16
+ import type { JsonSchemaObject, ResolvedToolBudget, SubagentState } from "../../shared/types.ts";
17
+ import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
18
+ import { resolveWatchPath } from "../../shared/utils.ts";
19
+ import { registerChildWatchdog } from "../../watchdog/register-child.ts";
20
+ import { SUBAGENT_WATCHDOG_WARNING_TYPE } from "../../watchdog/types.ts";
21
+ import { resolveWaitToolConfig } from "../background/wait-config.ts";
22
+ import { registerWaitTool } from "../background/wait-tool.ts";
23
+ import { drainOutstandingWork } from "../background/auto-drain.ts";
10
24
 
11
25
  const SUBAGENT_INHERIT_PROJECT_CONTEXT_ENV = "PI_SUBAGENT_INHERIT_PROJECT_CONTEXT";
12
26
  const SUBAGENT_INHERIT_SKILLS_ENV = "PI_SUBAGENT_INHERIT_SKILLS";
@@ -55,6 +69,18 @@ function readBooleanEnv(name: string): boolean | undefined {
55
69
  return value !== "0";
56
70
  }
57
71
 
72
+ function refreshChildToolDiagnostic(pi: ExtensionAPI): ChildToolDiagnostic | undefined {
73
+ const filePath = process.env[CHILD_TOOL_DIAGNOSTIC_PATH_ENV]?.trim();
74
+ const encoded = process.env[REQUIRED_CHILD_TOOLS_ENV]?.trim();
75
+ if (!filePath || !encoded) return undefined;
76
+ const required = JSON.parse(encoded) as unknown;
77
+ if (!Array.isArray(required) || required.some((name) => typeof name !== "string" || !name)) {
78
+ throw new Error(`Invalid ${REQUIRED_CHILD_TOOLS_ENV} payload.`);
79
+ }
80
+ const available = pi.getAllTools().map((tool) => tool.name);
81
+ return writeChildToolDiagnostic(filePath, required, available, process.env[SUBAGENT_CHILD_AGENT_ENV]?.trim());
82
+ }
83
+
58
84
  function findSectionEnd(prompt: string, startIndex: number, nextHeaders: string[]): number {
59
85
  let endIndex = prompt.length;
60
86
  for (const header of nextHeaders) {
@@ -114,9 +140,9 @@ export function rewriteSubagentPrompt(
114
140
 
115
141
  function isParentOnlySubagentMessage(message: unknown): boolean {
116
142
  const m = message as { role?: string; customType?: string };
117
- return m?.role === "custom"
118
- && typeof m.customType === "string"
119
- && PARENT_ONLY_CUSTOM_MESSAGE_TYPES.has(m.customType);
143
+ if (m?.role !== "custom" || typeof m.customType !== "string") return false;
144
+ if (m.customType === SUBAGENT_WATCHDOG_WARNING_TYPE) return true;
145
+ return PARENT_ONLY_CUSTOM_MESSAGE_TYPES.has(m.customType);
120
146
  }
121
147
 
122
148
  function isSubagentToolResultMessage(message: unknown): boolean {
@@ -190,29 +216,59 @@ function registerToolBudget(pi: ExtensionAPI, budget: ResolvedToolBudget | undef
190
216
  });
191
217
  }
192
218
 
193
- function registerSteeringInbox(pi: ExtensionAPI): void {
219
+ export function registerSteeringInbox(
220
+ pi: ExtensionAPI,
221
+ deps: { watch?: typeof fs.watch; nativeRealpath?: (filePath: string) => string } = {},
222
+ ): void {
194
223
  const steerInbox = process.env[SUBAGENT_STEER_INBOX_ENV]?.trim();
195
224
  if (!steerInbox) return;
225
+ const capabilityPath = process.env[SUBAGENT_STEER_CAPABILITY_ENV]?.trim();
226
+ const ackDir = process.env[SUBAGENT_STEER_ACK_DIR_ENV]?.trim();
196
227
  const sendUserMessage = (pi as { sendUserMessage?: (content: string, options: { deliverAs: "steer" }) => unknown }).sendUserMessage;
197
- if (typeof sendUserMessage !== "function") return;
198
-
199
- let canSteer = false;
228
+ const childIndex = Number(process.env[SUBAGENT_CHILD_INDEX_ENV]);
229
+ const pending = new Map<string, string[]>();
200
230
  let disposed = false;
201
231
  let flushing = false;
202
232
  let started = false;
233
+ let canSteer = typeof sendUserMessage === "function";
203
234
  let watcher: fs.FSWatcher | undefined;
204
235
  let interval: NodeJS.Timeout | undefined;
236
+ const acknowledge = (request: SteerRequest, state: "delivered" | "failed", message: string): void => {
237
+ if (!ackDir || !Number.isInteger(childIndex) || childIndex < 0) return;
238
+ writeSteerAckAt(steerAckPathFromDir(ackDir, request.id), {
239
+ requestId: request.id,
240
+ index: childIndex,
241
+ ts: Date.now(),
242
+ state,
243
+ message,
244
+ });
245
+ };
246
+ const publishCapability = (): void => {
247
+ if (!capabilityPath || !Number.isInteger(childIndex) || childIndex < 0) return;
248
+ writeSteerCapabilityAt(capabilityPath, { index: childIndex, pid: process.pid, readyAt: Date.now(), supported: canSteer });
249
+ };
205
250
  const flush = (): void => {
206
- if (disposed || flushing || !canSteer) return;
251
+ if (disposed || flushing) return;
207
252
  flushing = true;
208
253
  try {
209
254
  const requests = consumeSteerRequestsFromDir(steerInbox);
210
255
  for (let index = 0; index < requests.length; index++) {
211
256
  const request = requests[index]!;
257
+ if (!canSteer || typeof sendUserMessage !== "function") {
258
+ acknowledge(request, "failed", "Child Pi session does not support sendUserMessage steering.");
259
+ continue;
260
+ }
261
+ const formatted = formatSteerMessage(request);
262
+ const ids = pending.get(formatted) ?? [];
263
+ ids.push(request.id);
264
+ pending.set(formatted, ids);
212
265
  try {
213
- sendUserMessage(formatSteerMessage(request), { deliverAs: "steer" });
214
- } catch {
215
- for (const pending of requests.slice(index)) writeSteerRequestToDir(steerInbox, pending);
266
+ sendUserMessage(formatted, { deliverAs: "steer" });
267
+ } catch (error) {
268
+ ids.pop();
269
+ if (ids.length === 0) pending.delete(formatted);
270
+ acknowledge(request, "failed", error instanceof Error ? error.message : String(error));
271
+ for (const retry of requests.slice(index + 1)) writeSteerRequestToDir(steerInbox, retry);
216
272
  break;
217
273
  }
218
274
  }
@@ -220,16 +276,30 @@ function registerSteeringInbox(pi: ExtensionAPI): void {
220
276
  flushing = false;
221
277
  }
222
278
  };
279
+ const onInput = (event: unknown): undefined => {
280
+ if (disposed || !event || typeof event !== "object") return undefined;
281
+ const input = event as { source?: unknown; streamingBehavior?: unknown; text?: unknown; content?: unknown };
282
+ if (input.source !== "extension" || input.streamingBehavior !== "steer") return undefined;
283
+ const text = typeof input.text === "string" ? input.text : typeof input.content === "string" ? input.content : undefined;
284
+ if (!text) return undefined;
285
+ const ids = pending.get(text);
286
+ const requestId = ids?.shift();
287
+ if (!requestId) return undefined;
288
+ if (ids?.length === 0) pending.delete(text);
289
+ acknowledge({ type: "steer", id: requestId, ts: Date.now(), message: text }, "delivered", "Pi accepted the correlated steering input.");
290
+ return undefined;
291
+ };
223
292
  const start = (): void => {
224
293
  if (started || disposed) return;
225
294
  try {
226
295
  fs.mkdirSync(steerInbox, { recursive: true });
296
+ publishCapability();
227
297
  } catch {
228
298
  return;
229
299
  }
230
300
  started = true;
231
301
  try {
232
- watcher = fs.watch(steerInbox, () => flush());
302
+ watcher = (deps.watch ?? fs.watch)(resolveWatchPath(steerInbox, deps.nativeRealpath), () => flush());
233
303
  watcher.on("error", () => {});
234
304
  } catch {
235
305
  watcher = undefined;
@@ -239,21 +309,20 @@ function registerSteeringInbox(pi: ExtensionAPI): void {
239
309
  };
240
310
  const activate = (): undefined => {
241
311
  start();
242
- canSteer = true;
243
312
  flush();
244
313
  return undefined;
245
314
  };
246
315
 
247
316
  const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown) => unknown) => void;
317
+ // Register input before the watcher so an accepted extension input cannot race request dispatch.
318
+ onRuntimeEvent("input", onInput);
248
319
  onRuntimeEvent("session_start", () => start());
249
320
  for (const eventName of ["message_start", "message_update", "message_end", "tool_execution_start", "tool_execution_end", "turn_end"] as const) {
250
321
  onRuntimeEvent(eventName, activate);
251
322
  }
252
323
  onRuntimeEvent("session_shutdown", () => {
253
324
  disposed = true;
254
- try {
255
- watcher?.close();
256
- } catch {}
325
+ try { watcher?.close(); } catch {}
257
326
  if (interval) clearInterval(interval);
258
327
  });
259
328
  }
@@ -261,6 +330,23 @@ function registerSteeringInbox(pi: ExtensionAPI): void {
261
330
  export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
262
331
  registerSteeringInbox(pi);
263
332
  registerToolBudget(pi, decodeToolBudgetEnv(process.env[TOOL_BUDGET_ENV]));
333
+ registerChildWatchdog(pi);
334
+ const waitToolEnabled = resolveWaitToolConfig().enabled;
335
+ const waitState = {
336
+ baseCwd: "",
337
+ currentSessionId: null,
338
+ asyncJobs: new Map(),
339
+ foregroundControls: new Map(),
340
+ lastForegroundControlId: null,
341
+ cleanupTimers: new Map(),
342
+ lastUiContext: null,
343
+ poller: null,
344
+ completionSeen: new Map(),
345
+ watcher: null,
346
+ watcherRestartTimer: null,
347
+ resultFileCoalescer: { schedule: () => false, clear: () => {} },
348
+ } as unknown as SubagentState;
349
+ if (typeof pi.registerTool === "function") registerWaitTool(pi, waitState, waitToolEnabled);
264
350
  let nativeSupervisorClientRegistered = false;
265
351
  let nativeSupervisorFallbackRegistered = false;
266
352
  const registerNativeSupervisorClientOnce = (): void => {
@@ -275,7 +361,16 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
275
361
  registerNativeSupervisorClient(pi);
276
362
  };
277
363
  const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown) => unknown) => void;
278
- onRuntimeEvent("session_start", registerNativeSupervisorClientOnce);
364
+ onRuntimeEvent("session_start", (_event: unknown, ctx: unknown) => {
365
+ const sessionManager = (ctx as { sessionManager?: Parameters<typeof resolveCurrentSessionId>[0] } | undefined)?.sessionManager;
366
+ waitState.currentSessionId = sessionManager ? resolveCurrentSessionId(sessionManager) : null;
367
+ registerNativeSupervisorClientOnce();
368
+ refreshChildToolDiagnostic(pi);
369
+ });
370
+ onRuntimeEvent("agent_end", async (_event: unknown, ctx: unknown) => {
371
+ if ((ctx as { hasUI?: boolean } | undefined)?.hasUI === true) return;
372
+ await drainOutstandingWork({ state: waitState, events: pi.events });
373
+ });
279
374
  const structuredOutputPath = process.env[STRUCTURED_OUTPUT_CAPTURE_ENV];
280
375
  const structuredSchemaPath = process.env[STRUCTURED_OUTPUT_SCHEMA_ENV];
281
376
  if (structuredOutputPath && structuredSchemaPath) {
@@ -322,6 +417,7 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
322
417
 
323
418
  onRuntimeEvent("before_agent_start", async (event: { systemPrompt: string }) => {
324
419
  registerNativeSupervisorFallbackOnce();
420
+ const toolDiagnostic = refreshChildToolDiagnostic(pi);
325
421
  const intercomSessionName = process.env[SUBAGENT_INTERCOM_SESSION_NAME_ENV]?.trim();
326
422
  if (intercomSessionName && typeof pi.setSessionName === "function") {
327
423
  pi.setSessionName(intercomSessionName);
@@ -330,12 +426,17 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
330
426
  const inheritProjectContext = readBooleanEnv(SUBAGENT_INHERIT_PROJECT_CONTEXT_ENV);
331
427
  const inheritSkills = readBooleanEnv(SUBAGENT_INHERIT_SKILLS_ENV);
332
428
  const fanoutChild = readBooleanEnv(SUBAGENT_FANOUT_CHILD_ENV);
333
- if (inheritProjectContext === undefined && inheritSkills === undefined && fanoutChild === undefined) return;
334
- const rewritten = rewriteSubagentPrompt(event.systemPrompt, {
335
- inheritProjectContext: inheritProjectContext ?? true,
336
- inheritSkills: inheritSkills ?? true,
337
- fanoutChild: fanoutChild === true,
338
- });
429
+ let rewritten = event.systemPrompt;
430
+ if (inheritProjectContext !== undefined || inheritSkills !== undefined || fanoutChild !== undefined) {
431
+ rewritten = rewriteSubagentPrompt(event.systemPrompt, {
432
+ inheritProjectContext: inheritProjectContext ?? true,
433
+ inheritSkills: inheritSkills ?? true,
434
+ fanoutChild: fanoutChild === true,
435
+ });
436
+ }
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
+ }
339
440
  if (rewritten === event.systemPrompt) return;
340
441
  return { systemPrompt: rewritten };
341
442
  });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Shared task mutation-intent classifier.
3
+ *
4
+ * Single authority for reading a task's wording, answering two different
5
+ * questions from one prohibition analysis:
6
+ *
7
+ * - `classifyTaskMutationIntent` / `expectsImplementationMutation`: does the
8
+ * task REQUIRE file changes? Consumed by the completion mutation guard,
9
+ * which blocks completion, so its vocabulary is deliberately narrow.
10
+ * - `taskMayMutate`: COULD the task plausibly change files? Consumed by
11
+ * acceptance level inference, which only raises evidence gates, so its
12
+ * vocabulary is deliberately broad (any bare write verb).
13
+ *
14
+ * Explicit read-only wording ("do not modify", "review only") is a task-level
15
+ * intent only when no write imperative survives outside those phrases. A task
16
+ * like "Do not modify tests; implement the fix" is an implementation task with
17
+ * a scoped constraint, not a read-only task.
18
+ */
19
+
20
+ const REVIEW_ONLY_PATTERNS = [
21
+ /\breview only\b/i,
22
+ /\bsuggest fixes only\b/i,
23
+ /\bonly return findings\b/i,
24
+ /\breturn findings only\b/i,
25
+ ];
26
+
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,
30
+ /\bregardless\s+of\s+findings\b/i,
31
+ /\balways\s+(?:edit|modify|change|fix|patch|apply)\b/i,
32
+ /\bapply\s+(?:the\s+)?fix(?:es)?\s+directly\b/i,
33
+ /\bmake\s+(?:the\s+)?code\s+changes\b/i,
34
+ ];
35
+
36
+ // The prohibition's object ends at punctuation or at a coordinating word
37
+ // (but/and/then), so a follow-on clause like "but implement the fix" stays in
38
+ // the text for write-intent testing instead of being swallowed as the object.
39
+ const NO_EDIT_PROHIBITION_PATTERN = /\b(?:do not|don't|must not)\s+(?:edit|modify|write(?:\s+to)?|touch|change)\b((?:(?!\b(?:but|and|then)\b)[^.;,:!?\n–—-])*)/gi;
40
+
41
+ /** Objects of a no-edit prohibition that mean "the codebase in general" rather than a named scope. */
42
+ const GENERIC_PROHIBITION_OBJECT = /^\s*(?:(?:any|all|the|these|those|your|our|existing|project|source|sources|repo|repository)[\s/,-]*)*(?:files?|code|codebase|sources?|anything|repo(?:sitory)?)?\s*$/i;
43
+
44
+ const SCOPED_NO_EDIT_CONSTRAINT_PATTERNS = [
45
+ /\bdo not edit files?\s+outside\b/i,
46
+ /\bdo not edit\s+outside\b/i,
47
+ /\bdo not edit\s+unrelated files?\b/i,
48
+ /\bdo not change\s+unrelated files?\b/i,
49
+ /\bdo not modify\s+unrelated files?\b/i,
50
+ ];
51
+
52
+ const NO_TOOL_INTENT_PATTERNS = [
53
+ /\bno tools? needed\b/i,
54
+ /\bno tools? required\b/i,
55
+ /\bwithout using tools\b/i,
56
+ /\bdo not use tools\b/i,
57
+ /\bdon't use tools\b/i,
58
+ ];
59
+
60
+ const READ_ONLY_DELIVERABLE_PATTERNS = [
61
+ /\b(?:draft|write|compose|prepare|produce)\s+(?:(?:a|an|the)\s+)?(?:github\s+)?(?:issue|bug report|issue draft|issue body|proposal|plan|report|summary|findings?|analysis|recommendations?)\b/i,
62
+ /\b(?:issue|bug report)\s+(?:draft|body|template)\b/i,
63
+ /\b(?:return|provide|produce)\s+(?:text|markdown|answer|findings?|recommendations?)\s+only\b/i,
64
+ ];
65
+
66
+ const RESEARCH_AGENT_PATTERNS = [
67
+ /\binvestigate\b/i,
68
+ /\bscout\b/i,
69
+ /\bresearch(?:er)?\b/i,
70
+ ];
71
+
72
+ const FIX_OR_PATCH_IMPLEMENTATION_PATTERN = /\b(?:fix|patch)\s+(?:(?:it|this|that|them|each|any|all|these|those)\b|(?:(?:a|an|the|any|all)\s+)?(?:(?:failing|failed|broken|flaky|red|cold|start|current|existing|reported|approved|known|regression|unit|integration|e2e|source|typescript|type-?script|ts|type-?check|compiler)\s+)*(?:bug|defect|issues?|problems?|failures?|regressions?|tests?|errors?|items?|typos?|code|source|implementation|component|function|module|class|method|logic|file|files|readme|docs?|changelog|package\.json|config|manifest|extension|prompt|command|lint(?:ing)?|build|ci|type-?check|type\s+checking)\b)/i;
73
+
74
+ const WORKER_IMPLEMENTATION_PATTERNS = [
75
+ /\b(?:implement|edit|modify|refactor|delete)\b/i,
76
+ FIX_OR_PATCH_IMPLEMENTATION_PATTERN,
77
+ /\b(?:update|add|remove|replace|create)\b(?!\s+(?:(?:a|an|the)\s+)?(?:report|summary|findings?)(?:\b|$))/i,
78
+ /\bapply\s+(?:the\s+)?(?:(?:suggested|proposed|recommended)\s+)?(?:changes?|fix(?:es)?|patch)\b/i,
79
+ /\bmake\s+(?:the\s+)?changes\b/i,
80
+ /\bdo those fixes\b/i,
81
+ ];
82
+
83
+ const GENERAL_IMPLEMENTATION_PATTERNS = [
84
+ /\b(?:implement|edit|modify|refactor)\b/i,
85
+ FIX_OR_PATCH_IMPLEMENTATION_PATTERN,
86
+ /\bapply\s+(?:the\s+)?(?:(?:suggested|proposed|recommended)\s+)?(?:changes?|fix(?:es)?|patch)\b/i,
87
+ /\bmake\s+(?:the\s+)?changes\b/i,
88
+ /\bdo those fixes\b/i,
89
+ /\b(?:update|add|remove|replace|delete|create)\s+(?:the\s+)?(?:file|files|code|source|implementation|test|tests|component|function|module|class|method|logic|import|imports|readme|docs?|changelog|package\.json|config|manifest|extension|prompt|command)\b/i,
90
+ ];
91
+
92
+ export type TaskMutationIntent = { kind: "implementation" } | { kind: "read-only" } | { kind: "unknown" };
93
+
94
+ function stripFrameworkInstructions(task: string): string {
95
+ return task
96
+ .split("\n")
97
+ .filter((line) => !/^\s*\[(?:Write to|Read from):/i.test(line))
98
+ .filter((line) => !/^\s*(?:Create and maintain progress at:|Update progress at:|\*\*Output:\*\*|Write your findings to(?: exactly this path)?:|Return the complete artifact in your final response\.|The runtime will persist it to exactly this path:|Do not call contact_supervisor merely because no write-capable tool is available\.|This path is authoritative for this run\.|Ignore any other output filename or output path mentioned elsewhere)/i.test(line))
99
+ .join("\n");
100
+ }
101
+
102
+ function stripPatterns(task: string, patterns: RegExp[]): string {
103
+ let stripped = task;
104
+ for (const pattern of patterns) {
105
+ const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
106
+ stripped = stripped.replace(new RegExp(pattern.source, flags), " ");
107
+ }
108
+ return stripped;
109
+ }
110
+
111
+ interface NoEditProhibitionAnalysis {
112
+ /** True when any prohibition (or review-only/no-tool wording) is present. */
113
+ present: boolean;
114
+ /** True when a prohibition covers files/code in general; such wording wins over write verbs. */
115
+ blanket: boolean;
116
+ /** Task text with prohibition phrases and their objects removed, for write-intent testing. */
117
+ strippedText: string;
118
+ }
119
+
120
+ function analyzeNoEditProhibitions(taskText: string): NoEditProhibitionAnalysis {
121
+ let present = REVIEW_ONLY_PATTERNS.some((pattern) => pattern.test(taskText))
122
+ || NO_TOOL_INTENT_PATTERNS.some((pattern) => pattern.test(taskText));
123
+ let blanket = present;
124
+ let strippedText = stripPatterns(taskText, [...REVIEW_ONLY_PATTERNS, ...NO_TOOL_INTENT_PATTERNS]);
125
+ strippedText = strippedText.replace(new RegExp(NO_EDIT_PROHIBITION_PATTERN.source, NO_EDIT_PROHIBITION_PATTERN.flags), (_match, object: string) => {
126
+ present = true;
127
+ if (GENERIC_PROHIBITION_OBJECT.test(object)) blanket = true;
128
+ return " ";
129
+ });
130
+ return { present, blanket, strippedText };
131
+ }
132
+
133
+ function taskHasReadOnlyDeliverable(taskText: string): boolean {
134
+ return READ_ONLY_DELIVERABLE_PATTERNS.some((pattern) => pattern.test(taskText));
135
+ }
136
+
137
+ function hasImplementationIntent(agent: string, taskText: string): boolean {
138
+ if (/\breviewer\b/i.test(agent)) return REVIEWER_REQUIRED_EDIT_PATTERNS.some((pattern) => pattern.test(taskText));
139
+ if (agent === "worker") return WORKER_IMPLEMENTATION_PATTERNS.some((pattern) => pattern.test(taskText));
140
+ return GENERAL_IMPLEMENTATION_PATTERNS.some((pattern) => pattern.test(taskText));
141
+ }
142
+
143
+ export function classifyTaskMutationIntent(agent: string, task: string): TaskMutationIntent {
144
+ const taskText = stripFrameworkInstructions(task);
145
+ const taskTextWithoutScopedConstraints = stripPatterns(taskText, SCOPED_NO_EDIT_CONSTRAINT_PATTERNS);
146
+ const prohibitions = analyzeNoEditProhibitions(taskTextWithoutScopedConstraints);
147
+ if (prohibitions.present) {
148
+ if (prohibitions.blanket) return { kind: "read-only" };
149
+ return hasImplementationIntent(agent, prohibitions.strippedText) ? { kind: "implementation" } : { kind: "read-only" };
150
+ }
151
+
152
+ if (RESEARCH_AGENT_PATTERNS.some((pattern) => pattern.test(agent))) return { kind: "read-only" };
153
+ if (hasImplementationIntent(agent, taskText)) return { kind: "implementation" };
154
+ if (/\breviewer\b/i.test(agent)) return { kind: "read-only" };
155
+ return taskHasReadOnlyDeliverable(taskTextWithoutScopedConstraints) ? { kind: "read-only" } : { kind: "unknown" };
156
+ }
157
+
158
+ export function expectsImplementationMutation(agent: string, task: string): boolean {
159
+ return classifyTaskMutationIntent(agent, task).kind === "implementation";
160
+ }
161
+
162
+ /** Bare write verbs that make a task write-capable for acceptance inference. */
163
+ const MAY_MUTATE_VERB_PATTERN = /\b(?:fix|implement|update|write|edit|modify|migrate|delete|remove|refactor|commit)\b/i;
164
+
165
+ /**
166
+ * Whether the task could plausibly change files. Blanket prohibitions win;
167
+ * write verbs inside a scoped prohibition's object or a read-only deliverable
168
+ * phrase ("write a summary report") do not count; any other bare write verb
169
+ * does.
170
+ */
171
+ export function taskMayMutate(task: string): boolean {
172
+ const taskText = stripPatterns(stripFrameworkInstructions(task), SCOPED_NO_EDIT_CONSTRAINT_PATTERNS);
173
+ const prohibitions = analyzeNoEditProhibitions(taskText);
174
+ if (prohibitions.blanket) return false;
175
+ return MAY_MUTATE_VERB_PATTERN.test(stripPatterns(prohibitions.strippedText, READ_ONLY_DELIVERABLE_PATTERNS));
176
+ }
@@ -0,0 +1,65 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export const REQUIRED_CHILD_TOOLS_ENV = "PI_SUBAGENT_REQUIRED_TOOLS";
5
+ export const CHILD_TOOL_DIAGNOSTIC_PATH_ENV = "PI_SUBAGENT_TOOL_DIAGNOSTIC_PATH";
6
+
7
+ export interface ChildToolDiagnostic {
8
+ agent?: string;
9
+ required: string[];
10
+ available: string[];
11
+ missing: string[];
12
+ }
13
+
14
+ export function writeChildToolDiagnostic(
15
+ filePath: string,
16
+ required: string[],
17
+ available: string[],
18
+ agent?: string,
19
+ ): ChildToolDiagnostic | undefined {
20
+ const availableNames = new Set(available);
21
+ const missing = required.filter((name) => !availableNames.has(name));
22
+ if (missing.length === 0) {
23
+ fs.rmSync(filePath, { force: true });
24
+ return undefined;
25
+ }
26
+
27
+ const diagnostic: ChildToolDiagnostic = { agent, required, available, missing };
28
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
29
+ fs.writeFileSync(filePath, JSON.stringify(diagnostic), { mode: 0o600 });
30
+ return diagnostic;
31
+ }
32
+
33
+ export function readChildToolDiagnostic(filePath: string | undefined): ChildToolDiagnostic | undefined {
34
+ if (!filePath || !fs.existsSync(filePath)) return undefined;
35
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Partial<ChildToolDiagnostic>;
36
+ const stringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every((entry) => typeof entry === "string" && entry.length > 0);
37
+ if (!stringArray(parsed.required) || !stringArray(parsed.available) || !stringArray(parsed.missing) || (parsed.agent !== undefined && typeof parsed.agent !== "string")) {
38
+ throw new Error(`Malformed child tool diagnostic at '${filePath}'.`);
39
+ }
40
+ return {
41
+ ...(parsed.agent ? { agent: parsed.agent } : {}),
42
+ required: parsed.required,
43
+ available: parsed.available,
44
+ missing: parsed.missing,
45
+ };
46
+ }
47
+
48
+ export function formatChildToolDiagnostic(diagnostic: ChildToolDiagnostic): string {
49
+ const subject = diagnostic.agent ? `Agent '${diagnostic.agent}'` : "Subagent";
50
+ return [
51
+ `${subject} requested unavailable child tools: ${diagnostic.missing.join(", ")}.`,
52
+ "The `tools` field is a strict allowlist; it does not load extension code.",
53
+ "For extension tools, add the provider path to `subagentOnlyExtensions` (child-only), `extensions`, or as a path-like entry in `tools`, while keeping each registered tool name in `tools`.",
54
+ "For MCP tools, verify the MCP adapter configuration and selected tool names. For builtin tools, verify the name against the installed Pi version.",
55
+ ].join("\n");
56
+ }
57
+
58
+ export function readChildToolDiagnosticError(filePath: string | undefined): string | undefined {
59
+ try {
60
+ const diagnostic = readChildToolDiagnostic(filePath);
61
+ return diagnostic ? formatChildToolDiagnostic(diagnostic) : undefined;
62
+ } catch (error) {
63
+ return `Failed to read child tool availability diagnostic: ${error instanceof Error ? error.message : String(error)}`;
64
+ }
65
+ }
@@ -2,6 +2,27 @@ import type { ResolvedTurnBudget, TurnBudgetState } from "../../shared/types.ts"
2
2
 
3
3
  export const DEFAULT_TURN_BUDGET_GRACE_TURNS = 1;
4
4
 
5
+ export function resolveTurnBudgetConfig(
6
+ raw: unknown,
7
+ label = "turnBudget",
8
+ ): { turnBudget?: ResolvedTurnBudget; error?: string } {
9
+ if (raw === undefined) return {};
10
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
11
+ return { error: `${label} must be an object with maxTurns and optional graceTurns.` };
12
+ }
13
+ const unknownField = Object.keys(raw).find((key) => key !== "maxTurns" && key !== "graceTurns");
14
+ if (unknownField) return { error: `${label}.${unknownField} is not supported.` };
15
+ const budget = raw as { maxTurns?: unknown; graceTurns?: unknown };
16
+ if (typeof budget.maxTurns !== "number" || !Number.isInteger(budget.maxTurns) || budget.maxTurns < 1) {
17
+ return { error: `${label}.maxTurns must be an integer >= 1.` };
18
+ }
19
+ const graceTurns = budget.graceTurns ?? DEFAULT_TURN_BUDGET_GRACE_TURNS;
20
+ if (typeof graceTurns !== "number" || !Number.isInteger(graceTurns) || graceTurns < 0) {
21
+ return { error: `${label}.graceTurns must be an integer >= 0.` };
22
+ }
23
+ return { turnBudget: { maxTurns: budget.maxTurns, graceTurns } };
24
+ }
25
+
5
26
  export function appendTurnBudgetSystemPrompt(systemPrompt: string, budget: ResolvedTurnBudget | undefined): string {
6
27
  if (!budget) return systemPrompt;
7
28
  const grace = budget.graceTurns === 1 ? "1 additional assistant turn" : `${budget.graceTurns} additional assistant turns`;
@@ -24,6 +45,10 @@ export function turnBudgetExceededMessage(budget: ResolvedTurnBudget, turnCount:
24
45
  return `Subagent exceeded turn budget after ${turnCount} assistant turn${turnCount === 1 ? "" : "s"} (soft limit ${budget.maxTurns} + grace ${budget.graceTurns}).`;
25
46
  }
26
47
 
48
+ export function turnBudgetDeferredNote(budget: ResolvedTurnBudget, turnCount: number): string {
49
+ return `Turn-budget termination was deferred at ${turnCount} assistant turn${turnCount === 1 ? "" : "s"} (soft limit ${budget.maxTurns} + grace ${budget.graceTurns}) because the assistant started tool work. The run ended before another safe assistant boundary; output may be partial.`;
50
+ }
51
+
27
52
  export function formatTurnBudgetOutput(message: string, output: string): string {
28
53
  return output.trim()
29
54
  ? `${message}\n\nPartial output before turn-budget abort:\n${output}`
@@ -44,9 +69,29 @@ export function turnBudgetState(budget: ResolvedTurnBudget, turnCount: number, e
44
69
  };
45
70
  }
46
71
 
47
- export function shouldAbortForTurnBudget(budget: ResolvedTurnBudget, turnCount: number, terminalAssistantStop: boolean): boolean {
72
+ export function turnBudgetDeferredState(
73
+ budget: ResolvedTurnBudget,
74
+ turnCount: number,
75
+ terminationDeferredAtTurn = turnCount,
76
+ ): TurnBudgetState {
77
+ return {
78
+ ...budget,
79
+ turnCount,
80
+ outcome: "termination-deferred",
81
+ wrapUpRequestedAtTurn: budget.maxTurns,
82
+ terminationDeferredAtTurn,
83
+ };
84
+ }
85
+
86
+ export function turnBudgetDecision(
87
+ budget: ResolvedTurnBudget,
88
+ turnCount: number,
89
+ terminalAssistantStop: boolean,
90
+ toolWorkActiveOrStarting: boolean,
91
+ ): "continue" | "defer" | "abort" {
48
92
  const hardLimit = budget.maxTurns + budget.graceTurns;
49
- if (turnCount < hardLimit) return false;
50
- if (turnCount > hardLimit) return true;
51
- return !terminalAssistantStop;
93
+ if (turnCount < hardLimit) return "continue";
94
+ if (toolWorkActiveOrStarting) return "defer";
95
+ if (turnCount === hardLimit && terminalAssistantStop) return "continue";
96
+ return "abort";
52
97
  }
@@ -8,6 +8,7 @@ type AtomicJsonWriterOptions = {
8
8
  now?: () => number;
9
9
  pid?: number;
10
10
  random?: () => number;
11
+ mode?: number;
11
12
  retryRenameErrors?: boolean;
12
13
  retryDelaysMs?: readonly number[];
13
14
  wait?: (delayMs: number) => void;
@@ -65,6 +66,7 @@ export function createAtomicJsonWriter(options: AtomicJsonWriterOptions = {}): (
65
66
  const now = options.now ?? Date.now;
66
67
  const pid = options.pid ?? process.pid;
67
68
  const random = options.random ?? Math.random;
69
+ const mode = options.mode;
68
70
  const retryRenameErrors = options.retryRenameErrors ?? process.platform === "win32";
69
71
  const retryDelaysMs = retryRenameErrors ? options.retryDelaysMs ?? DEFAULT_RENAME_RETRY_DELAYS_MS : [];
70
72
  const wait = options.wait ?? waitSync;
@@ -75,7 +77,7 @@ export function createAtomicJsonWriter(options: AtomicJsonWriterOptions = {}): (
75
77
  `.${path.basename(filePath)}.${pid}.${now()}.${random().toString(36).slice(2)}.tmp`,
76
78
  );
77
79
  try {
78
- fsImpl.writeFileSync(tempPath, JSON.stringify(payload, null, 2), "utf-8");
80
+ fsImpl.writeFileSync(tempPath, JSON.stringify(payload, null, 2), mode === undefined ? "utf-8" : { encoding: "utf-8", mode });
79
81
  renameWithRetry(fsImpl, tempPath, filePath, retryDelaysMs, wait);
80
82
  } finally {
81
83
  fsImpl.rmSync(tempPath, { force: true });
@@ -84,3 +86,4 @@ export function createAtomicJsonWriter(options: AtomicJsonWriterOptions = {}): (
84
86
  }
85
87
 
86
88
  export const writeAtomicJson = createAtomicJsonWriter();
89
+ export const writePrivateAtomicJson = createAtomicJsonWriter({ mode: 0o600 });