pi-subagents 0.33.1 → 0.34.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.34.0] - 2026-07-07
6
+
7
+ ### Added
8
+ - Added `waitTool` config and `PI_SUBAGENT_WAIT_TOOL_ENABLED` so interactive users can keep the `wait` tool registered while making it return immediately instead of blocking on background subagents.
9
+
10
+ ### Fixed
11
+ - Coerce agent frontmatter `thinking: false` to disabled thinking so child model IDs do not gain invalid `:false` suffixes.
12
+ - Suppress stale native supervisor-channel asks after replies, expiry, or inactive child runs, and clean cancelled child requests so `subagent_supervisor` and visible intercom notices stay aligned.
13
+ - Avoid completion-guard failures for read-only issue-drafting tasks that mention suggested fixes while preserving mutation expectations for real implementation tasks.
14
+ - Prune stale empty native supervisor-channel directories before polling while preserving fresh or non-empty channels.
15
+
5
16
  ## [0.33.1] - 2026-07-03
6
17
 
7
18
  ### Fixed
package/README.md CHANGED
@@ -1154,6 +1154,14 @@ Controls the parent-facing `subagent` tool description registered at startup. `f
1154
1154
 
1155
1155
  Makes top-level calls use background execution when the request does not explicitly set `async`. Callers can still force foreground with `async: false` unless `forceTopLevelAsync` is enabled.
1156
1156
 
1157
+ ### `waitTool`
1158
+
1159
+ ```json
1160
+ { "waitTool": { "enabled": false } }
1161
+ ```
1162
+
1163
+ Keeps the `wait` tool registered but makes it return immediately instead of blocking on active async runs. Use this in interactive sessions where background completions should arrive as notifications while the main conversation stays steerable. The default is enabled. You can also set `"waitTool": false`; set `PI_SUBAGENT_WAIT_TOOL_ENABLED=false` (or `0`, `off`, `disabled`) to override config for one process. Invalid `waitTool` config or env values fail instead of being coerced.
1164
+
1157
1165
  ### `forceTopLevelAsync`
1158
1166
 
1159
1167
  ```json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.33.1",
3
+ "version": "0.34.0",
4
4
  "description": "Pi extension for delegating tasks to subagents with chains, parallel execution, and TUI clarification",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
