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 +11 -0
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/agents/agents.ts +3 -3
- package/src/extension/index.ts +4 -3
- package/src/intercom/native-supervisor-channel.ts +175 -26
- package/src/runs/background/wait.ts +41 -0
- package/src/runs/shared/completion-guard.ts +53 -17
- package/src/runs/shared/pi-args.ts +2 -2
- package/src/shared/model-info.ts +1 -1
- package/src/shared/types.ts +7 -0
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
package/src/agents/agents.ts
CHANGED
|
@@ -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,
|
package/src/extension/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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|
|
|
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|
|
|
51
|
-
|
|
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
|
|
100
|
-
return
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
113
|
-
if (/\breviewer\b/i.test(agent))
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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))) {
|
package/src/shared/model-info.ts
CHANGED
|
@@ -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);
|
package/src/shared/types.ts
CHANGED
|
@@ -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;
|