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 +16 -0
- package/README.md +10 -2
- package/package.json +1 -1
- package/skills/pi-subagents/SKILL.md +5 -3
- package/src/agents/agents.ts +3 -3
- package/src/extension/index.ts +4 -3
- package/src/intercom/native-supervisor-channel.ts +193 -35
- 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/runs/shared/subagent-prompt-runtime.ts +16 -2
- package/src/shared/model-info.ts +1 -1
- package/src/shared/types.ts +7 -0
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 `
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
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);
|
|
@@ -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
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
437
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
438
|
+
if (sessionId) return sessionId;
|
|
355
439
|
} catch {
|
|
356
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
561
|
+
function buildParentIntercomTool(pending: Map<string, PendingSupervisorRequest>, state: SubagentState, name = "intercom"): ToolDefinition<typeof IntercomParamsSchema, Record<string, unknown>> {
|
|
428
562
|
return {
|
|
429
|
-
name
|
|
430
|
-
label: "Intercom",
|
|
431
|
-
description:
|
|
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
|
-
|
|
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
|
-
|
|
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|
|
|
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))) {
|
|
@@ -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
|
-
|
|
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);
|
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;
|