pi-subagents 0.33.0 → 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,22 @@
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
+
16
+ ## [0.33.1] - 2026-07-03
17
+
18
+ ### Fixed
19
+ - Avoid native supervisor-channel tool conflicts when `pi-intercom` is also installed by deferring native tool registration until runtime startup and keeping a namespaced native supervisor reply tool.
20
+
5
21
  ## [0.33.0] - 2026-07-03
6
22
 
7
23
  ### Added
package/README.md CHANGED
@@ -267,7 +267,7 @@ Add `autofix` to `/parallel-review` or `/parallel-cleanup` to apply only the syn
267
267
 
268
268
  ## Native supervisor coordination
269
269
 
270
- Child agents can talk back to the parent Pi session without installing `pi-intercom`. `pi-subagents` now provides the child-facing `contact_supervisor` tool and the parent-facing `intercom({ action: "reply" })` path natively.
270
+ Child agents can talk back to the parent Pi session without installing `pi-intercom`. `pi-subagents` now provides the child-facing `contact_supervisor` tool and the parent-facing `subagent_supervisor({ action: "reply" })` path natively. If no external `pi-intercom` tool owns the `intercom` name, the native channel also exposes `intercom` as a compatibility fallback.
271
271
 
272
272
  Use it for work where the child might need a decision instead of guessing:
273
273
 
@@ -283,7 +283,7 @@ The child can use one dedicated coordination tool:
283
283
 
284
284
  - `contact_supervisor`: the child contacts the parent/supervisor session that delegated the task. Use `reason: "need_decision"` for blocking decisions or clarification, `reason: "interview_request"` for structured input, and `reason: "progress_update"` for short non-blocking updates when a discovery changes the plan. Do not ask for clarification when the only conflict is review-only/no-edit versus progress-writing or artifact-writing instructions; no-edit wins.
285
285
 
286
- Supervisor messages are scoped to the exact Pi session id that spawned the child. A second Pi session in the same repository does not receive those requests.
286
+ The parent replies with `subagent_supervisor({ action: "reply", replyTo, message })` or checks pending requests with `subagent_supervisor({ action: "pending" })`. Supervisor messages are scoped to the exact Pi session id that spawned the child. A second Pi session in the same repository does not receive those requests.
287
287
 
288
288
  Child-side routine completion handoffs are still not expected. If a child appears stalled, needs-attention notices can show up in the parent session with useful next actions, such as checking `subagent({ action: "status" })`, interrupting the run, or nudging the child.
289
289
 
@@ -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.0",
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",
@@ -550,18 +550,20 @@ contact_supervisor({
550
550
  })
551
551
  ```
552
552
 
553
- The parent replies with:
553
+ The parent replies with the native supervisor tool:
554
554
 
555
555
  ```typescript
556
- intercom({ action: "reply", message: "Optimize for readability." })
556
+ subagent_supervisor({ action: "reply", message: "Optimize for readability." })
557
557
  ```
558
558
 
559
559
  Or inspects unresolved asks first:
560
560
 
561
561
  ```typescript
