@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.
@@ -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 { buildParentContext, extractText } from "./context.js";
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. `registerTool` writes into the very `extension.tools` maps this reads,
223
- * so `inScope()` sees late arrivals on the next call.
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
- * Two enforcement points, because neither covers the whole picture:
226
- *
227
- * - `turn_end` re-narrows the ACTIVE set. pi emits `turn_end` immediately before
228
- * `prepareNextTurn` re-snapshots `agent.state.tools`, and session listeners run
229
- * synchronously, so the narrow lands in time for turns 2..N.
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
- export function installExtensionToolScope(
243
- session: AgentSession,
244
- ctx: {
245
- loader: DefaultResourceLoader;
246
- toolNames: string[];
247
- disallowedSet: Set<string> | undefined;
248
- extNames: Set<string>;
249
- narrowing: Map<string, Set<string>>;
250
- /** Opt-in nested-delegation tool names to keep active despite the EXCLUDED strip. */
251
- nestedToolNames: Set<string>;
252
- /** Per-call approval gate from `ask_tools:`, when the agent declares any. */
253
- askGate?: (toolName: string, input: unknown) => Promise<{ block: true; reason: string } | undefined>;
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
- for (const extension of loader.getExtensions().extensions) {
266
- const canons = extensionCanonicalNames(extension.path);
267
- if (optInActive && !canons.some((c) => extNames.has(c))) continue;
268
- // First alias that carries a narrowing set — a user won't narrow one
269
- // extension under two different names, so first-match is correct.
270
- const narrowed = canons.map((c) => narrowing.get(c)).find(Boolean);
271
- for (const name of extension.tools.keys()) {
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
- if (disallowedSet?.has(name)) continue;
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 next = session.getAllTools().map((t) => t.name).filter((n) => allowed.has(n));
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((n, i) => n !== current[i])) {
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
- // Activate what registered during session_start (eager MCP servers); pi would
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 as Parameters<ExtensionAPI["exec"]>[2]);
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
- * omitting it makes the target's `.pi` extensions execute in this process.
552
- * (Worktree isolation is the one intentional exception: its copy IS the
553
- * parent's repo, so config resolving inside it is correct.)
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
- // When skills is string[], we've already preloaded them into the prompt.
917
- // Still pass noSkills: true since we don't need the skill loader to load them again.
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 agentDir = getAgentDir();
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[] → loader-level allowlist. Bare names keep the matching
926
- // default-discovered extension; path entries load that extension fresh;
927
- // "*" keeps all default-discovered extensions. Excluded extensions never
928
- // bind handlers or register tools (their factory still runs once).
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
- // wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt
934
- // is embedded in systemPromptOverride) or inherit_context (conversation).
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). Note: excluded extensions'
950
- // factories still run once during reload() (see comment above) — exclusion
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 loaded extensions down to `keepNames` minus `excludeNames`.
955
- // It's only needed when we're neither loading everything without excludes
956
- // (`extensions: true` or a `"*"` wildcard) nor nothing (`noExtensions`).
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 additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined;
959
- // Pre-filter discovered set, captured by the override — the exclude-typo warning
960
- // must compare against this, not the surviving set (absence from survivors is
961
- // an exclude *succeeding*).
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
- const extensionsOverride: ((base: LoadExtensionsResult) => LoadExtensionsResult) | undefined =
964
- noExtensions || (loadAll && !hasExcludes)
965
- ? undefined
966
- : (base) => {
967
- discoveredNames = new Set(base.extensions.flatMap((e) => extensionCanonicalNames(e.path)));
968
- return {
969
- ...base,
970
- extensions: base.extensions.filter((e) => {
971
- const canons = extensionCanonicalNames(e.path);
972
- if (canons.some((n) => excludeNames.has(n))) return false; // exclude wins
973
- return loadAll || canons.some((n) => keepNames.has(n));
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
- noExtensions,
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
- // A subagent spawns mid-task, so a bad `extensions:`/`ext:` entry warns rather
1015
- // than aborts. Two distinct misconfigurations to catch:
1016
- // - `extensions: [foo]` but no extension named foo was discovered (typo or
1017
- // path that failed to load — path entries fold their canonical name into
1018
- // `keepNames`, so this covers them too).
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: ctx.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
- modelRegistry: ctx.modelRegistry,
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 registry is scoped by `excludeTools` but
1286
- // the ACTIVE set still needs managing: pi activates only its four default
1287
- // built-ins at turn 1, and `ext:` narrowing has no registry-level expression
1288
- // (we can't deny the name of a tool that hasn't registered yet). Both are
1289
- // handled below by re-deriving scope from the loader's live extension maps —
1290
- // `registerTool` writes into those same maps, so late arrivals are judged too.
1291
- // `ask_tools:` gates individual CALLS, which is orthogonal to which tools
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 pendingToolTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
1395
- const clearToolTimeout = (toolCallId: string): void => {
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
- armToolTimeout((event as { toolCallId: string }).toolCallId, event.toolName);
1742
+ toolTimeouts.start(event);
1445
1743
  }
1446
1744
  if (event.type === "tool_execution_end") {
1447
- clearToolTimeout((event as { toolCallId: string }).toolCallId);
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(effectivePrompt);
1771
+ await session.prompt(prompt);
1484
1772
  } finally {
1773
+ approvalTimer.armApproved = () => {};
1485
1774
  unsubTurns();
1486
1775
  collector.unsubscribe();
1487
- for (const handle of pendingToolTimeouts.values()) clearTimeout(handle);
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
- // Per-tool timeout for resume as well — same stuck-command fix as runAgent.
1551
- const resumePendingTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
1552
- const unsubResumeTimeout =
1553
- defaultToolTimeoutMs > 0
1554
- ? session.subscribe((event: AgentSessionEvent) => {
1555
- if (event.type === "tool_execution_start") {
1556
- const id = (event as { toolCallId: string }).toolCallId;
1557
- if (event.toolName === "contact_supervisor") return;
1558
- const handle = setTimeout(() => {
1559
- resumePendingTimeouts.delete(id);
1560
- try {
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
- for (const handle of resumePendingTimeouts.values()) clearTimeout(handle);
1589
- resumePendingTimeouts.clear();
1859
+ toolTimeouts.dispose();
1590
1860
  cleanupAbort();
1591
1861
  }
1592
1862