@@ -57,7 +57,7 @@ export function defaultInheritSkills(): boolean {
57
57
  export interface BuiltinAgentOverrideBase {
58
58
  model?: string;
59
59
  fallbackModels?: string[];
60
- thinking?: string;
60
+ thinking?: string | false;
61
61
  systemPromptMode: SystemPromptMode;
62
62
  inheritProjectContext: boolean;
63
63
  inheritSkills: boolean;
@@ -111,7 +111,7 @@ export interface AgentConfig {
111
111
  mcpDirectTools?: string[];
112
112
  model?: string;
113
113
  fallbackModels?: string[];
114
- thinking?: string;
114
+ thinking?: string | false;
115
115
  systemPromptMode: SystemPromptMode;
116
116
  inheritProjectContext: boolean;
117
117
  inheritSkills: boolean;
@@ -1283,7 +1283,7 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
1283
1283
  mcpDirectTools: mcpDirectTools.length > 0 ? mcpDirectTools : undefined,
1284
1284
  model: frontmatter.model,
1285
1285
  fallbackModels: fallbackModels && fallbackModels.length > 0 ? fallbackModels : undefined,
1286
- thinking: frontmatter.thinking,
1286
+ thinking: frontmatter.thinking === "false" ? false : frontmatter.thinking,
1287
1287
  systemPromptMode,
1288
1288
  inheritProjectContext,
1289
1289
  inheritSkills,
@@ -36,7 +36,7 @@ import { createNativeSupervisorChannel } from "../intercom/native-supervisor-cha
36
36
  import { registerSubagentRpcBridge } from "./rpc.ts";
37
37
  import { clearSlashSnapshots, getSlashRenderableSnapshot, resolveSlashMessageDetails, restoreSlashFinalSnapshots, type SlashMessageDetails } from "../slash/slash-live-state.ts";
38
38
  import { inspectSubagentStatus } from "../runs/background/run-status.ts";
39
- import { waitForSubagents } from "../runs/background/wait.ts";
39
+ import { resolveWaitToolConfig, waitForSubagents } from "../runs/background/wait.ts";
40
40
  import registerSubagentNotify, { type SubagentNotifyDetails } from "../runs/background/notify.ts";
41
41
  import { SUBAGENT_CHILD_ENV, SUBAGENT_PARENT_SESSION_ENV } from "../runs/shared/pi-args.ts";
42
42
  import { formatDuration, shortenPath } from "../shared/formatters.ts";
@@ -257,6 +257,7 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
257
257
  cleanupOldChainDirs();
258
258
 
259
259
  const config = loadConfig();
260
+ const waitToolConfig = resolveWaitToolConfig(config.waitTool);
260
261
  const asyncByDefault = config.asyncByDefault === true;
261
262
  const tempArtifactsDir = getArtifactsDir(null);
262
263
  cleanupAllArtifactDirs(DEFAULT_ARTIFACT_CONFIG.cleanupDays);
@@ -517,10 +518,10 @@ Use this after launching async subagents when you have no independent work left
517
518
  • { id: "..." } — wait for one specific run (id or prefix) to finish.
518
519
  • { timeoutMs: 600000 } — stop waiting after N ms (the runs keep going regardless; default 30 min)
519
520
 
520
- wait also returns when a run needs attention (a child that went idle or blocked for a decision), not only on completion — so a stuck child never stalls the loop; the summary names the run(s) to inspect/nudge/resume/interrupt. It wakes the instant a completion or control event arrives (subscribed to Pi's event bus, with a poll fallback that reconciles crashed runners), keeps the turn alive for normal notification delivery, and resolves early if the turn is aborted.`,
521
+ wait also returns when a run needs attention (a child that went idle or blocked for a decision), not only on completion — so a stuck child never stalls the loop; the summary names the run(s) to inspect/nudge/resume/interrupt. It wakes the instant a completion or control event arrives (subscribed to Pi's event bus, with a poll fallback that reconciles crashed runners), keeps the turn alive for normal notification delivery, and resolves early if the turn is aborted.${waitToolConfig.enabled ? "" : "\n\nConfigured behavior: wait is disabled by config.waitTool or PI_SUBAGENT_WAIT_TOOL_ENABLED and returns immediately without blocking."}`,
521
522
  parameters: WaitParams,
522
523
  execute(_id, params, signal, _onUpdate, _ctx) {
523
- return waitForSubagents(params, signal, { state, events: pi.events });
524
+ return waitForSubagents(params, signal, { state, events: pi.events, enabled: waitToolConfig.enabled });
524
525
  },
525
526
  };
526
527
  pi.registerTool(waitTool);
@@ -22,6 +22,8 @@ export const NATIVE_SUPERVISOR_TOOL_NAME = "subagent_supervisor";
22
22
  const MAX_MESSAGE_BYTES = 64 * 1024;
23
23
  const DEFAULT_ASK_TIMEOUT_MS = 10 * 60 * 1000;
24
24
  const CHANNEL_POLL_MS = Math.min(POLL_INTERVAL_MS, 500);
25
+ const STALE_EMPTY_CHANNEL_AGE_MS = 60 * 1000;
26
+ const STALE_EMPTY_CHANNEL_CLEANUP_INTERVAL_MS = 60 * 1000;
25
27
 
26
28
  type SupervisorReason = "need_decision" | "interview_request" | "progress_update";
27
29
 
@@ -29,6 +31,7 @@ interface SupervisorRequest {
29
31
  type: "subagent.supervisor.request";
30
32
  id: string;
31
33
  createdAt: number;
34
+ expiresAt?: number;
32
35
  reason: SupervisorReason;
33
36
  message: string;
34
37
  expectsReply: boolean;
@@ -203,9 +206,8 @@ function delay(ms: number, signal?: AbortSignal): Promise<void> {
203
206
  });
204
207
  }
205
208
 
206
- async function waitForReply(channelDir: string, requestId: string, signal?: AbortSignal): Promise<SupervisorReply> {
209
+ async function waitForReply(channelDir: string, requestId: string, deadline: number, signal?: AbortSignal): Promise<SupervisorReply> {
207
210
  const file = replyPath(channelDir, requestId);
208
- const deadline = Date.now() + askTimeoutMs();
209
211
  while (Date.now() <= deadline) {
210
212
  if (signal?.aborted) throw new Error("Supervisor request cancelled.");
211
213
  if (fs.existsSync(file)) {
@@ -228,11 +230,15 @@ async function sendSupervisorRequest(params: ContactSupervisorParams, signal?: A
228
230
  ensureSupervisorChannelDir(metadata.channelDir);
229
231
  const requestId = randomUUID();
230
232
  const expectsReply = params.reason !== "progress_update";
233
+ const createdAt = Date.now();
234
+ const replyDeadline = createdAt + askTimeoutMs();
235
+ const expiresAt = expectsReply ? replyDeadline : undefined;
231
236
  const message = formatChildMessage({ ...metadata, reason: params.reason, message: params.message, interview: params.interview });
232
237
  const request: SupervisorRequest = {
233
238
  type: "subagent.supervisor.request",
234
239
  id: requestId,
235
- createdAt: Date.now(),
240
+ createdAt,
241
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
236
242
  reason: params.reason,
237
243
  message,
238
244
  expectsReply,
@@ -255,17 +261,22 @@ async function sendSupervisorRequest(params: ContactSupervisorParams, signal?: A
255
261
  };
256
262
  }
257
263
 
258
- const reply = await waitForReply(metadata.channelDir, requestId, signal);
259
- const details: Record<string, unknown> = { requestId, reason: params.reason };
260
- if (params.reason === "interview_request") {
261
- const structured = parseStructuredReply(reply.message);
262
- if (structured.error) details.structuredReplyParseError = structured.error;
263
- else details.structuredReply = structured.value;
264
+ try {
265
+ const reply = await waitForReply(metadata.channelDir, requestId, replyDeadline, signal);
266
+ const details: Record<string, unknown> = { requestId, reason: params.reason };
267
+ if (params.reason === "interview_request") {
268
+ const structured = parseStructuredReply(reply.message);
269
+ if (structured.error) details.structuredReplyParseError = structured.error;
270
+ else details.structuredReply = structured.value;
271
+ }
272
+ return {
273
+ content: [{ type: "text", text: `**Reply from supervisor:**\n${reply.message}` }],
274
+ details,
275
+ };
276
+ } catch (error) {
277
+ removeRequestFile(requestPath(metadata.channelDir, requestId));
278
+ throw error;
264
279
  }
265
- return {
266
- content: [{ type: "text", text: `**Reply from supervisor:**\n${reply.message}` }],
267
- details,
268
- };
269
280
  }
270
281
 
271
282
  function hasTool(pi: ExtensionAPI, name: string): boolean {
@@ -350,6 +361,77 @@ function listRequestFiles(): Array<{ channelDir: string; file: string }> {
350
361
  return files;
351
362
  }
352
363
 
364
+ function readDirectoryEntries(dir: string): fs.Dirent[] | undefined {
365
+ try {
366
+ return fs.readdirSync(dir, { withFileTypes: true });
367
+ } catch (error) {
368
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
369
+ return undefined;
370
+ }
371
+ }
372
+
373
+ function directoryMtimeMs(dir: string): number {
374
+ try {
375
+ return fs.statSync(dir).mtimeMs;
376
+ } catch {
377
+ return 0;
378
+ }
379
+ }
380
+
381
+ function removeEmptyDirectory(dir: string): boolean {
382
+ try {
383
+ fs.rmdirSync(dir);
384
+ return true;
385
+ } catch (error) {
386
+ const code = (error as NodeJS.ErrnoException).code;
387
+ if (code === "ENOENT") return true;
388
+ if (code === "ENOTEMPTY" || code === "EEXIST" || code === "EPERM" || code === "EBUSY") return false;
389
+ throw error;
390
+ }
391
+ }
392
+
393
+ function removeStaleEmptySupervisorChannel(channelDir: string, nowMs: number): boolean {
394
+ const requestsDir = path.join(channelDir, REQUESTS_DIR);
395
+ const repliesDir = path.join(channelDir, REPLIES_DIR);
396
+ const newestKnownMtimeMs = Math.max(
397
+ directoryMtimeMs(channelDir),
398
+ directoryMtimeMs(requestsDir),
399
+ directoryMtimeMs(repliesDir),
400
+ );
401
+ if (nowMs - newestKnownMtimeMs < STALE_EMPTY_CHANNEL_AGE_MS) return false;
402
+
403
+ const requestEntries = readDirectoryEntries(requestsDir);
404
+ if (!requestEntries || requestEntries.length > 0) return false;
405
+ const replyEntries = readDirectoryEntries(repliesDir);
406
+ if (!replyEntries || replyEntries.length > 0) return false;
407
+
408
+ if (!removeEmptyDirectory(requestsDir)) return false;
409
+ if (!removeEmptyDirectory(repliesDir)) return false;
410
+ if (!removeEmptyDirectory(channelDir)) return false;
411
+ return true;
412
+ }
413
+
414
+ function cleanupStaleEmptySupervisorChannels(nowMs = Date.now()): number {
415
+ let channelEntries: fs.Dirent[];
416
+ try {
417
+ channelEntries = fs.readdirSync(SUPERVISOR_CHANNEL_ROOT, { withFileTypes: true });
418
+ } catch (error) {
419
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return 0;
420
+ throw error;
421
+ }
422
+
423
+ let removed = 0;
424
+ for (const entry of channelEntries) {
425
+ if (!entry.isDirectory()) continue;
426
+ try {
427
+ if (removeStaleEmptySupervisorChannel(path.join(SUPERVISOR_CHANNEL_ROOT, entry.name), nowMs)) removed++;
428
+ } catch {
429
+ // Cleanup is opportunistic; active writers can race with us and will be picked up by a later pass.
430
+ }
431
+ }
432
+ return removed;
433
+ }
434
+
353
435
  function currentContextSessionId(state: Pick<SubagentState, "currentSessionId">, ctx: ExtensionContext): string | undefined {
354
436
  try {
355
437
  const sessionId = ctx.sessionManager.getSessionId();
@@ -365,6 +447,59 @@ function requestMatchesContext(request: SupervisorRequest, state: Pick<SubagentS
365
447
  return Boolean(currentSessionId && request.orchestratorSessionId === currentSessionId);
366
448
  }
367
449
 
450
+ function removeRequestFile(file: string): void {
451
+ try {
452
+ fs.rmSync(file, { force: true });
453
+ } catch {
454
+ // Request cleanup is best-effort; reply files and timeout errors remain authoritative.
455
+ }
456
+ }
457
+
458
+ type SupervisorRequestLifecycle = "pending" | "resolved" | "expired" | "inactive" | "missing" | "wrong-session";
459
+
460
+ function requestExpiresAt(request: SupervisorRequest, now: number): number {
461
+ const expiresAt = (request as { expiresAt?: unknown }).expiresAt;
462
+ if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) return expiresAt;
463
+ return Number.isFinite(request.createdAt) ? request.createdAt + askTimeoutMs() : now;
464
+ }
465
+
466
+ function requestRunInactive(request: SupervisorRequest, state: SubagentState): boolean {
467
+ if (state.foregroundControls.has(request.runId)) return false;
468
+ const foregroundRun = state.foregroundRuns?.get(request.runId);
469
+ const foregroundChild = foregroundRun?.children.find((child) => child.index === request.childIndex && child.agent === request.agent)
470
+ ?? foregroundRun?.children[request.childIndex];
471
+ if (foregroundChild) return foregroundChild.status !== "detached";
472
+
473
+ const asyncJob = state.asyncJobs.get(request.runId);
474
+ if (!asyncJob) return false;
475
+ if (asyncJob.status === "complete" || asyncJob.status === "failed" || asyncJob.status === "paused") return true;
476
+ const stepStatus = asyncJob.steps?.[request.childIndex]?.status;
477
+ return stepStatus === "complete" || stepStatus === "completed" || stepStatus === "failed" || stepStatus === "paused";
478
+ }
479
+
480
+ function requestLifecycle(request: PendingSupervisorRequest, state: SubagentState, ctx: ExtensionContext | undefined, now: number): SupervisorRequestLifecycle {
481
+ if (ctx && !requestMatchesContext(request, state, ctx)) return "wrong-session";
482
+ if (!fs.existsSync(request.requestFile)) return "missing";
483
+ if (request.expectsReply && fs.existsSync(replyPath(request.channelDir, request.id))) return "resolved";
484
+ if (request.expectsReply && now > requestExpiresAt(request, now)) return "expired";
485
+ if (request.expectsReply && requestRunInactive(request, state)) return "inactive";
486
+ return "pending";
487
+ }
488
+
489
+ function cleanupRequestLifecycle(request: PendingSupervisorRequest, lifecycle: SupervisorRequestLifecycle): void {
490
+ if (lifecycle === "resolved" || lifecycle === "expired" || lifecycle === "inactive") removeRequestFile(request.requestFile);
491
+ }
492
+
493
+ function refreshPendingRequests(pending: Map<string, PendingSupervisorRequest>, state: SubagentState, ctx: ExtensionContext | undefined): void {
494
+ const now = Date.now();
495
+ for (const request of pending.values()) {
496
+ const lifecycle = requestLifecycle(request, state, ctx, now);
497
+ if (lifecycle === "pending") continue;
498
+ pending.delete(request.id);
499
+ cleanupRequestLifecycle(request, lifecycle);
500
+ }
501
+ }
502
+
368
503
  function formatPendingLine(request: PendingSupervisorRequest): string {
369
504
  const replyHint = request.expectsReply ? ` Reply: ${NATIVE_SUPERVISOR_TOOL_NAME}({ action: "reply", replyTo: "${request.id}", message: "..." })` : "";
370
505
  return `- ${request.id}: ${request.agent} [${request.runId}#${request.childIndex}] ${request.reason}.${replyHint}`;
@@ -387,11 +522,7 @@ function writeReply(request: PendingSupervisorRequest, message: string): void {
387
522
  message: message.trim(),
388
523
  };
389
524
  writeAtomicJson(replyPath(request.channelDir, request.id), reply);
390
- try {
391
- fs.rmSync(request.requestFile, { force: true });
392
- } catch {
393
- // Best effort: the reply file is authoritative for the child.
394
- }
525
+ removeRequestFile(request.requestFile);
395
526
  }
396
527
 
397
528
  function resolvePendingRequest(pending: Map<string, PendingSupervisorRequest>, params: IntercomParams): PendingSupervisorRequest {
@@ -427,7 +558,7 @@ function publicPendingRequests(pending: Map<string, PendingSupervisorRequest>):
427
558
  }));
428
559
  }
429
560
 
430
- function buildParentIntercomTool(pending: Map<string, PendingSupervisorRequest>, name = "intercom"): ToolDefinition<typeof IntercomParamsSchema, Record<string, unknown>> {
561
+ function buildParentIntercomTool(pending: Map<string, PendingSupervisorRequest>, state: SubagentState, name = "intercom"): ToolDefinition<typeof IntercomParamsSchema, Record<string, unknown>> {
431
562
  return {
432
563
  name,
433
564
  label: name === "intercom" ? "Intercom" : "Subagent Supervisor",
@@ -436,6 +567,7 @@ function buildParentIntercomTool(pending: Map<string, PendingSupervisorRequest>,
436
567
  : "Native pi-subagents supervisor channel. Use reply/pending/status to answer child subagent requests without overriding pi-intercom.",
437
568
  parameters: IntercomParamsSchema,
438
569
  async execute(_id, params) {
570
+ refreshPendingRequests(pending, state, state.lastUiContext ?? undefined);
439
571
  const input = params as IntercomParams;
440
572
  if (input.action === "status") {
441
573
  return { content: [{ type: "text", text: `Native supervisor channel active. Pending replies: ${pending.size}.` }], details: { active: true, pending: pending.size, root: SUPERVISOR_CHANNEL_ROOT } };
@@ -462,27 +594,44 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
462
594
  const pending = new Map<string, PendingSupervisorRequest>();
463
595
  const seenFiles = new Set<string>();
464
596
  let poller: ReturnType<typeof setInterval> | undefined;
597
+ let lastStaleCleanupAt = 0;
465
598
 
466
599
  const registerParentTools = (): void => {
467
- if (!hasTool(pi, NATIVE_SUPERVISOR_TOOL_NAME)) pi.registerTool(buildParentIntercomTool(pending, NATIVE_SUPERVISOR_TOOL_NAME));
468
- if (!hasTool(pi, "intercom")) pi.registerTool(buildParentIntercomTool(pending));
600
+ if (!hasTool(pi, NATIVE_SUPERVISOR_TOOL_NAME)) pi.registerTool(buildParentIntercomTool(pending, state, NATIVE_SUPERVISOR_TOOL_NAME));
601
+ if (!hasTool(pi, "intercom")) pi.registerTool(buildParentIntercomTool(pending, state));
602
+ };
603
+
604
+ const cleanupStaleChannelsIfDue = (): void => {
605
+ const nowMs = Date.now();
606
+ if (nowMs - lastStaleCleanupAt < STALE_EMPTY_CHANNEL_CLEANUP_INTERVAL_MS) return;
607
+ lastStaleCleanupAt = nowMs;
608
+ try {
609
+ cleanupStaleEmptySupervisorChannels(nowMs);
610
+ } catch {
611
+ // Supervisor delivery must not fail because best-effort temp cleanup failed.
612
+ }
469
613
  };
470
614
 
471
615
  const poll = (): void => {
616
+ cleanupStaleChannelsIfDue();
472
617
  const ctx = state.lastUiContext;
473
618
  if (!ctx) return;
619
+ refreshPendingRequests(pending, state, ctx);
620
+ const now = Date.now();
474
621
  for (const { channelDir, file } of listRequestFiles()) {
475
622
  if (seenFiles.has(file)) continue;
476
623
  const request = parseRequestFile(file, channelDir);
477
624
  if (!request || !requestMatchesContext(request, state, ctx)) continue;
625
+ const lifecycle = requestLifecycle(request, state, undefined, now);
626
+ if (lifecycle !== "pending") {
627
+ seenFiles.add(file);
628
+ cleanupRequestLifecycle(request, lifecycle);
629
+ continue;
630
+ }
478
631
  seenFiles.add(file);
479
632
  if (request.expectsReply) pending.set(request.id, request);
480
633
  else {
481
- try {
482
- fs.rmSync(request.requestFile, { force: true });
483
- } catch {
484
- // Non-blocking progress updates are already delivered to this session.
485
- }
634
+ removeRequestFile(request.requestFile);
486
635
  }
487
636
  pi.sendMessage({
488
637
  customType: "subagent_supervisor_request",
@@ -47,6 +47,7 @@ import {
47
47
  SUBAGENT_RESULT_INTERCOM_EVENT,
48
48
  type Details,
49
49
  type SubagentState,
50
+ type WaitToolConfig,
50
51
  } from "../../shared/types.ts";
51
52
  import { formatDuration } from "../../shared/formatters.ts";
52
53
 
@@ -57,6 +58,41 @@ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
57
58
  const MIN_POLL_INTERVAL_MS = 250;
58
59
  const DEFAULT_POLL_INTERVAL_MS = 1000;
59
60
 
61
+ export const WAIT_TOOL_ENABLED_ENV = "PI_SUBAGENT_WAIT_TOOL_ENABLED";
62
+
63
+ export interface ResolvedWaitToolConfig {
64
+ enabled: boolean;
65
+ }
66
+
67
+ const WAIT_TOOL_TRUE_VALUES = new Set(["1", "true", "yes", "on", "enabled"]);
68
+ const WAIT_TOOL_FALSE_VALUES = new Set(["0", "false", "no", "off", "disabled"]);
69
+
70
+ function parseWaitToolEnabledEnv(value: string | undefined): boolean | undefined {
71
+ if (value === undefined) return undefined;
72
+ const normalized = value.trim().toLowerCase();
73
+ if (WAIT_TOOL_TRUE_VALUES.has(normalized)) return true;
74
+ if (WAIT_TOOL_FALSE_VALUES.has(normalized)) return false;
75
+ throw new Error(`${WAIT_TOOL_ENABLED_ENV} must be one of true/false, 1/0, yes/no, on/off, or enabled/disabled.`);
76
+ }
77
+
78
+ function configWaitToolEnabled(config: unknown): boolean | undefined {
79
+ if (config === undefined) return undefined;
80
+ if (typeof config === "boolean") return config;
81
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
82
+ throw new Error("config.waitTool must be a boolean or an object with optional enabled boolean.");
83
+ }
84
+ const enabled = (config as { enabled?: unknown }).enabled;
85
+ if (enabled === undefined) return undefined;
86
+ if (typeof enabled !== "boolean") throw new Error("config.waitTool.enabled must be a boolean.");
87
+ return enabled;
88
+ }
89
+
90
+ export function resolveWaitToolConfig(config?: WaitToolConfig, env: Record<string, string | undefined> = process.env): ResolvedWaitToolConfig {
91
+ return {
92
+ enabled: parseWaitToolEnabledEnv(env[WAIT_TOOL_ENABLED_ENV]) ?? configWaitToolEnabled(config) ?? true,
93
+ };
94
+ }
95
+
60
96
  export interface WaitParams {
61
97
  /** Optional run id/prefix to wait for. When omitted, waits across every active run in this session. */
62
98
  id?: string;
@@ -83,6 +119,8 @@ export interface WaitDeps {
83
119
  kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
84
120
  now?: () => number;
85
121
  pollIntervalMs?: number;
122
+ /** False makes the tool return immediately without blocking active async runs. */
123
+ enabled?: boolean;
86
124
  /** Injectable sleep for tests. */
87
125
  sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
88
126
  /**
@@ -228,6 +266,9 @@ export async function waitForSubagents(
228
266
  signal: AbortSignal | undefined,
229
267
  deps: WaitDeps,
230
268
  ): Promise<AgentToolResult<Details>> {
269
+ if (deps.enabled === false) {
270
+ return result("Wait tool is disabled by config.waitTool or PI_SUBAGENT_WAIT_TOOL_ENABLED; returning immediately without blocking background subagent runs. Active runs keep going, and you can inspect them with subagent({ action: \"status\" }) or wait for completion notifications.");
271
+ }
231
272
  const now = deps.now ?? Date.now;
232
273
  const pollIntervalMs = Math.max(MIN_POLL_INTERVAL_MS, deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
233
274
  const timeoutMs = params.timeoutMs !== undefined && params.timeoutMs > 0 ? params.timeoutMs : DEFAULT_TIMEOUT_MS;
@@ -32,23 +32,41 @@ const SCOPED_NO_EDIT_CONSTRAINT_PATTERNS = [
32
32
  /\bdo not modify\s+unrelated files?\b/i,
33
33
  ];
34
34
 
35
+ const NO_TOOL_INTENT_PATTERNS = [
36
+ /\bno tools? needed\b/i,
37
+ /\bno tools? required\b/i,
38
+ /\bwithout using tools\b/i,
39
+ /\bdo not use tools\b/i,
40
+ /\bdon't use tools\b/i,
41
+ ];
42
+
43
+ const READ_ONLY_DELIVERABLE_PATTERNS = [
44
+ /\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,
45
+ /\b(?:issue|bug report)\s+(?:draft|body|template)\b/i,
46
+ /\b(?:return|provide|produce)\s+(?:text|markdown|answer|findings?|recommendations?)\s+only\b/i,
47
+ ];
48
+
35
49
  const RESEARCH_AGENT_PATTERNS = [
36
50
  /\binvestigate\b/i,
37
51
  /\bscout\b/i,
38
52
  /\bresearch(?:er)?\b/i,
39
53
  ];
40
54
 
55
+ 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;
56
+
41
57
  const WORKER_IMPLEMENTATION_PATTERNS = [
42
- /\b(?:implement|fix|edit|modify|patch|refactor|delete)\b/i,
58
+ /\b(?:implement|edit|modify|refactor|delete)\b/i,
59
+ FIX_OR_PATCH_IMPLEMENTATION_PATTERN,
43
60
  /\b(?:update|add|remove|replace|create)\b(?!\s+(?:(?:a|an|the)\s+)?(?:report|summary|findings?)(?:\b|$))/i,
44
- /\bapply\s+(?:the\s+)?(?:changes?|fix(?:es)?|patch)\b/i,
61
+ /\bapply\s+(?:the\s+)?(?:(?:suggested|proposed|recommended)\s+)?(?:changes?|fix(?:es)?|patch)\b/i,
45
62
  /\bmake\s+(?:the\s+)?changes\b/i,
46
63
  /\bdo those fixes\b/i,
47
64
  ];
48
65
 
49
66
  const GENERAL_IMPLEMENTATION_PATTERNS = [
50
- /\b(?:implement|fix|edit|modify|patch|refactor)\b/i,
51
- /\bapply\s+(?:the\s+)?(?:changes?|fix(?:es)?|patch)\b/i,
67
+ /\b(?:implement|edit|modify|refactor)\b/i,
68
+ FIX_OR_PATCH_IMPLEMENTATION_PATTERN,
69
+ /\bapply\s+(?:the\s+)?(?:(?:suggested|proposed|recommended)\s+)?(?:changes?|fix(?:es)?|patch)\b/i,
52
70
  /\bmake\s+(?:the\s+)?changes\b/i,
53
71
  /\bdo those fixes\b/i,
54
72
  /\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,
@@ -80,6 +98,10 @@ interface CompletionMutationGuardResult {
80
98
  triggered: boolean;
81
99
  }
82
100
 
101
+ type TaskMutationIntent = { kind: "implementation" } | { kind: "read-only" } | { kind: "unknown" };
102
+
103
+ type ToolMutationCapability = { kind: "mutation-capable" } | { kind: "read-only" };
104
+
83
105
  function stripFrameworkInstructions(task: string): string {
84
106
  return task
85
107
  .split("\n")
@@ -96,26 +118,40 @@ function stripScopedNoEditConstraints(task: string): string {
96
118
  return stripped;
97
119
  }
98
120
 
99
- function declaresOnlyReadOnlyTools(tools: string[] | undefined, mcpDirectTools: string[] | undefined): boolean {
100
- return tools !== undefined
101
- && tools.length > 0
102
- && (mcpDirectTools?.length ?? 0) === 0
103
- && tools.every((tool) => READ_ONLY_BUILTIN_TOOLS.has(tool));
121
+ function taskHasExplicitReadOnlyIntent(taskText: string): boolean {
122
+ return REVIEW_ONLY_PATTERNS.some((pattern) => pattern.test(taskText))
123
+ || EXPLICIT_NO_EDIT_PATTERNS.some((pattern) => pattern.test(taskText))
124
+ || NO_TOOL_INTENT_PATTERNS.some((pattern) => pattern.test(taskText));
104
125
  }
105
126
 
106
- export function expectsImplementationMutation(agent: string, task: string): boolean {
127
+ function taskHasReadOnlyDeliverable(taskText: string): boolean {
128
+ return READ_ONLY_DELIVERABLE_PATTERNS.some((pattern) => pattern.test(taskText));
129
+ }
130
+
131
+ function toolMutationCapability(tools: string[] | undefined, mcpDirectTools: string[] | undefined): ToolMutationCapability {
132
+ if (tools === undefined || tools.length === 0 || (mcpDirectTools?.length ?? 0) > 0) return { kind: "mutation-capable" };
133
+ return tools.every((tool) => READ_ONLY_BUILTIN_TOOLS.has(tool)) ? { kind: "read-only" } : { kind: "mutation-capable" };
134
+ }
135
+
136
+ function classifyTaskMutationIntent(agent: string, task: string): TaskMutationIntent {
107
137
  const taskText = stripFrameworkInstructions(task);
108
138
  const taskTextWithoutScopedConstraints = stripScopedNoEditConstraints(taskText);
109
- if (REVIEW_ONLY_PATTERNS.some((pattern) => pattern.test(taskTextWithoutScopedConstraints))) return false;
110
- if (EXPLICIT_NO_EDIT_PATTERNS.some((pattern) => pattern.test(taskTextWithoutScopedConstraints))) return false;
139
+ if (taskHasExplicitReadOnlyIntent(taskTextWithoutScopedConstraints)) return { kind: "read-only" };
111
140
 
112
- if (RESEARCH_AGENT_PATTERNS.some((pattern) => pattern.test(agent))) return false;
113
- if (/\breviewer\b/i.test(agent)) return REVIEWER_REQUIRED_EDIT_PATTERNS.some((pattern) => pattern.test(taskText));
141
+ if (RESEARCH_AGENT_PATTERNS.some((pattern) => pattern.test(agent))) return { kind: "read-only" };
142
+ if (/\breviewer\b/i.test(agent)) {
143
+ return REVIEWER_REQUIRED_EDIT_PATTERNS.some((pattern) => pattern.test(taskText)) ? { kind: "implementation" } : { kind: "read-only" };
144
+ }
114
145
 
115
146
  const workerIntent = agent === "worker" && WORKER_IMPLEMENTATION_PATTERNS.some((pattern) => pattern.test(taskText));
116
- if (workerIntent) return true;
147
+ if (workerIntent) return { kind: "implementation" };
148
+
149
+ if (GENERAL_IMPLEMENTATION_PATTERNS.some((pattern) => pattern.test(taskText))) return { kind: "implementation" };
150
+ return taskHasReadOnlyDeliverable(taskTextWithoutScopedConstraints) ? { kind: "read-only" } : { kind: "unknown" };
151
+ }
117
152
 
118
- return GENERAL_IMPLEMENTATION_PATTERNS.some((pattern) => pattern.test(taskText));
153
+ export function expectsImplementationMutation(agent: string, task: string): boolean {
154
+ return classifyTaskMutationIntent(agent, task).kind === "implementation";
119
155
  }
120
156
 
121
157
  export function hasMutationToolCall(messages: Message[]): boolean {
@@ -135,7 +171,7 @@ export function hasMutationToolCall(messages: Message[]): boolean {
135
171
  }
136
172
 
137
173
  export function evaluateCompletionMutationGuard(input: CompletionMutationGuardInput): CompletionMutationGuardResult {
138
- const expectedMutation = declaresOnlyReadOnlyTools(input.tools, input.mcpDirectTools)
174
+ const expectedMutation = toolMutationCapability(input.tools, input.mcpDirectTools).kind === "read-only"
139
175
  ? false
140
176
  : expectsImplementationMutation(input.agent, input.task);
141
177
  const attemptedMutation = hasMutationToolCall(input.messages);
@@ -39,7 +39,7 @@ interface BuildPiArgsInput {
39
39
  sessionDir?: string;
40
40
  sessionFile?: string;
41
41
  model?: string;
42
- thinking?: string;
42
+ thinking?: string | false;
43
43
  systemPromptMode?: "append" | "replace";
44
44
  inheritProjectContext: boolean;
45
45
  inheritSkills: boolean;
@@ -87,7 +87,7 @@ function supervisorChannelDir(runId: string, agent: string, childIndex: number):
87
87
  return path.join(TEMP_ROOT_DIR, "supervisor-channels", `${sanitizeSupervisorChannelSegment(runId)}-${sanitizeSupervisorChannelSegment(agent)}-${childIndex}`);
88
88
  }
89
89
 
90
- export function applyThinkingSuffix(model: string | undefined, thinking: string | undefined, replaceExisting = false): string | undefined {
90
+ export function applyThinkingSuffix(model: string | undefined, thinking: string | false | undefined, replaceExisting = false): string | undefined {
91
91
  if (!model || !thinking) return model;
92
92
  const colonIdx = model.lastIndexOf(":");
93
93
  if (colonIdx !== -1 && THINKING_LEVELS.includes(model.substring(colonIdx + 1))) {
@@ -30,7 +30,7 @@ export function toModelInfo(model: RegistryModelLike): ModelInfo {
30
30
  /** Resolve the effective thinking level from a model string (which may contain a known suffix like `:high`)
31
31
  * and an explicit thinking config value. Returns `undefined` when no thinking is applicable
32
32
  * (e.g. no model was specified, or the model has no suffix and no config was provided). */
33
- export function resolveEffectiveThinking(model: string | undefined, configThinking: string | undefined): string | undefined {
33
+ export function resolveEffectiveThinking(model: string | undefined, configThinking: string | false | undefined): string | undefined {
34
34
  if (!model) return undefined;
35
35
  const { thinkingSuffix } = splitKnownThinkingSuffix(model);
36
36
  if (thinkingSuffix) return thinkingSuffix.slice(1);
@@ -181,6 +181,12 @@ export interface CompletionBatchConfig {
181
181
  stragglerWindowMs?: number;
182
182
  }
183
183
 
184
+ export interface WaitToolConfigObject {
185
+ enabled?: boolean;
186
+ }
187
+
188
+ export type WaitToolConfig = boolean | WaitToolConfigObject;
189
+
184
190
  export interface ControlEvent {
185
191
  type: ControlEventType;
186
192
  from?: ActivityState;
@@ -1000,6 +1006,7 @@ export interface ExtensionConfig {
1000
1006
  /** Tool description variant registered for the parent-facing subagent tool. Defaults to full. */
1001
1007
  toolDescriptionMode?: ToolDescriptionMode;
1002
1008
  forceTopLevelAsync?: boolean;
1009
+ waitTool?: WaitToolConfig;
1003
1010
  defaultSessionDir?: string;
1004
1011
  singleRunOutputBaseDir?: string;
1005
1012
  maxSubagentDepth?: number;