562
- intercom({ action: "pending" })
562
+ subagent_supervisor({ action: "pending" })
563
563
  ```
564
564
 
565
+ If no external `pi-intercom` tool owns the `intercom` name, native supervisor coordination may also expose `intercom` as a compatibility fallback. Prefer `subagent_supervisor` for parent replies because it never overrides installed `pi-intercom`.
566
+
565
567
  If intercom messages do not show up, run `subagent({ action: "doctor" })` or `/subagents-doctor`.
566
568
 
567
569
  ## Management Mode
@@ -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);
@@ -18,9 +18,12 @@ import { writeAtomicJson } from "../shared/atomic-json.ts";
18
18
  const SUPERVISOR_CHANNEL_ROOT = path.join(TEMP_ROOT_DIR, "supervisor-channels");
19
19
  const REQUESTS_DIR = "requests";
20
20
  const REPLIES_DIR = "replies";
21
+ export const NATIVE_SUPERVISOR_TOOL_NAME = "subagent_supervisor";
21
22
  const MAX_MESSAGE_BYTES = 64 * 1024;
22
23
  const DEFAULT_ASK_TIMEOUT_MS = 10 * 60 * 1000;
23
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;
24
27
 
25
28
  type SupervisorReason = "need_decision" | "interview_request" | "progress_update";
26
29
 
@@ -28,6 +31,7 @@ interface SupervisorRequest {
28
31
  type: "subagent.supervisor.request";
29
32
  id: string;
30
33
  createdAt: number;
34
+ expiresAt?: number;
31
35
  reason: SupervisorReason;
32
36
  message: string;
33
37
  expectsReply: boolean;
@@ -202,9 +206,8 @@ function delay(ms: number, signal?: AbortSignal): Promise<void> {
202
206
  });
203
207
  }
204
208
 
205
- 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> {
206
210
  const file = replyPath(channelDir, requestId);
207
- const deadline = Date.now() + askTimeoutMs();
208
211
  while (Date.now() <= deadline) {
209
212
  if (signal?.aborted) throw new Error("Supervisor request cancelled.");
210
213
  if (fs.existsSync(file)) {
@@ -227,11 +230,15 @@ async function sendSupervisorRequest(params: ContactSupervisorParams, signal?: A
227
230
  ensureSupervisorChannelDir(metadata.channelDir);
228
231
  const requestId = randomUUID();
229
232
  const expectsReply = params.reason !== "progress_update";
233
+ const createdAt = Date.now();
234
+ const replyDeadline = createdAt + askTimeoutMs();
235
+ const expiresAt = expectsReply ? replyDeadline : undefined;
230
236
  const message = formatChildMessage({ ...metadata, reason: params.reason, message: params.message, interview: params.interview });
231
237
  const request: SupervisorRequest = {
232
238
  type: "subagent.supervisor.request",
233
239
  id: requestId,
234
- createdAt: Date.now(),
240
+ createdAt,
241
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
235
242
  reason: params.reason,
236
243
  message,
237
244
  expectsReply,
@@ -254,17 +261,22 @@ async function sendSupervisorRequest(params: ContactSupervisorParams, signal?: A
254
261
  };
255
262
  }
256
263
 
257
- const reply = await waitForReply(metadata.channelDir, requestId, signal);
258
- const details: Record<string, unknown> = { requestId, reason: params.reason };
259
- if (params.reason === "interview_request") {
260
- const structured = parseStructuredReply(reply.message);
261
- if (structured.error) details.structuredReplyParseError = structured.error;
262
- 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;
263
279
  }
264
- return {
265
- content: [{ type: "text", text: `**Reply from supervisor:**\n${reply.message}` }],
266
- details,
267
- };
268
280
  }
269
281
 
270
282
  function hasTool(pi: ExtensionAPI, name: string): boolean {
@@ -275,8 +287,9 @@ function hasTool(pi: ExtensionAPI, name: string): boolean {
275
287
  }
276
288
  }
277
289
 
278
- export function registerNativeSupervisorClient(pi: ExtensionAPI): void {
290
+ export function registerNativeSupervisorClient(pi: ExtensionAPI, options: { includeIntercomFallback?: boolean } = {}): void {
279
291
  if (!readChildMetadata()) return;
292
+ const includeIntercomFallback = options.includeIntercomFallback !== false;
280
293
  if (!hasTool(pi, "contact_supervisor")) {
281
294
  const tool: ToolDefinition<typeof ContactSupervisorParamsSchema, Record<string, unknown>> = {
282
295
  name: "contact_supervisor",
@@ -289,7 +302,7 @@ export function registerNativeSupervisorClient(pi: ExtensionAPI): void {
289
302
  };
290
303
  pi.registerTool(tool);
291
304
  }
292
- if (!hasTool(pi, "intercom")) {
305
+ if (includeIntercomFallback && !hasTool(pi, "intercom")) {
293
306
  const tool: ToolDefinition<typeof IntercomParamsSchema, Record<string, unknown>> = {
294
307
  name: "intercom",
295
308
  label: "Intercom",
@@ -348,13 +361,85 @@ function listRequestFiles(): Array<{ channelDir: string; file: string }> {
348
361
  return files;
349
362
  }
350
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
+
351
435
  function currentContextSessionId(state: Pick<SubagentState, "currentSessionId">, ctx: ExtensionContext): string | undefined {
352
- if (state.currentSessionId) return state.currentSessionId;
353
436
  try {
354
- return ctx.sessionManager.getSessionId();
437
+ const sessionId = ctx.sessionManager.getSessionId();
438
+ if (sessionId) return sessionId;
355
439
  } catch {
356
- return undefined;
440
+ // Fall through to the last known identity.
357
441
  }
442
+ return state.currentSessionId ?? undefined;
358
443
  }
359
444
 
360
445
  function requestMatchesContext(request: SupervisorRequest, state: Pick<SubagentState, "currentSessionId">, ctx: ExtensionContext): boolean {
@@ -362,15 +447,68 @@ function requestMatchesContext(request: SupervisorRequest, state: Pick<SubagentS
362
447
  return Boolean(currentSessionId && request.orchestratorSessionId === currentSessionId);
363
448
  }
364
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
+
365
503
  function formatPendingLine(request: PendingSupervisorRequest): string {
366
- const replyHint = request.expectsReply ? ` Reply: intercom({ action: "reply", replyTo: "${request.id}", message: "..." })` : "";
504
+ const replyHint = request.expectsReply ? ` Reply: ${NATIVE_SUPERVISOR_TOOL_NAME}({ action: "reply", replyTo: "${request.id}", message: "..." })` : "";
367
505
  return `- ${request.id}: ${request.agent} [${request.runId}#${request.childIndex}] ${request.reason}.${replyHint}`;
368
506
  }
