@signalridge/pi-subagents 1.10.3 → 1.11.1
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 +53 -0
- package/README.md +34 -11
- package/examples/agent-tool-description.md +1 -1
- package/package.json +9 -8
- package/src/agent-manager.ts +1405 -486
- package/src/agent-runner.ts +538 -268
- package/src/agent-types.ts +6 -0
- package/src/ask-tools.ts +31 -21
- package/src/context-boundary.ts +54 -0
- package/src/context.ts +121 -36
- package/src/custom-agents.ts +19 -4
- package/src/enabled-models.ts +84 -83
- package/src/gate.ts +0 -0
- package/src/index.ts +1118 -861
- package/src/mention-clone.ts +129 -83
- package/src/model-runtime-bridge.ts +68 -0
- package/src/model-scope.ts +8 -3
- package/src/nested-tools.ts +13 -5
- package/src/project-trust.ts +146 -0
- package/src/settings.ts +7 -4
- package/src/skill-loader.ts +22 -10
- package/src/types.ts +6 -4
package/src/agent-runner.ts
CHANGED
|
@@ -2,20 +2,21 @@
|
|
|
2
2
|
* agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { readFileSync } from "node:fs";
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
8
|
+
import { performance } from "node:perf_hooks";
|
|
8
9
|
import type { Model } from "@earendil-works/pi-ai";
|
|
9
|
-
import type { ExtensionContext, LoadExtensionsResult } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import type { Extension, ExtensionContext, InlineExtension, LoadExtensionsResult, ToolCallEventResult } from "@earendil-works/pi-coding-agent";
|
|
10
11
|
import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
|
|
11
12
|
import {
|
|
12
13
|
type AgentSession,
|
|
13
14
|
type AgentSessionEvent,
|
|
14
15
|
createAgentSession,
|
|
16
|
+
DefaultPackageManager,
|
|
15
17
|
DefaultResourceLoader,
|
|
16
18
|
type ExtensionAPI,
|
|
17
19
|
getAgentDir,
|
|
18
|
-
type ModelRuntime,
|
|
19
20
|
SessionManager,
|
|
20
21
|
SettingsManager,
|
|
21
22
|
} from "@earendil-works/pi-coding-agent";
|
|
@@ -23,7 +24,8 @@ import { type AgentTierResolutionSnapshot, resolveAgentTier } from "./agent-tier
|
|
|
23
24
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
|
|
24
25
|
import { createAskGate } from "./ask-tools.js";
|
|
25
26
|
import { runInChildSessionContext } from "./child-context.js";
|
|
26
|
-
import {
|
|
27
|
+
import { extractText } from "./context.js";
|
|
28
|
+
import { assertNoRawInheritance, assertSafeChildSession } from "./context-boundary.js";
|
|
27
29
|
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
28
30
|
import { detectEnv } from "./env.js";
|
|
29
31
|
import { formatGateVerdict, type GateExec, runGate, workspaceFingerprint } from "./gate.js";
|
|
@@ -35,8 +37,10 @@ import {
|
|
|
35
37
|
} from "./internal-run.js";
|
|
36
38
|
import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
|
|
37
39
|
import { type ModelRegistry, resolveModel, shortModelLabel } from "./model-resolver.js";
|
|
40
|
+
import { parentModelSessionOptions } from "./model-runtime-bridge.js";
|
|
38
41
|
import { checkModelScope } from "./model-scope.js";
|
|
39
42
|
import { createNestedSubagentTools, getMaxSubagentDepth, type NestedAgentManager } from "./nested-tools.js";
|
|
43
|
+
import { captureProjectTrust, childProjectTrust, configurationMcpFactory, isProjectResource } from "./project-trust.js";
|
|
40
44
|
import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
|
|
41
45
|
import { shutdownAndDisposeSession } from "./session-lifecycle.js";
|
|
42
46
|
import { preloadSkills } from "./skill-loader.js";
|
|
@@ -57,6 +61,7 @@ export const SUBAGENT_TOOL_NAMES = {
|
|
|
57
61
|
|
|
58
62
|
/** Names of tools registered by this extension that subagents must NOT inherit. */
|
|
59
63
|
const EXCLUDED_TOOL_NAMES: string[] = Object.values(SUBAGENT_TOOL_NAMES);
|
|
64
|
+
const TOOL_POLICY_EXTENSION = "pi-subagents-tool-policy";
|
|
60
65
|
|
|
61
66
|
/**
|
|
62
67
|
* Canonical name of an extension for `extensions: [...]` allowlist matching.
|
|
@@ -66,6 +71,7 @@ const EXCLUDED_TOOL_NAMES: string[] = Object.values(SUBAGENT_TOOL_NAMES);
|
|
|
66
71
|
* single-file extensions to the basename minus `.ts`/`.js`.
|
|
67
72
|
*/
|
|
68
73
|
export function extensionCanonicalName(extPath: string): string {
|
|
74
|
+
if (extPath.startsWith("builtin:")) return extPath.slice("builtin:".length).toLowerCase();
|
|
69
75
|
const base = basename(extPath);
|
|
70
76
|
const name = base === "index.ts" || base === "index.js"
|
|
71
77
|
? basename(dirname(extPath))
|
|
@@ -219,118 +225,103 @@ export function parseExtSelectors(entries: string[]): {
|
|
|
219
225
|
*
|
|
220
226
|
* Extensions may call `registerTool` long after load — pi-mcp from `session_start`,
|
|
221
227
|
* context-mode from `before_agent_start` — so scope has to be re-derived rather than
|
|
222
|
-
* snapshotted. `
|
|
223
|
-
* so `inScope()` sees late arrivals
|
|
228
|
+
* snapshotted. Pi's public `getAllTools()` includes each live effective tool and
|
|
229
|
+
* its source, so `inScope()` sees late arrivals and resolves name collisions.
|
|
224
230
|
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
* - `beforeToolCall` blocks out-of-scope calls. Turn 1 cannot be narrowed at all:
|
|
231
|
-
* `before_agent_start` fires INSIDE `prompt()` and may widen the tool set, but
|
|
232
|
-
* `createContextSnapshot()` freezes that turn's tools immediately after — there
|
|
233
|
-
* is no hook in between. A call-time check is the only correct guard there.
|
|
234
|
-
*
|
|
235
|
-
* Both are installed on the session and deliberately NOT unsubscribed: they must
|
|
236
|
-
* outlive the `runAgent` call so resumed/steered turns stay scoped. pi's `dispose()`
|
|
237
|
-
* clears `_eventListeners`, so they die with the session rather than leaking.
|
|
238
|
-
*
|
|
239
|
-
* Only meaningful when extensions are loaded — under `noExtensions`/`isolated` the
|
|
240
|
-
* static `allowedToolNames` allowlist already gates the registry itself.
|
|
231
|
+
* The session's active set is re-narrowed at `turn_end`, before the next turn
|
|
232
|
+
* snapshots its tools. A separate, hidden extension's public `tool_call` handler
|
|
233
|
+
* enforces this same live scope at execution time. That handler also sees calls
|
|
234
|
+
* made through `ctx.executeTool()` (codemode and MCP), which do not pass through
|
|
235
|
+
* the Agent's model-call hook. Both outlive runAgent for resumed/steered turns.
|
|
241
236
|
*/
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
): void {
|
|
256
|
-
const { loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames, askGate } = ctx;
|
|
257
|
-
|
|
258
|
-
// The names allowed right now. Mirrors the `ext:` opt-in flip: when any `ext:`
|
|
259
|
-
// selector is present, extension tools become an explicit allowlist — a loaded
|
|
260
|
-
// extension not named by a selector contributes nothing (its handlers still ran),
|
|
261
|
-
// and `ext:foo/bar` narrows `foo` to just `bar`.
|
|
262
|
-
const inScope = (): Set<string> => {
|
|
263
|
-
const keep = new Set(toolNames.filter((t) => !disallowedSet?.has(t)));
|
|
237
|
+
function extensionToolNamesInScope(session: AgentSession, ctx: {
|
|
238
|
+
loader: DefaultResourceLoader;
|
|
239
|
+
toolNames: string[];
|
|
240
|
+
disallowedSet: Set<string> | undefined;
|
|
241
|
+
extNames: Set<string>;
|
|
242
|
+
narrowing: Map<string, Set<string>>;
|
|
243
|
+
nestedToolNames: Set<string>;
|
|
244
|
+
}): () => Set<string> {
|
|
245
|
+
const { loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames } = ctx;
|
|
246
|
+
const builtins = new Set(toolNames);
|
|
247
|
+
return () => {
|
|
248
|
+
const keep = new Set<string>();
|
|
249
|
+
const extensionsByPath = new Map(loader.getExtensions().extensions.map((e) => [e.path, e]));
|
|
264
250
|
const optInActive = extNames.size > 0;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
251
|
+
// Pi's public registry reports the EFFECTIVE tool definition, after its
|
|
252
|
+
// first-registration-wins collision handling. A union of extension.tools
|
|
253
|
+
// would mistakenly approve a disallowed tool shadowed by an allowed one.
|
|
254
|
+
for (const tool of session.getAllTools()) {
|
|
255
|
+
const { name } = tool;
|
|
256
|
+
if (disallowedSet?.has(name)) continue;
|
|
257
|
+
if (EXCLUDED_TOOL_NAMES.includes(name) && !nestedToolNames.has(name)) continue;
|
|
258
|
+
const path = tool.sourceInfo.path;
|
|
259
|
+
const extension = extensionsByPath.get(path);
|
|
260
|
+
if (extension) {
|
|
261
|
+
const canons = extensionCanonicalNames(extension.path);
|
|
262
|
+
if (optInActive && !canons.some((c) => extNames.has(c))) continue;
|
|
263
|
+
// First alias that carries a narrowing set — a user won't narrow one
|
|
264
|
+
// extension under two different names, so first-match is correct.
|
|
265
|
+
const narrowed = canons.map((c) => narrowing.get(c)).find(Boolean);
|
|
272
266
|
if (narrowed && !narrowed.has(name)) continue;
|
|
273
|
-
|
|
267
|
+
keep.add(name);
|
|
268
|
+
} else if (builtins.has(name) && (path === `builtin:${name}` || path === `<builtin:${name}>`)) {
|
|
269
|
+
// Pi 0.84–0.87 used angle-bracket source paths for core tools; 0.99
|
|
270
|
+
// uses builtin:<name>. Match the actual tool name, never a loose prefix.
|
|
271
|
+
keep.add(name);
|
|
272
|
+
} else if (path === `<sdk:${name}>` && nestedToolNames.has(name)) {
|
|
274
273
|
keep.add(name);
|
|
275
274
|
}
|
|
276
|
-
|
|
277
|
-
for (const name of EXCLUDED_TOOL_NAMES) keep.delete(name);
|
|
278
|
-
// Opt-in nested delegation tools share EXCLUDED_TOOL_NAMES' names but are
|
|
279
|
-
// legitimately active for this agent — re-admit them so the renarrow keeps
|
|
280
|
-
// them in the active set and beforeToolCall doesn't block them.
|
|
281
|
-
for (const name of nestedToolNames) {
|
|
282
|
-
if (!disallowedSet?.has(name)) keep.add(name);
|
|
275
|
+
// An unrecognized source cannot inherit another extension's permission.
|
|
283
276
|
}
|
|
284
277
|
return keep;
|
|
285
278
|
};
|
|
279
|
+
}
|
|
286
280
|
|
|
281
|
+
/** Only needed with extensions; `extensions: false` has a static registry allowlist. */
|
|
282
|
+
export function installExtensionToolScope(session: AgentSession, inScope: () => Set<string>): void {
|
|
283
|
+
// Remember the effective registry winner, not just the name. If Pi or another
|
|
284
|
+
// extension deliberately deactivates a tool, a later turn must not undo that
|
|
285
|
+
// decision; a different source winning the same name is a new registration.
|
|
286
|
+
const seenSources = new Map<string, string>();
|
|
287
287
|
const renarrow = () => {
|
|
288
288
|
const allowed = inScope();
|
|
289
|
-
const
|
|
289
|
+
const tools = session.getAllTools();
|
|
290
|
+
const registered = new Set(tools.map((tool) => tool.name));
|
|
291
|
+
for (const name of seenSources.keys()) {
|
|
292
|
+
if (!registered.has(name)) seenSources.delete(name);
|
|
293
|
+
}
|
|
290
294
|
const current = session.getActiveToolNames();
|
|
295
|
+
const next = current.filter((name) => allowed.has(name) && registered.has(name));
|
|
296
|
+
const active = new Set(next);
|
|
297
|
+
for (const tool of tools) {
|
|
298
|
+
const { name } = tool;
|
|
299
|
+
const source = tool.sourceInfo.path;
|
|
300
|
+
const newSource = seenSources.get(name) !== source;
|
|
301
|
+
seenSources.set(name, source);
|
|
302
|
+
if (!newSource || !allowed.has(name) || active.has(name)) continue;
|
|
303
|
+
// Pi 0.99 deliberately leaves deferred/codemode/hidden tools and
|
|
304
|
+
// defaultActive:false tools inactive. Older hosts expose neither setting
|
|
305
|
+
// and auto-activate every newly registered extension tool.
|
|
306
|
+
const exposure = tool.exposure ?? "direct";
|
|
307
|
+
if ((exposure === "direct" || exposure === "model-only") &&
|
|
308
|
+
session.getToolDefinition?.(name)?.defaultActive !== false) {
|
|
309
|
+
next.push(name);
|
|
310
|
+
active.add(name);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
291
313
|
// setActiveToolsByName unconditionally rebuilds the system prompt, so skip
|
|
292
314
|
// the no-op that steady-state turns would otherwise pay for every turn.
|
|
293
|
-
if (next.length !== current.length || next.some((
|
|
315
|
+
if (next.length !== current.length || next.some((name, i) => name !== current[i])) {
|
|
294
316
|
session.setActiveToolsByName(next);
|
|
295
317
|
}
|
|
296
318
|
};
|
|
297
319
|
|
|
298
|
-
//
|
|
299
|
-
// otherwise leave only its four default built-ins active at turn 1.
|
|
320
|
+
// Reconcile tools registered during session_start before the first turn.
|
|
300
321
|
renarrow();
|
|
301
|
-
|
|
302
322
|
session.subscribe((event: AgentSessionEvent) => {
|
|
303
323
|
if (event.type === "turn_end") renarrow();
|
|
304
324
|
});
|
|
305
|
-
|
|
306
|
-
const priorBeforeToolCall = session.agent.beforeToolCall;
|
|
307
|
-
session.agent.beforeToolCall = async (context, signal) => {
|
|
308
|
-
const run = async () => {
|
|
309
|
-
if (!inScope().has(context.toolCall.name)) {
|
|
310
|
-
return {
|
|
311
|
-
block: true,
|
|
312
|
-
reason: `Tool "${context.toolCall.name}" is not available to this subagent.`,
|
|
313
|
-
} as const;
|
|
314
|
-
}
|
|
315
|
-
// Scope first, then approval: a tool this agent may not use at all is
|
|
316
|
-
// refused without troubling the user about it.
|
|
317
|
-
const gated = await askGate?.(context.toolCall.name, context.args);
|
|
318
|
-
if (gated) return gated;
|
|
319
|
-
return priorBeforeToolCall?.(context, signal);
|
|
320
|
-
};
|
|
321
|
-
// Default per-tool timeout prevents a hung beforeToolCall (slow extension,
|
|
322
|
-
// stuck LLM arbitrator) from stalling the subagent forever. Approval
|
|
323
|
-
// dialogs are wrapped too — a tool that cannot be approved in time is
|
|
324
|
-
// blocked fail-closed rather than left hanging.
|
|
325
|
-
if (defaultToolTimeoutMs > 0) {
|
|
326
|
-
try {
|
|
327
|
-
return await withTimeout(run(), defaultToolTimeoutMs, `beforeToolCall for "${context.toolCall.name}"`);
|
|
328
|
-
} catch (err) {
|
|
329
|
-
return { block: true, reason: (err as Error).message };
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
return run();
|
|
333
|
-
};
|
|
334
325
|
}
|
|
335
326
|
|
|
336
327
|
/**
|
|
@@ -341,8 +332,10 @@ export function installExtensionToolScope(
|
|
|
341
332
|
* the agent's whole result down with it.
|
|
342
333
|
*/
|
|
343
334
|
async function runConfiguredGate(command: string, cwd: string, pi: ExtensionAPI): Promise<string> {
|
|
344
|
-
const exec: GateExec = (file, args, execOptions) =>
|
|
345
|
-
pi.exec(file, args, execOptions
|
|
335
|
+
const exec: GateExec = async (file, args, execOptions) => {
|
|
336
|
+
const result = await pi.exec(file, args, execOptions);
|
|
337
|
+
return { stdout: result.stdout, stderr: result.stderr, exitCode: result.killed ? 1 : result.code };
|
|
338
|
+
};
|
|
346
339
|
try {
|
|
347
340
|
const fingerprint = await workspaceFingerprint(cwd, exec);
|
|
348
341
|
const verdict = await runGate({ command, cwd, exec, ...(fingerprint ? { fingerprint } : {}) });
|
|
@@ -391,6 +384,17 @@ export function setDefaultMaxToolCalls(n: number): void { defaultMaxToolCalls =
|
|
|
391
384
|
/** Default per-tool timeout; `0` disables (no timeout). Mirrors tintinweb — no per-tool timeout by default; hung tools are reclaimed via session abort/quiescence, not a hard tool cut. Set via settings defaultToolTimeoutMs when needed. */
|
|
392
385
|
const DEFAULT_TOOL_TIMEOUT_MS = 0;
|
|
393
386
|
const TOOL_TIMEOUT_CEILING_MS = 600_000;
|
|
387
|
+
// Pi emits tool_execution_start before invoking the public tool_call hooks.
|
|
388
|
+
// Let their shared deadline return a block before the fallback session.abort()
|
|
389
|
+
// timer can turn that result into an unhelpful "Operation aborted".
|
|
390
|
+
const TOOL_CALL_ABORT_GRACE_MS = 25;
|
|
391
|
+
interface ApprovalTimer {
|
|
392
|
+
isInteractiveAsk: (name: string) => boolean;
|
|
393
|
+
armApproved: (toolCallId: string, toolName: string) => void;
|
|
394
|
+
}
|
|
395
|
+
// The policy survives runAgent for resumed turns; the current prompt owns its
|
|
396
|
+
// abort timers, so resume replaces armApproved instead of arming a stale timer.
|
|
397
|
+
const approvalTimers = new WeakMap<AgentSession, ApprovalTimer>();
|
|
394
398
|
let defaultToolTimeoutMs = DEFAULT_TOOL_TIMEOUT_MS;
|
|
395
399
|
export function getDefaultToolTimeoutMs(): number { return defaultToolTimeoutMs; }
|
|
396
400
|
export function setDefaultToolTimeoutMs(n: number): void {
|
|
@@ -409,6 +413,79 @@ export function withTimeout<T>(promise: Promise<T>, ms: number, label: string):
|
|
|
409
413
|
});
|
|
410
414
|
}
|
|
411
415
|
|
|
416
|
+
/**
|
|
417
|
+
* The execution fallback begins before Pi's tool_call chain. Its small grace
|
|
418
|
+
* lets a timed-out hook return a block before aborting the session; while a
|
|
419
|
+
* parent waits on ctx.executeTool, only the nested call's own budget advances.
|
|
420
|
+
* Interactive approval is excluded until the policy handler has a decision.
|
|
421
|
+
*/
|
|
422
|
+
function createToolTimeouts(session: AgentSession, grace: number, isInteractiveAsk: (name: string) => boolean) {
|
|
423
|
+
const pending = new Map<string, ReturnType<typeof setTimeout>>();
|
|
424
|
+
const due = new Map<string, number>();
|
|
425
|
+
const paused = new Map<string, { remaining: number; children: Set<string> }>();
|
|
426
|
+
const clear = (id: string): void => {
|
|
427
|
+
const handle = pending.get(id);
|
|
428
|
+
if (handle) clearTimeout(handle);
|
|
429
|
+
pending.delete(id);
|
|
430
|
+
due.delete(id);
|
|
431
|
+
paused.delete(id);
|
|
432
|
+
};
|
|
433
|
+
const arm = (id: string, name: string, delay = defaultToolTimeoutMs + grace): void => {
|
|
434
|
+
if (defaultToolTimeoutMs <= 0 || name === "contact_supervisor") return;
|
|
435
|
+
due.set(id, performance.now() + delay);
|
|
436
|
+
const handle = setTimeout(() => {
|
|
437
|
+
pending.delete(id);
|
|
438
|
+
due.delete(id);
|
|
439
|
+
try {
|
|
440
|
+
session.abort();
|
|
441
|
+
} catch {
|
|
442
|
+
// The session may already have been torn down.
|
|
443
|
+
}
|
|
444
|
+
}, delay);
|
|
445
|
+
handle.unref?.();
|
|
446
|
+
pending.set(id, handle);
|
|
447
|
+
};
|
|
448
|
+
return {
|
|
449
|
+
armApproved: (id: string, name: string): void => arm(id, name),
|
|
450
|
+
start: (event: { toolCallId: string; toolName: string; parentToolCallId?: string }): void => {
|
|
451
|
+
if (event.parentToolCallId) {
|
|
452
|
+
const parent = paused.get(event.parentToolCallId);
|
|
453
|
+
if (parent) parent.children.add(event.toolCallId);
|
|
454
|
+
else {
|
|
455
|
+
const handle = pending.get(event.parentToolCallId);
|
|
456
|
+
const deadline = due.get(event.parentToolCallId);
|
|
457
|
+
if (handle && deadline !== undefined) {
|
|
458
|
+
clearTimeout(handle);
|
|
459
|
+
pending.delete(event.parentToolCallId);
|
|
460
|
+
due.delete(event.parentToolCallId);
|
|
461
|
+
paused.set(event.parentToolCallId, {
|
|
462
|
+
remaining: Math.max(0, deadline - performance.now()),
|
|
463
|
+
children: new Set([event.toolCallId]),
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (!isInteractiveAsk(event.toolName)) arm(event.toolCallId, event.toolName);
|
|
469
|
+
},
|
|
470
|
+
end: (event: { toolCallId: string; parentToolCallId?: string }): void => {
|
|
471
|
+
clear(event.toolCallId);
|
|
472
|
+
if (!event.parentToolCallId) return;
|
|
473
|
+
const parent = paused.get(event.parentToolCallId);
|
|
474
|
+
if (!parent) return;
|
|
475
|
+
parent.children.delete(event.toolCallId);
|
|
476
|
+
if (parent.children.size > 0) return;
|
|
477
|
+
paused.delete(event.parentToolCallId);
|
|
478
|
+
arm(event.parentToolCallId, "", parent.remaining);
|
|
479
|
+
},
|
|
480
|
+
dispose: (): void => {
|
|
481
|
+
for (const handle of pending.values()) clearTimeout(handle);
|
|
482
|
+
pending.clear();
|
|
483
|
+
due.clear();
|
|
484
|
+
paused.clear();
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
412
489
|
let graceTurns = 5;
|
|
413
490
|
|
|
414
491
|
/** Get the grace turns value. */
|
|
@@ -548,9 +625,9 @@ export interface RunOptions {
|
|
|
548
625
|
*
|
|
549
626
|
* WARNING for future callers: if you pass `cwd` pointing at a directory the
|
|
550
627
|
* user didn't open, you almost certainly must pass `configCwd` too —
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
628
|
+
* a different configuration root does not inherit the parent's trust.
|
|
629
|
+
* Worktree execution with configCwd pointing at the parent retains trust;
|
|
630
|
+
* configuration discovered inside a new worktree is not implicitly trusted.
|
|
554
631
|
*/
|
|
555
632
|
configCwd?: string;
|
|
556
633
|
/** Called on tool start/end with activity info. */
|
|
@@ -752,6 +829,68 @@ function resolveConfiguredSessionDir(sessionDir: string | undefined, cwd: string
|
|
|
752
829
|
return resolve(cwd, sessionDir);
|
|
753
830
|
}
|
|
754
831
|
|
|
832
|
+
/**
|
|
833
|
+
* Give Pi a live, guarded view of an extension's tool_call handlers. Pi's
|
|
834
|
+
* public loader returns Extension objects whose handlers map is also the target
|
|
835
|
+
* of later pi.on() registrations. Copying that map once misses late handlers;
|
|
836
|
+
* replacing its raw arrays breaks pi.on()'s unsubscribe closure. Intercept
|
|
837
|
+
* reads instead, leaving registration/unsubscription and all metadata intact.
|
|
838
|
+
* Each read wraps the CURRENT callbacks before Pi takes its event snapshot.
|
|
839
|
+
*/
|
|
840
|
+
function guardExtensionToolCalls(
|
|
841
|
+
extension: Extension,
|
|
842
|
+
deadlines: WeakMap<object, number>,
|
|
843
|
+
): Extension {
|
|
844
|
+
type Handler = NonNullable<ReturnType<Extension["handlers"]["get"]>>[number];
|
|
845
|
+
const guarded = new WeakMap<Handler, Handler>();
|
|
846
|
+
const handlers = new Proxy(extension.handlers, {
|
|
847
|
+
get(target, key) {
|
|
848
|
+
if (key === "get") return (event: string) => {
|
|
849
|
+
const raw = target.get(event);
|
|
850
|
+
if (event !== "tool_call" || !raw) return raw;
|
|
851
|
+
return raw.map((handler) => {
|
|
852
|
+
let wrapper = guarded.get(handler);
|
|
853
|
+
if (!wrapper) {
|
|
854
|
+
wrapper = async (...args: unknown[]) => {
|
|
855
|
+
const call = args[0];
|
|
856
|
+
if (!call || typeof call !== "object") {
|
|
857
|
+
return { block: true, reason: "Invalid subagent tool_call event." };
|
|
858
|
+
}
|
|
859
|
+
const deadline = deadlines.get(call);
|
|
860
|
+
if (deadline === undefined) {
|
|
861
|
+
return { block: true, reason: "Subagent tool policy was bypassed." };
|
|
862
|
+
}
|
|
863
|
+
if (deadline === Infinity) return handler(...args);
|
|
864
|
+
const toolName = Reflect.get(call, "toolName");
|
|
865
|
+
const label = `tool_call for "${typeof toolName === "string" ? toolName : "unknown"}"`;
|
|
866
|
+
const remaining = Math.ceil(deadline - performance.now());
|
|
867
|
+
if (remaining <= 0) return { block: true, reason: `${label} timed out before the extension handler.` };
|
|
868
|
+
try {
|
|
869
|
+
// This race blocks the call; it cannot cancel side effects of
|
|
870
|
+
// a handler that keeps running after its Promise loses.
|
|
871
|
+
return await withTimeout(Promise.resolve().then(() => handler(...args)), remaining, label);
|
|
872
|
+
} catch (error) {
|
|
873
|
+
// Pi does not catch tool_call handler failures. A timeout must
|
|
874
|
+
// block the tool, not leave a rejected event or an awaited hook.
|
|
875
|
+
return { block: true, reason: error instanceof Error ? error.message : String(error) };
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
guarded.set(handler, wrapper);
|
|
879
|
+
}
|
|
880
|
+
return wrapper;
|
|
881
|
+
});
|
|
882
|
+
};
|
|
883
|
+
const value: unknown = Reflect.get(target, key, target);
|
|
884
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
885
|
+
},
|
|
886
|
+
});
|
|
887
|
+
return new Proxy(extension, {
|
|
888
|
+
get(target, key, receiver) {
|
|
889
|
+
return key === "handlers" ? handlers : Reflect.get(target, key, receiver);
|
|
890
|
+
},
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
755
894
|
export async function runAgent(
|
|
756
895
|
ctx: ExtensionContext,
|
|
757
896
|
type: SubagentType,
|
|
@@ -762,8 +901,33 @@ export async function runAgent(
|
|
|
762
901
|
// an already-cancelled caller. This matters for tool-call signals: the
|
|
763
902
|
// caller may be cancelled before this async function gets its first turn.
|
|
764
903
|
throwIfAborted(options.signal);
|
|
904
|
+
const parentProjectTrust = captureProjectTrust(ctx);
|
|
905
|
+
// Configuration provenance is established synchronously, before definition
|
|
906
|
+
// admission, model setup, callbacks, or any await. Execution cwd is not trust.
|
|
907
|
+
const effectiveCwd = options.cwd ?? ctx.cwd;
|
|
908
|
+
const configCwd = options.configCwd ?? effectiveCwd;
|
|
909
|
+
const projectTrust = childProjectTrust(parentProjectTrust, configCwd);
|
|
910
|
+
const projectTrusted = projectTrust.trusted;
|
|
911
|
+
const deniedProjectResource = (path: string): boolean =>
|
|
912
|
+
projectTrust.deniedRoots.some(root => isProjectResource(path, root));
|
|
913
|
+
const agentDir = getAgentDir();
|
|
914
|
+
// Native global configuration is resolved before resource-path filtering.
|
|
915
|
+
// Reject aliases into denied roots before the SDK parses their declarations;
|
|
916
|
+
// do not copy or reinterpret the host's settings/MCP schemas here.
|
|
917
|
+
for (const filename of ["settings.json", "mcp.json"]) {
|
|
918
|
+
if (deniedProjectResource(join(agentDir, filename))) {
|
|
919
|
+
throw new Error(`Project trust denied for global configuration source "${filename}".`);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
765
922
|
|
|
766
923
|
const loadedConfig = getConfig(type);
|
|
924
|
+
// The request's provenance matters, not just the requested extension path:
|
|
925
|
+
// a denied definition can request packages anywhere (including npm caches).
|
|
926
|
+
// getConfig carries the actual fallback definition's provenance as well.
|
|
927
|
+
if ((!parentProjectTrust.trusted && loadedConfig.source === "project") ||
|
|
928
|
+
(loadedConfig.sourcePath && deniedProjectResource(loadedConfig.sourcePath))) {
|
|
929
|
+
throw new Error(`Project trust denied for agent definition "${type}".`);
|
|
930
|
+
}
|
|
767
931
|
const internalOverride = options[INTERNAL_AGENT_CONFIG_OVERRIDE];
|
|
768
932
|
const loadedAgentConfig = getAgentConfig(type);
|
|
769
933
|
const config = internalOverride
|
|
@@ -796,6 +960,12 @@ export async function runAgent(
|
|
|
796
960
|
inheritContext: internalOverride.inheritContext,
|
|
797
961
|
}
|
|
798
962
|
: loadedAgentConfig;
|
|
963
|
+
// Reject raw parent-history inheritance before building context, loading
|
|
964
|
+
// extensions, creating a session, or dispatching a provider request.
|
|
965
|
+
assertNoRawInheritance(internalOverride?.inheritContext ?? options.inheritContext ?? agentConfig?.inheritContext);
|
|
966
|
+
// A missing parent bridge must fail before child resource discovery or session
|
|
967
|
+
// allocation. Re-check the selected model below once tier resolution finishes.
|
|
968
|
+
parentModelSessionOptions(ctx);
|
|
799
969
|
const parentPolicySnapshot = options[INTERNAL_PARENT_POLICY_SNAPSHOT];
|
|
800
970
|
const parentModel = parentPolicySnapshot ? parentPolicySnapshot.model : ctx.model;
|
|
801
971
|
const parentThinking = parentPolicySnapshot
|
|
@@ -840,12 +1010,6 @@ export async function runAgent(
|
|
|
840
1010
|
options.onAgentTierResolved?.(agentTierSnapshot, tierModelLabel);
|
|
841
1011
|
}
|
|
842
1012
|
|
|
843
|
-
// Resolve working directory: worktree override > parent cwd
|
|
844
|
-
const effectiveCwd = options.cwd ?? ctx.cwd;
|
|
845
|
-
// Filesystem work happens in effectiveCwd; config discovery in configCwd.
|
|
846
|
-
// They differ only for SpawnOptions.cwd spawns (config stays with the parent).
|
|
847
|
-
const configCwd = options.configCwd ?? effectiveCwd;
|
|
848
|
-
|
|
849
1013
|
const env = await detectEnv(options.pi, effectiveCwd);
|
|
850
1014
|
throwIfAborted(options.signal);
|
|
851
1015
|
|
|
@@ -868,7 +1032,7 @@ export async function runAgent(
|
|
|
868
1032
|
|
|
869
1033
|
// Skill preloading: when skills is string[], preload their content into prompt
|
|
870
1034
|
if (Array.isArray(skills)) {
|
|
871
|
-
const loaded = preloadSkills(skills, configCwd);
|
|
1035
|
+
const loaded = preloadSkills(skills, configCwd, { projectTrusted, deniedResource: deniedProjectResource });
|
|
872
1036
|
if (loaded.length > 0) {
|
|
873
1037
|
extras.skillBlocks = loaded;
|
|
874
1038
|
}
|
|
@@ -913,25 +1077,26 @@ export async function runAgent(
|
|
|
913
1077
|
systemPrompt = buildAgentPrompt({ ...fallback, name: type }, effectiveCwd, env, parentSystemPrompt, extras);
|
|
914
1078
|
}
|
|
915
1079
|
|
|
916
|
-
//
|
|
917
|
-
//
|
|
1080
|
+
// Named skills are preloaded into the prompt, not exposed through Pi's skill catalog.
|
|
1081
|
+
// noSkills only skips initial discovery; resources_discover can add extension
|
|
1082
|
+
// skills later, so the loader override must enforce this policy on every update.
|
|
918
1083
|
const noSkills = skills === false || Array.isArray(skills);
|
|
919
1084
|
|
|
920
|
-
const
|
|
1085
|
+
const settingsManager = SettingsManager.create(configCwd, agentDir, { projectTrusted });
|
|
921
1086
|
|
|
922
1087
|
// Extension loading:
|
|
923
1088
|
// - true → all default-discovered extensions
|
|
924
1089
|
// - false → none (noExtensions)
|
|
925
|
-
// - string[] →
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
//
|
|
1090
|
+
// - string[] → allowlist. Bare names keep matching default-discovered
|
|
1091
|
+
// extensions; path entries load explicitly; "*" keeps all defaults.
|
|
1092
|
+
// On Pi with replaceable built-ins, restrictive policies resolve paths
|
|
1093
|
+
// before loading, so excluded factories cannot replace a selected built-in.
|
|
929
1094
|
//
|
|
930
1095
|
// Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md — upstream's
|
|
931
1096
|
// buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
|
|
932
1097
|
// would defeat prompt_mode: replace and isolated: true. Parent context, if
|
|
933
|
-
//
|
|
934
|
-
// is
|
|
1098
|
+
// append-mode agents receive system instructions through systemPromptOverride;
|
|
1099
|
+
// raw parent conversation inheritance is unavailable on these Pi hosts.
|
|
935
1100
|
// `ext:` selectors from the `tools:` CSV narrow which extension tools surface to
|
|
936
1101
|
// the LLM. They do NOT control loading — `extensions:` is the sole authority for
|
|
937
1102
|
// which extensions load. `ext:foo` against an extension that `extensions:` excluded
|
|
@@ -946,42 +1111,190 @@ export async function runAgent(
|
|
|
946
1111
|
: undefined;
|
|
947
1112
|
const keepNames = extensionsSpec?.names ?? new Set<string>();
|
|
948
1113
|
// `exclude_extensions:` is a denylist applied AFTER the include set — exclude wins.
|
|
949
|
-
// Plain canonical names only (case-insensitive).
|
|
950
|
-
//
|
|
951
|
-
// suppresses handler binding and tool registration; it is not a sandbox.
|
|
1114
|
+
// Plain canonical names only (case-insensitive). Without preselection,
|
|
1115
|
+
// filtering is post-factory, not a sandbox for extensions that already loaded.
|
|
952
1116
|
const excludeNames = new Set((excludeExtensions ?? []).map((n) => n.toLowerCase()));
|
|
953
1117
|
const hasExcludes = excludeNames.size > 0;
|
|
954
|
-
// The override filters
|
|
955
|
-
//
|
|
956
|
-
//
|
|
1118
|
+
// The override filters without preselection, validates preselection when used,
|
|
1119
|
+
// and ALWAYS puts our tool policy first. Pi passes one mutable
|
|
1120
|
+
// tool_call event through handlers in extension order; if an earlier extension
|
|
1121
|
+
// changes event.toolName, a later gate would inspect a different name from the
|
|
1122
|
+
// one Pi actually executes. Ordering before untrusted handlers is essential.
|
|
957
1123
|
const loadAll = extensions === true || extensionsSpec?.wildcard === true;
|
|
958
|
-
const
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1124
|
+
const bareNames = new Set(Array.isArray(extensions)
|
|
1125
|
+
? extensions.filter((entry) => entry !== "*" && !entry.includes("/") && !entry.includes("\\") && !entry.startsWith("~"))
|
|
1126
|
+
.map((entry) => entry.toLowerCase())
|
|
1127
|
+
: []);
|
|
1128
|
+
// Pre-filter discovered set, captured by the override (or the resolver) — the
|
|
1129
|
+
// exclude-typo warning must compare against this, not the surviving set.
|
|
962
1130
|
let discoveredNames: Set<string> | undefined;
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1131
|
+
let prefilteredExtensionPaths: Set<string> | undefined;
|
|
1132
|
+
const resolvedExplicitNames = new Set<string>();
|
|
1133
|
+
const interactiveAskNames = new Set(ctx.hasUI ? (agentConfig?.askTools ?? []).map((name) => name.toLowerCase()) : []);
|
|
1134
|
+
const approvalTimer: ApprovalTimer = {
|
|
1135
|
+
isInteractiveAsk: (name) => interactiveAskNames.has(name.toLowerCase()),
|
|
1136
|
+
armApproved: () => {},
|
|
1137
|
+
};
|
|
1138
|
+
// The policy starts a single monotonic deadline for each public tool_call
|
|
1139
|
+
// event. All subsequently selected extension handlers share its remainder,
|
|
1140
|
+
// including calls initiated by another tool through ctx.executeTool().
|
|
1141
|
+
const toolCallDeadlines = new WeakMap<object, number>();
|
|
1142
|
+
const extensionsOverride = (base: LoadExtensionsResult): LoadExtensionsResult => {
|
|
1143
|
+
const policy = base.extensions.find((e) => e.path === `<inline:${TOOL_POLICY_EXTENSION}>`);
|
|
1144
|
+
if (!policy) throw new Error("Subagent tool policy extension failed to load.");
|
|
1145
|
+
let selected = base.extensions;
|
|
1146
|
+
if (prefilteredExtensionPaths) {
|
|
1147
|
+
// Fail closed if a host loads anything outside the pre-resolved policy.
|
|
1148
|
+
// Filtering it now would be too late to undo its factory side effects.
|
|
1149
|
+
const allowedPaths = prefilteredExtensionPaths;
|
|
1150
|
+
const unexpected = base.extensions.find((e) => e !== policy && !allowedPaths.has(e.path));
|
|
1151
|
+
if (unexpected) throw new Error(`Pi loaded unselected extension "${unexpected.path}" for agent "${type}".`);
|
|
1152
|
+
} else if (!noExtensions && (!loadAll || hasExcludes)) {
|
|
1153
|
+
discoveredNames = new Set(base.extensions.flatMap((e) => extensionCanonicalNames(e.path)));
|
|
1154
|
+
selected = base.extensions.filter((e) => {
|
|
1155
|
+
if (e === policy) return true; // never filter the child policy itself
|
|
1156
|
+
const canons = extensionCanonicalNames(e.path);
|
|
1157
|
+
if (canons.some((n) => excludeNames.has(n))) return false; // exclude wins
|
|
1158
|
+
return loadAll || canons.some((n) => keepNames.has(n));
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
return {
|
|
1162
|
+
...base,
|
|
1163
|
+
extensions: [policy, ...selected.filter((e) => e !== policy)
|
|
1164
|
+
.map((extension) => guardExtensionToolCalls(extension, toolCallDeadlines))],
|
|
1165
|
+
};
|
|
1166
|
+
};
|
|
1167
|
+
|
|
1168
|
+
// The public extension `tool_call` event covers BOTH model calls and nested
|
|
1169
|
+
// ctx.executeTool calls. A policy factory also loads with noExtensions: true,
|
|
1170
|
+
// without loading any discovered extension. Deny until the child policy is
|
|
1171
|
+
// installed, including calls an extension might make during session_start.
|
|
1172
|
+
let checkToolCall: (name: string, input: unknown, deadline: number) => Promise<ToolCallEventResult | undefined> =
|
|
1173
|
+
async () => ({ block: true, reason: "Subagent tool policy is not ready." });
|
|
1174
|
+
const extensionFactories: InlineExtension[] = [{
|
|
1175
|
+
name: TOOL_POLICY_EXTENSION,
|
|
1176
|
+
hidden: true,
|
|
1177
|
+
factory: (pi) => {
|
|
1178
|
+
pi.on("tool_call", async (event, hookCtx) => {
|
|
1179
|
+
if (hookCtx?.signal?.aborted) return { block: true, reason: "Subagent tool call was aborted." };
|
|
1180
|
+
const waitingForHuman = approvalTimer.isInteractiveAsk(event.toolName);
|
|
1181
|
+
// Human decision time is not tool execution time. The public event
|
|
1182
|
+
// arrives after tool_execution_start, so the normal abort timer also
|
|
1183
|
+
// waits until approval; only then do later handlers get a deadline.
|
|
1184
|
+
const deadline = !waitingForHuman && defaultToolTimeoutMs > 0
|
|
1185
|
+
? performance.now() + defaultToolTimeoutMs
|
|
1186
|
+
: Infinity;
|
|
1187
|
+
toolCallDeadlines.set(event, deadline);
|
|
1188
|
+
const decision = await checkToolCall(event.toolName, event.input, deadline);
|
|
1189
|
+
// Abort cannot interrupt an already-open human dialog on these hosts.
|
|
1190
|
+
// Do not execute a tool if that dialog eventually approves a stopped run.
|
|
1191
|
+
if (hookCtx?.signal?.aborted) return { block: true, reason: "Subagent tool call was aborted." };
|
|
1192
|
+
if (waitingForHuman && !decision?.block) {
|
|
1193
|
+
toolCallDeadlines.set(event, defaultToolTimeoutMs > 0
|
|
1194
|
+
? performance.now() + defaultToolTimeoutMs
|
|
1195
|
+
: Infinity);
|
|
1196
|
+
approvalTimer.armApproved(event.toolCallId, event.toolName);
|
|
1197
|
+
}
|
|
1198
|
+
return decision;
|
|
1199
|
+
});
|
|
1200
|
+
},
|
|
1201
|
+
}];
|
|
1202
|
+
// Public SDK factories are verified on Pi 1.0.4. Capability-check the
|
|
1203
|
+
// namespace rather than importing a potentially missing named export; this
|
|
1204
|
+
// retained fallback does not establish support for older host versions.
|
|
1205
|
+
if ("createCodemodeExtension" in PiCodingAgent && typeof PiCodingAgent.createCodemodeExtension === "function") {
|
|
1206
|
+
extensionFactories.push({ name: "codemode", builtin: true, replaceable: true, factory: PiCodingAgent.createCodemodeExtension() });
|
|
1207
|
+
}
|
|
1208
|
+
if ("createToolSearchExtension" in PiCodingAgent && typeof PiCodingAgent.createToolSearchExtension === "function") {
|
|
1209
|
+
extensionFactories.push({ name: "tool-search", builtin: true, replaceable: true, factory: PiCodingAgent.createToolSearchExtension() });
|
|
1210
|
+
}
|
|
1211
|
+
if ("createMcpExtension" in PiCodingAgent && typeof PiCodingAgent.createMcpExtension === "function") {
|
|
1212
|
+
extensionFactories.push({ name: "mcp", builtin: true, replaceable: true,
|
|
1213
|
+
factory: configurationMcpFactory(PiCodingAgent.createMcpExtension(), configCwd, settingsManager) });
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
for (const path of extensionsSpec?.paths ?? []) {
|
|
1217
|
+
if (deniedProjectResource(path)) {
|
|
1218
|
+
throw new Error(`Project extension source "${path}" requested by agent "${type}" is not trusted.`);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
let additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined;
|
|
1222
|
+
let settingsBeforeLoad: string | undefined;
|
|
1223
|
+
// Pi 0.99 resolves replacement conflicts BEFORE extensionsOverride. A rogue
|
|
1224
|
+
// discovered extension can register /mcp, replace builtin:mcp, and only then
|
|
1225
|
+
// get filtered out by our allowlist. Resolve configured resources through the
|
|
1226
|
+
// public package API first and feed only the selected paths into a loader with
|
|
1227
|
+
// discovery off. Without the built-in factory capability, retain the
|
|
1228
|
+
// post-load filtering fallback rather than assuming replacement behavior.
|
|
1229
|
+
const builtinNames = extensionFactories.filter((factory) => typeof factory !== "function" && factory.builtin)
|
|
1230
|
+
.map((factory) => factory.name);
|
|
1231
|
+
const prefilter = !noExtensions && (!loadAll || hasExcludes || !projectTrusted) && builtinNames.length > 0;
|
|
1232
|
+
if (prefilter) {
|
|
1233
|
+
await settingsManager.reload();
|
|
1234
|
+
throwIfAborted(options.signal);
|
|
1235
|
+
const packages = new DefaultPackageManager({ cwd: configCwd, agentDir, settingsManager, builtinExtensions: builtinNames });
|
|
1236
|
+
// As with the normal loader, resolve() may install configured packages, and
|
|
1237
|
+
// temporary sources may install a requested package. Do not read private Pi
|
|
1238
|
+
// state or replace its source-resolution rules with path heuristics.
|
|
1239
|
+
const configured = await packages.resolve();
|
|
1240
|
+
throwIfAborted(options.signal);
|
|
1241
|
+
const explicit = await packages.resolveExtensionSources(extensionsSpec?.paths ?? [], { temporary: true });
|
|
1242
|
+
throwIfAborted(options.signal);
|
|
1243
|
+
for (const source of extensionsSpec?.paths ?? []) {
|
|
1244
|
+
if (!existsSync(source) || !explicit.extensions.some((resource) => resource.enabled && resource.metadata.source === source)) {
|
|
1245
|
+
throw new Error(`Extension source "${source}" requested by agent "${type}" was not found or has no enabled extensions.`);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
settingsBeforeLoad = JSON.stringify([settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()]);
|
|
1249
|
+
const enabledConfigured = configured.extensions.filter((resource) => resource.enabled && !deniedProjectResource(resource.path)).map((resource) => resource.path);
|
|
1250
|
+
const enabledExplicit = explicit.extensions.filter((resource) => resource.enabled);
|
|
1251
|
+
for (const resource of enabledExplicit) {
|
|
1252
|
+
if (deniedProjectResource(resource.path)) {
|
|
1253
|
+
throw new Error(`Project extension "${resource.path}" requested by agent "${type}" is not trusted.`);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
discoveredNames = new Set([...enabledConfigured, ...enabledExplicit.map((resource) => resource.path)].flatMap(extensionCanonicalNames));
|
|
1257
|
+
const selectedExplicit = enabledExplicit.filter((resource) =>
|
|
1258
|
+
!extensionCanonicalNames(resource.path).some((name) => excludeNames.has(name)));
|
|
1259
|
+
const explicitPaths = new Set(selectedExplicit.map((resource) => resource.path));
|
|
1260
|
+
// For priority, use the package's declared name rather than a generic entry
|
|
1261
|
+
// alias like "src"; unrelated packages often have src/index.ts too.
|
|
1262
|
+
const explicitNames = new Set(selectedExplicit.map((resource) =>
|
|
1263
|
+
extensionPackageName(resource.path) ?? extensionCanonicalName(resource.path)));
|
|
1264
|
+
for (const source of extensionsSpec?.paths ?? []) {
|
|
1265
|
+
if (selectedExplicit.some((resource) => resource.metadata.source === source)) {
|
|
1266
|
+
resolvedExplicitNames.add(extensionCanonicalName(source));
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
const selectedPaths = [
|
|
1270
|
+
// The loader normally loads temporary paths before discovered paths. Keep
|
|
1271
|
+
// that priority (including for directory/package paths with many entries).
|
|
1272
|
+
...selectedExplicit.map((resource) => resource.path),
|
|
1273
|
+
...enabledConfigured.filter((path) => {
|
|
1274
|
+
const names = extensionCanonicalNames(path);
|
|
1275
|
+
if (names.some((name) => excludeNames.has(name))) return false;
|
|
1276
|
+
if (explicitPaths.has(path) || explicitNames.has(extensionPackageName(path) ?? extensionCanonicalName(path))) return false;
|
|
1277
|
+
return loadAll || names.some((name) => bareNames.has(name));
|
|
1278
|
+
}),
|
|
1279
|
+
];
|
|
1280
|
+
prefilteredExtensionPaths = new Set(selectedPaths);
|
|
1281
|
+
additionalExtensionPaths = [...prefilteredExtensionPaths];
|
|
1282
|
+
}
|
|
977
1283
|
|
|
978
1284
|
const loader = new DefaultResourceLoader({
|
|
979
1285
|
cwd: configCwd,
|
|
980
1286
|
agentDir,
|
|
981
|
-
|
|
1287
|
+
settingsManager,
|
|
1288
|
+
noExtensions: noExtensions || prefilter,
|
|
982
1289
|
additionalExtensionPaths,
|
|
983
1290
|
extensionsOverride,
|
|
1291
|
+
extensionFactories,
|
|
984
1292
|
noSkills,
|
|
1293
|
+
skillsOverride: noSkills
|
|
1294
|
+
? () => ({ skills: [], diagnostics: [] })
|
|
1295
|
+
: !projectTrusted
|
|
1296
|
+
? (base) => ({ ...base, skills: base.skills.filter((skill) => !deniedProjectResource(skill.filePath)) })
|
|
1297
|
+
: undefined,
|
|
985
1298
|
noPromptTemplates: true,
|
|
986
1299
|
noThemes: true,
|
|
987
1300
|
noContextFiles: true,
|
|
@@ -990,6 +1303,18 @@ export async function runAgent(
|
|
|
990
1303
|
});
|
|
991
1304
|
await runInChildSessionContext(() => loader.reload());
|
|
992
1305
|
throwIfAborted(options.signal);
|
|
1306
|
+
if (prefilteredExtensionPaths) {
|
|
1307
|
+
if (settingsBeforeLoad !== JSON.stringify([settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()])) {
|
|
1308
|
+
throw new Error(`Pi extension settings changed while loading agent "${type}"; retry with the current settings.`);
|
|
1309
|
+
}
|
|
1310
|
+
const loadedPaths = new Set(loader.getExtensions().extensions.map((extension) => extension.path));
|
|
1311
|
+
for (const path of prefilteredExtensionPaths) {
|
|
1312
|
+
if (!loadedPaths.has(path)) {
|
|
1313
|
+
const diagnostic = loader.getExtensions().errors.find((error) => error.path === path);
|
|
1314
|
+
throw new Error(`Selected extension "${path}" was not loaded for agent "${type}"${diagnostic ? `: ${diagnostic.error}` : " (possibly replaced by another extension)"}.`);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
993
1318
|
|
|
994
1319
|
// Plain entries in `tools:` are expected to be built-in names (extension tools
|
|
995
1320
|
// go through `ext:`), so an unknown name there is unambiguously a typo. Previously
|
|
@@ -1011,11 +1336,13 @@ export async function runAgent(
|
|
|
1011
1336
|
}
|
|
1012
1337
|
}
|
|
1013
1338
|
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1016
|
-
//
|
|
1017
|
-
//
|
|
1018
|
-
//
|
|
1339
|
+
// Bare `extensions:` names and `ext:` tool selectors warn when unmatched.
|
|
1340
|
+
// On prefiltered hosts, missing explicit paths and failed selected factories
|
|
1341
|
+
// already raised above instead of silently losing a requested capability.
|
|
1342
|
+
// Two remaining misconfigurations to catch:
|
|
1343
|
+
// - `extensions: [foo]` but no extension named foo was discovered (typo;
|
|
1344
|
+
// without preselection path entries also fold their canonical name into
|
|
1345
|
+
// `keepNames`, so a failed path load is warned here).
|
|
1019
1346
|
// - `tools: ext:foo` but foo isn't in the loaded set (because `extensions:`
|
|
1020
1347
|
// didn't include it). Since v0.9, `ext:` no longer pulls extensions in;
|
|
1021
1348
|
// loading is `extensions:`-authoritative.
|
|
@@ -1045,6 +1372,10 @@ export async function runAgent(
|
|
|
1045
1372
|
loader.getExtensions().extensions.flatMap((e) => extensionCanonicalNames(e.path)),
|
|
1046
1373
|
);
|
|
1047
1374
|
for (const name of keepNames) {
|
|
1375
|
+
// A directory/package source can resolve to entries with different
|
|
1376
|
+
// canonical names; its resolved paths were checked above, not by the
|
|
1377
|
+
// basename of the requested directory.
|
|
1378
|
+
if (prefilteredExtensionPaths && resolvedExplicitNames.has(name) && !bareNames.has(name)) continue;
|
|
1048
1379
|
if (!survivingNames.has(name)) {
|
|
1049
1380
|
options.onToolActivity?.({
|
|
1050
1381
|
type: "end",
|
|
@@ -1077,7 +1408,8 @@ export async function runAgent(
|
|
|
1077
1408
|
const { configuredModel, source } = agentTierSnapshot;
|
|
1078
1409
|
const scopeVerdict = checkModelScope({
|
|
1079
1410
|
model,
|
|
1080
|
-
cwd:
|
|
1411
|
+
cwd: projectTrust.cwd,
|
|
1412
|
+
authority: projectTrust,
|
|
1081
1413
|
modelRegistry: ctx.modelRegistry,
|
|
1082
1414
|
// scopeModels polices a tier chosen per dispatch, by the host model or by
|
|
1083
1415
|
// a workflow script alike; a tier from the agent file or the configured
|
|
@@ -1089,6 +1421,7 @@ export async function runAgent(
|
|
|
1089
1421
|
if (scopeVerdict.kind === "error") throw new Error(scopeVerdict.message);
|
|
1090
1422
|
if (scopeVerdict.kind === "warn" && ctx.hasUI) ctx.ui.notify(scopeVerdict.message, "warning");
|
|
1091
1423
|
}
|
|
1424
|
+
const parentModels = parentModelSessionOptions(ctx, model);
|
|
1092
1425
|
const disallowedSet = (() => {
|
|
1093
1426
|
const names = [...(agentConfig?.disallowedTools ?? []), ...(options.excludeTools ?? [])];
|
|
1094
1427
|
return names.length > 0 ? new Set(names) : undefined;
|
|
@@ -1115,6 +1448,7 @@ export async function runAgent(
|
|
|
1115
1448
|
maxSubagentDepth: effectiveMaxDepth,
|
|
1116
1449
|
allowedSubagents: agentConfig.allowedSubagents,
|
|
1117
1450
|
configCwd,
|
|
1451
|
+
projectTrust,
|
|
1118
1452
|
})
|
|
1119
1453
|
: [];
|
|
1120
1454
|
// `contact_supervisor` is injected separately from the nested tools and under
|
|
@@ -1139,6 +1473,11 @@ export async function runAgent(
|
|
|
1139
1473
|
// the scoping pass below keeps exactly the names it is given.
|
|
1140
1474
|
const injectedTools = [...nestedTools, ...supervisorTools];
|
|
1141
1475
|
const nestedToolNames = new Set(injectedTools.map(tool => tool.name));
|
|
1476
|
+
const askGate = createAskGate({
|
|
1477
|
+
askTools: agentConfig?.askTools ?? [],
|
|
1478
|
+
agentLabel: agentConfig?.displayName ?? type,
|
|
1479
|
+
...(ctx.hasUI ? { confirm: (title: string, message: string) => ctx.ui.confirm(title, message) } : {}),
|
|
1480
|
+
});
|
|
1142
1481
|
|
|
1143
1482
|
// ─── Tool scoping ───────────────────────────────────────────────────────
|
|
1144
1483
|
//
|
|
@@ -1203,7 +1542,6 @@ export async function runAgent(
|
|
|
1203
1542
|
// allowlist. Concrete tool availability remains owned by the agent config.
|
|
1204
1543
|
}
|
|
1205
1544
|
|
|
1206
|
-
const settingsManager = SettingsManager.create(configCwd, agentDir);
|
|
1207
1545
|
const configuredSessionDir = resolveConfiguredSessionDir(agentConfig?.sessionDir, effectiveCwd);
|
|
1208
1546
|
const defaultSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR ?? settingsManager.getSessionDir?.();
|
|
1209
1547
|
// Frontmatter wins when it says anything; otherwise the project default,
|
|
@@ -1226,21 +1564,18 @@ export async function runAgent(
|
|
|
1226
1564
|
? SessionManager.create(effectiveCwd, configuredSessionDir ?? defaultSessionDir, { parentSession })
|
|
1227
1565
|
: SessionManager.create(effectiveCwd, configuredSessionDir ?? defaultSessionDir)
|
|
1228
1566
|
: SessionManager.inMemory(effectiveCwd);
|
|
1567
|
+
if (options.resumeSessionFile) {
|
|
1568
|
+
assertSafeChildSession({ messages: [], sessionManager });
|
|
1569
|
+
}
|
|
1229
1570
|
|
|
1230
|
-
// Pi 0.80.8 replaced createAgentSession's modelRegistry option with
|
|
1231
|
-
// modelRuntime, but ExtensionContext still exposes only the registry facade.
|
|
1232
|
-
// Pass both so the full supported Pi range retains the parent's providers.
|
|
1233
|
-
const parentModelRuntime = (ctx.modelRegistry as unknown as { runtime?: ModelRuntime }).runtime;
|
|
1234
1571
|
const sessionOpts: Parameters<typeof createAgentSession>[0] & {
|
|
1235
1572
|
modelRegistry: ExtensionContext["modelRegistry"];
|
|
1236
|
-
modelRuntime?: ModelRuntime;
|
|
1237
1573
|
} = {
|
|
1238
1574
|
cwd: effectiveCwd,
|
|
1239
1575
|
agentDir,
|
|
1240
1576
|
sessionManager,
|
|
1241
1577
|
settingsManager,
|
|
1242
|
-
|
|
1243
|
-
...(parentModelRuntime !== undefined && { modelRuntime: parentModelRuntime }),
|
|
1578
|
+
...parentModels,
|
|
1244
1579
|
model,
|
|
1245
1580
|
tools: sessionTools,
|
|
1246
1581
|
customTools: injectedTools,
|
|
@@ -1254,6 +1589,7 @@ export async function runAgent(
|
|
|
1254
1589
|
}
|
|
1255
1590
|
|
|
1256
1591
|
const { session } = await runInChildSessionContext(() => createAgentSession(sessionOpts));
|
|
1592
|
+
approvalTimers.set(session, approvalTimer);
|
|
1257
1593
|
// Install the forwarding listener immediately after session creation, before
|
|
1258
1594
|
// bindExtensions/session_start can do asynchronous work. The old placement
|
|
1259
1595
|
// just before prompt missed an abort during extension activation.
|
|
@@ -1263,6 +1599,27 @@ export async function runAgent(
|
|
|
1263
1599
|
try {
|
|
1264
1600
|
throwIfAborted(options.signal);
|
|
1265
1601
|
|
|
1602
|
+
const inScope = noExtensions ? undefined : extensionToolNamesInScope(session, {
|
|
1603
|
+
loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames,
|
|
1604
|
+
});
|
|
1605
|
+
checkToolCall = async (name, input, deadline) => {
|
|
1606
|
+
const run = async (): Promise<ToolCallEventResult | undefined> => {
|
|
1607
|
+
// Scope first, then approval: never prompt for a tool this child cannot use.
|
|
1608
|
+
if (inScope && !inScope().has(name)) {
|
|
1609
|
+
return { block: true, reason: `Tool "${name}" is not available to this subagent.` };
|
|
1610
|
+
}
|
|
1611
|
+
return askGate?.(name, input);
|
|
1612
|
+
};
|
|
1613
|
+
if (deadline === Infinity) return run();
|
|
1614
|
+
const remaining = deadline - performance.now();
|
|
1615
|
+
if (remaining <= 0) return { block: true, reason: `tool_call for "${name}" timed out` };
|
|
1616
|
+
try {
|
|
1617
|
+
return await withTimeout(run(), remaining, `tool_call for "${name}"`);
|
|
1618
|
+
} catch (err) {
|
|
1619
|
+
return { block: true, reason: (err as Error).message };
|
|
1620
|
+
}
|
|
1621
|
+
};
|
|
1622
|
+
|
|
1266
1623
|
const baseSessionName = options.thread ? `workflow-thread:${options.thread}` : (agentConfig?.name ?? type);
|
|
1267
1624
|
session.setSessionName(
|
|
1268
1625
|
options.agentId && !options.thread ? `${baseSessionName}#${options.agentId.slice(0, 8)}` : baseSessionName,
|
|
@@ -1282,50 +1639,13 @@ export async function runAgent(
|
|
|
1282
1639
|
});
|
|
1283
1640
|
throwIfAborted(options.signal);
|
|
1284
1641
|
|
|
1285
|
-
// With `allowedToolNames` unset, the
|
|
1286
|
-
//
|
|
1287
|
-
//
|
|
1288
|
-
//
|
|
1289
|
-
//
|
|
1290
|
-
//
|
|
1291
|
-
|
|
1292
|
-
// exist — so it applies to isolated agents too, where the scope installer
|
|
1293
|
-
// below never runs because the registry is already statically allowlisted.
|
|
1294
|
-
const askGate = createAskGate({
|
|
1295
|
-
askTools: agentConfig?.askTools ?? [],
|
|
1296
|
-
agentLabel: agentConfig?.displayName ?? type,
|
|
1297
|
-
...(ctx.hasUI ? { confirm: (title: string, message: string) => ctx.ui.confirm(title, message) } : {}),
|
|
1298
|
-
});
|
|
1299
|
-
|
|
1300
|
-
if (!noExtensions) {
|
|
1301
|
-
installExtensionToolScope(session, {
|
|
1302
|
-
loader,
|
|
1303
|
-
toolNames,
|
|
1304
|
-
disallowedSet,
|
|
1305
|
-
extNames,
|
|
1306
|
-
narrowing,
|
|
1307
|
-
nestedToolNames,
|
|
1308
|
-
...(askGate ? { askGate } : {}),
|
|
1309
|
-
});
|
|
1310
|
-
} else if (askGate) {
|
|
1311
|
-
// Same hook, without the scope check the allowlist already performed.
|
|
1312
|
-
const priorBeforeToolCall = session.agent.beforeToolCall;
|
|
1313
|
-
session.agent.beforeToolCall = async (context, signal) => {
|
|
1314
|
-
const run = async () => {
|
|
1315
|
-
const gated = await askGate(context.toolCall.name, context.args);
|
|
1316
|
-
if (gated) return gated;
|
|
1317
|
-
return priorBeforeToolCall?.(context, signal);
|
|
1318
|
-
};
|
|
1319
|
-
if (defaultToolTimeoutMs > 0) {
|
|
1320
|
-
try {
|
|
1321
|
-
return await withTimeout(run(), defaultToolTimeoutMs, `beforeToolCall for "${context.toolCall.name}"`);
|
|
1322
|
-
} catch (err) {
|
|
1323
|
-
return { block: true, reason: (err as Error).message };
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
|
-
return run();
|
|
1327
|
-
};
|
|
1328
|
-
}
|
|
1642
|
+
// With `allowedToolNames` unset, the ACTIVE set still needs managing: Pi
|
|
1643
|
+
// activates default built-ins and eligible extension tools at registration.
|
|
1644
|
+
// The same live inScope
|
|
1645
|
+
// predicate drives the public tool_call handler (also for nested calls) and
|
|
1646
|
+
// active-set narrowing. Under extensions: false the static registry allowlist
|
|
1647
|
+
// handles scope; the tool_call handler still enforces ask_tools.
|
|
1648
|
+
if (inScope) installExtensionToolScope(session, inScope);
|
|
1329
1649
|
|
|
1330
1650
|
if (options.onSessionCreated) {
|
|
1331
1651
|
// Mark ownership before invoking the callback: manager wrappers assign the
|
|
@@ -1391,30 +1711,8 @@ export async function runAgent(
|
|
|
1391
1711
|
// timeout the session is aborted — which propagates via the tool's
|
|
1392
1712
|
// AbortSignal into the hanging execute() and surfaces as an error result.
|
|
1393
1713
|
// `contact_supervisor` is excluded: it intentionally waits for a human.
|
|
1394
|
-
const
|
|
1395
|
-
|
|
1396
|
-
const handle = pendingToolTimeouts.get(toolCallId);
|
|
1397
|
-
if (handle) {
|
|
1398
|
-
clearTimeout(handle);
|
|
1399
|
-
pendingToolTimeouts.delete(toolCallId);
|
|
1400
|
-
}
|
|
1401
|
-
};
|
|
1402
|
-
const armToolTimeout = (toolCallId: string, toolName: string): void => {
|
|
1403
|
-
if (defaultToolTimeoutMs <= 0) return;
|
|
1404
|
-
if (toolName === "contact_supervisor") return;
|
|
1405
|
-
const handle = setTimeout(() => {
|
|
1406
|
-
pendingToolTimeouts.delete(toolCallId);
|
|
1407
|
-
try {
|
|
1408
|
-
session.abort();
|
|
1409
|
-
} catch {
|
|
1410
|
-
// The session may already be torn down by the time this fires; the
|
|
1411
|
-
// timeout's only job is to stop a hung tool, and there is nothing
|
|
1412
|
-
// left to stop.
|
|
1413
|
-
}
|
|
1414
|
-
}, defaultToolTimeoutMs);
|
|
1415
|
-
handle.unref?.();
|
|
1416
|
-
pendingToolTimeouts.set(toolCallId, handle);
|
|
1417
|
-
};
|
|
1714
|
+
const toolTimeouts = createToolTimeouts(session, noExtensions ? 0 : TOOL_CALL_ABORT_GRACE_MS, approvalTimer.isInteractiveAsk);
|
|
1715
|
+
approvalTimer.armApproved = toolTimeouts.armApproved;
|
|
1418
1716
|
// Own finalized output before observer dispatch: a synchronous callback
|
|
1419
1717
|
// failure must remain visible without erasing the answer it interrupted.
|
|
1420
1718
|
const collector = collectResponseText(session);
|
|
@@ -1441,10 +1739,10 @@ export async function runAgent(
|
|
|
1441
1739
|
}
|
|
1442
1740
|
if (event.type === "tool_execution_start") {
|
|
1443
1741
|
options.onToolActivity?.({ type: "start", toolName: event.toolName });
|
|
1444
|
-
|
|
1742
|
+
toolTimeouts.start(event);
|
|
1445
1743
|
}
|
|
1446
1744
|
if (event.type === "tool_execution_end") {
|
|
1447
|
-
|
|
1745
|
+
toolTimeouts.end(event);
|
|
1448
1746
|
options.onToolActivity?.({ type: "end", toolName: event.toolName });
|
|
1449
1747
|
budgetToolCalls++;
|
|
1450
1748
|
toolSoftReached = applyBudget(budgetToolCalls, toolCallBudget, toolSoftReached, "tool call");
|
|
@@ -1468,24 +1766,14 @@ export async function runAgent(
|
|
|
1468
1766
|
}
|
|
1469
1767
|
});
|
|
1470
1768
|
|
|
1471
|
-
// Build the effective prompt: optionally prepend parent context
|
|
1472
|
-
let effectivePrompt = prompt;
|
|
1473
|
-
const inheritContext = internalOverride?.inheritContext ?? options.inheritContext;
|
|
1474
|
-
if (inheritContext) {
|
|
1475
|
-
const parentContext = buildParentContext(ctx);
|
|
1476
|
-
if (parentContext) {
|
|
1477
|
-
effectivePrompt = parentContext + prompt;
|
|
1478
|
-
}
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
1769
|
try {
|
|
1482
1770
|
throwIfAborted(options.signal);
|
|
1483
|
-
await session.prompt(
|
|
1771
|
+
await session.prompt(prompt);
|
|
1484
1772
|
} finally {
|
|
1773
|
+
approvalTimer.armApproved = () => {};
|
|
1485
1774
|
unsubTurns();
|
|
1486
1775
|
collector.unsubscribe();
|
|
1487
|
-
|
|
1488
|
-
pendingToolTimeouts.clear();
|
|
1776
|
+
toolTimeouts.dispose();
|
|
1489
1777
|
}
|
|
1490
1778
|
|
|
1491
1779
|
const baseText = collector.getText();
|
|
@@ -1527,6 +1815,7 @@ export async function resumeAgent(
|
|
|
1527
1815
|
} = {},
|
|
1528
1816
|
): Promise<{ text: string; failure?: string }> {
|
|
1529
1817
|
throwIfAborted(options.signal);
|
|
1818
|
+
assertSafeChildSession(session);
|
|
1530
1819
|
const collector = collectResponseText(session);
|
|
1531
1820
|
const cleanupAbort = forwardAbortSignal(session, options.signal);
|
|
1532
1821
|
|
|
@@ -1547,46 +1836,27 @@ export async function resumeAgent(
|
|
|
1547
1836
|
}
|
|
1548
1837
|
})
|
|
1549
1838
|
: () => {};
|
|
1550
|
-
//
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
session.abort();
|
|
1562
|
-
} catch {
|
|
1563
|
-
// The session may already be torn down by the time this fires; the
|
|
1564
|
-
// timeout's only job is to stop a hung tool, and there is nothing
|
|
1565
|
-
// left to stop.
|
|
1566
|
-
}
|
|
1567
|
-
}, defaultToolTimeoutMs);
|
|
1568
|
-
handle.unref?.();
|
|
1569
|
-
resumePendingTimeouts.set(id, handle);
|
|
1570
|
-
} else if (event.type === "tool_execution_end") {
|
|
1571
|
-
const id = (event as { toolCallId: string }).toolCallId;
|
|
1572
|
-
const handle = resumePendingTimeouts.get(id);
|
|
1573
|
-
if (handle) {
|
|
1574
|
-
clearTimeout(handle);
|
|
1575
|
-
resumePendingTimeouts.delete(id);
|
|
1576
|
-
}
|
|
1577
|
-
}
|
|
1578
|
-
})
|
|
1579
|
-
: () => {};
|
|
1839
|
+
// The child policy and its consent gate survive resumed turns. Replace the
|
|
1840
|
+
// original prompt's timer callback so a delayed approval cannot arm a stale
|
|
1841
|
+
// timer that the first prompt no longer clears.
|
|
1842
|
+
const approvalTimer = approvalTimers.get(session);
|
|
1843
|
+
const toolTimeouts = createToolTimeouts(session, TOOL_CALL_ABORT_GRACE_MS,
|
|
1844
|
+
approvalTimer?.isInteractiveAsk ?? (() => false));
|
|
1845
|
+
if (approvalTimer) approvalTimer.armApproved = toolTimeouts.armApproved;
|
|
1846
|
+
const unsubResumeTimeout = session.subscribe((event: AgentSessionEvent) => {
|
|
1847
|
+
if (event.type === "tool_execution_start") toolTimeouts.start(event);
|
|
1848
|
+
else if (event.type === "tool_execution_end") toolTimeouts.end(event);
|
|
1849
|
+
});
|
|
1580
1850
|
|
|
1581
1851
|
try {
|
|
1582
1852
|
throwIfAborted(options.signal);
|
|
1583
1853
|
await session.prompt(prompt);
|
|
1584
1854
|
} finally {
|
|
1855
|
+
if (approvalTimer) approvalTimer.armApproved = () => {};
|
|
1585
1856
|
collector.unsubscribe();
|
|
1586
1857
|
unsubEvents();
|
|
1587
1858
|
unsubResumeTimeout();
|
|
1588
|
-
|
|
1589
|
-
resumePendingTimeouts.clear();
|
|
1859
|
+
toolTimeouts.dispose();
|
|
1590
1860
|
cleanupAbort();
|
|
1591
1861
|
}
|
|
1592
1862
|
|