369
507
 
370
508
  function requestVisibleText(request: PendingSupervisorRequest): string {
371
509
  const lines = [request.message];
372
510
  if (request.expectsReply) {
373
- lines.push("", `Reply with: intercom({ action: "reply", replyTo: "${request.id}", message: "..." })`);
511
+ lines.push("", `Reply with: ${NATIVE_SUPERVISOR_TOOL_NAME}({ action: "reply", replyTo: "${request.id}", message: "..." })`);
374
512
  }
375
513
  return lines.join("\n");
376
514
  }
@@ -384,11 +522,7 @@ function writeReply(request: PendingSupervisorRequest, message: string): void {
384
522
  message: message.trim(),
385
523
  };
386
524
  writeAtomicJson(replyPath(request.channelDir, request.id), reply);
387
- try {
388
- fs.rmSync(request.requestFile, { force: true });
389
- } catch {
390
- // Best effort: the reply file is authoritative for the child.
391
- }
525
+ removeRequestFile(request.requestFile);
392
526
  }
393
527
 
394
528
  function resolvePendingRequest(pending: Map<string, PendingSupervisorRequest>, params: IntercomParams): PendingSupervisorRequest {
@@ -424,13 +558,16 @@ function publicPendingRequests(pending: Map<string, PendingSupervisorRequest>):
424
558
  }));
425
559
  }
426
560
 
427
- function buildParentIntercomTool(pending: Map<string, PendingSupervisorRequest>): ToolDefinition<typeof IntercomParamsSchema, Record<string, unknown>> {
561
+ function buildParentIntercomTool(pending: Map<string, PendingSupervisorRequest>, state: SubagentState, name = "intercom"): ToolDefinition<typeof IntercomParamsSchema, Record<string, unknown>> {
428
562
  return {
429
- name: "intercom",
430
- label: "Intercom",
431
- description: "Native pi-subagents supervisor channel. Use reply/pending/status to answer child subagent requests.",
563
+ name,
564
+ label: name === "intercom" ? "Intercom" : "Subagent Supervisor",
565
+ description: name === "intercom"
566
+ ? "Native pi-subagents supervisor channel. Use reply/pending/status to answer child subagent requests."
567
+ : "Native pi-subagents supervisor channel. Use reply/pending/status to answer child subagent requests without overriding pi-intercom.",
432
568
  parameters: IntercomParamsSchema,
433
569
  async execute(_id, params) {
570
+ refreshPendingRequests(pending, state, state.lastUiContext ?? undefined);
434
571
  const input = params as IntercomParams;
435
572
  if (input.action === "status") {
436
573
  return { content: [{ type: "text", text: `Native supervisor channel active. Pending replies: ${pending.size}.` }], details: { active: true, pending: pending.size, root: SUPERVISOR_CHANNEL_ROOT } };
@@ -457,24 +594,44 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
457
594
  const pending = new Map<string, PendingSupervisorRequest>();
458
595
  const seenFiles = new Set<string>();
459
596
  let poller: ReturnType<typeof setInterval> | undefined;
597
+ let lastStaleCleanupAt = 0;
460
598
 
461
- if (!hasTool(pi, "intercom")) pi.registerTool(buildParentIntercomTool(pending));
599
+ const registerParentTools = (): void => {
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
+ }
613
+ };
462
614
 
463
615
  const poll = (): void => {
616
+ cleanupStaleChannelsIfDue();
464
617
  const ctx = state.lastUiContext;
465
618
  if (!ctx) return;
619
+ refreshPendingRequests(pending, state, ctx);
620
+ const now = Date.now();
466
621
  for (const { channelDir, file } of listRequestFiles()) {
467
622
  if (seenFiles.has(file)) continue;
468
623
  const request = parseRequestFile(file, channelDir);
469
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
+ }
470
631
  seenFiles.add(file);
471
632
  if (request.expectsReply) pending.set(request.id, request);
472
633
  else {
473
- try {
474
- fs.rmSync(request.requestFile, { force: true });
475
- } catch {
476
- // Non-blocking progress updates are already delivered to this session.
477
- }
634
+ removeRequestFile(request.requestFile);
478
635
  }
479
636
  pi.sendMessage({
480
637
  customType: "subagent_supervisor_request",
@@ -495,6 +652,7 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
495
652
  return {
496
653
  start: () => {
497
654
  if (poller) return;
655
+ registerParentTools();
498
656
  poll();
499
657
  poller = setInterval(poll, CHANNEL_POLL_MS);
500
658
  poller.unref?.();
@@ -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))) {
@@ -261,7 +261,21 @@ function registerSteeringInbox(pi: ExtensionAPI): void {
261
261
  export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
262
262
  registerSteeringInbox(pi);
263
263
  registerToolBudget(pi, decodeToolBudgetEnv(process.env[TOOL_BUDGET_ENV]));
264
- registerNativeSupervisorClient(pi);
264
+ let nativeSupervisorClientRegistered = false;
265
+ let nativeSupervisorFallbackRegistered = false;
266
+ const registerNativeSupervisorClientOnce = (): void => {
267
+ if (nativeSupervisorClientRegistered) return;
268
+ nativeSupervisorClientRegistered = true;
269
+ registerNativeSupervisorClient(pi, { includeIntercomFallback: false });
270
+ };
271
+ const registerNativeSupervisorFallbackOnce = (): void => {
272
+ registerNativeSupervisorClientOnce();
273
+ if (nativeSupervisorFallbackRegistered) return;
274
+ nativeSupervisorFallbackRegistered = true;
275
+ registerNativeSupervisorClient(pi);
276
+ };
277
+ const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown) => unknown) => void;
278
+ onRuntimeEvent("session_start", registerNativeSupervisorClientOnce);
265
279
  const structuredOutputPath = process.env[STRUCTURED_OUTPUT_CAPTURE_ENV];
266
280
  const structuredSchemaPath = process.env[STRUCTURED_OUTPUT_SCHEMA_ENV];
267
281
  if (structuredOutputPath && structuredSchemaPath) {
@@ -300,7 +314,6 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
300
314
  });
301
315
  }
302
316
 
303
- const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown) => unknown) => void;
304
317
  onRuntimeEvent("context", (event: { messages: unknown[] }) => {
305
318
  const messages = stripParentOnlySubagentMessages(event.messages);
306
319
  if (messages === event.messages) return undefined;
@@ -308,6 +321,7 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
308
321
  });
309
322
 
310
323
  onRuntimeEvent("before_agent_start", async (event: { systemPrompt: string }) => {
324
+ registerNativeSupervisorFallbackOnce();
311
325
  const intercomSessionName = process.env[SUBAGENT_INTERCOM_SESSION_NAME_ENV]?.trim();
312
326
  if (intercomSessionName && typeof pi.setSessionName === "function") {
313
327
  pi.setSessionName(intercomSessionName);
@@ -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;