@cad0p/pi-subagents-tintinweb 0.14.2 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,26 +5,34 @@
5
5
  import { readFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
8
- import type { Model } from "@earendil-works/pi-ai";
9
- import type { ExtensionContext, LoadExtensionsResult } from "@earendil-works/pi-coding-agent";
8
+ import type { AssistantMessage, Model } from "@earendil-works/pi-ai";
9
+ import type { ExtensionContext, LoadExtensionsResult, ScopedModel, ToolDefinition } from "@earendil-works/pi-coding-agent";
10
10
  import {
11
11
  type AgentSession,
12
12
  type AgentSessionEvent,
13
13
  createAgentSession,
14
+ createCodemodeExtension,
15
+ createMcpExtension,
16
+ createToolSearchExtension,
14
17
  DefaultResourceLoader,
15
18
  type ExtensionAPI,
16
19
  getAgentDir,
20
+ type InlineExtension,
17
21
  SessionManager,
18
22
  SettingsManager,
19
23
  } from "@earendil-works/pi-coding-agent";
20
- import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
24
+ import { BUILTIN_TOOL_NAMES, getAgentConfig, getBuiltinToolSnippets, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
25
+ import { runInChildSessionContext } from "./child-context.js";
21
26
  import { buildParentContext, extractText } from "./context.js";
22
27
  import { DEFAULT_AGENTS } from "./default-agents.js";
23
28
  import { detectEnv } from "./env.js";
24
29
  import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
25
- import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
30
+ import { type ModelRegistry as ModelLookupRegistry, resolveModel } from "./model-resolver.js";
31
+ import { createNestedSubagentTools, getMaxSubagentDepth, type NestedAgentManager } from "./nested-tools.js";
32
+ import { buildAgentPrompt, type PromptExtras, retargetToolsSection } from "./prompts.js";
26
33
  import { preloadSkills } from "./skill-loader.js";
27
- import type { SubagentType, ThinkingLevel } from "./types.js";
34
+ import type { AgentConfig, SubagentType, ThinkingLevel } from "./types.js";
35
+ import type { LifetimeUsage } from "./usage.js";
28
36
 
29
37
  /**
30
38
  * Tool names registered by THIS extension. Single source of truth so the
@@ -76,6 +84,8 @@ export function extensionCanonicalName(extPath: string): string {
76
84
  * every co-located file to `pi-subagents-tintinweb`.
77
85
  */
78
86
  function extensionPackageName(extPath: string): string | undefined {
87
+ // `builtin:<name>` names no file; climbing from it would read the process cwd's manifest.
88
+ if (extPath.startsWith("builtin:")) return undefined;
79
89
  const entry = resolve(extPath);
80
90
  let dir = dirname(extPath);
81
91
  for (;;) {
@@ -197,6 +207,51 @@ export function parseExtSelectors(entries: string[]): {
197
207
  return { extNames, narrowing };
198
208
  }
199
209
 
210
+ /**
211
+ * Mirror of pi's `createToolNameMatcher` (mcp-servers.js): exact names plus `*`
212
+ * globs. `disallowed_tools` is handed to pi verbatim, so pi removes a wildcard
213
+ * match from the registry — this side has to agree, or the prompt advertises a
214
+ * tool pi already dropped and the scope gate would let it through.
215
+ */
216
+ function toolNameMatcher(entries: readonly string[]): (name: string) => boolean {
217
+ const exact = new Set(entries.filter((entry) => !entry.includes("*")));
218
+ const patterns = entries
219
+ .filter((entry) => entry.includes("*"))
220
+ .map((entry) => new RegExp(`^${entry.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`));
221
+ return (name) => exact.has(name) || patterns.some((pattern) => pattern.test(name));
222
+ }
223
+
224
+ /** Set once the child's tool scope is known; until then the gate admits everything. */
225
+ type NestedCallGate = { allows?: (name: string) => boolean };
226
+
227
+ /** Name of the inline scope-gate extension; its loader path is `<inline:${SCOPE_GATE_NAME}>`. */
228
+ const SCOPE_GATE_NAME = "pi-subagents-scope";
229
+
230
+ /**
231
+ * Inline extension that applies the subagent's tool scope to every tool call, wherever
232
+ * it comes from.
233
+ *
234
+ * pi gates registration by the session's `tools:` allowlist, but keeps a registered
235
+ * MCP tool callable through `ctx.executeTool()` whether or not the agent selected it —
236
+ * so a codemode script could reach a tool outside its scope. Those nested calls run
237
+ * past extensions' `tool_call` handlers only, never `agent.beforeToolCall`, and a
238
+ * `tool_search`-activated tool reaches the model the same way. One `tool_call` handler
239
+ * covers both.
240
+ */
241
+ function scopeGateExtension(gate: NestedCallGate): InlineExtension {
242
+ return {
243
+ name: SCOPE_GATE_NAME,
244
+ hidden: true,
245
+ factory: (ext) => {
246
+ ext.on("tool_call", (event) =>
247
+ gate.allows?.(event.toolName) === false
248
+ ? { block: true, reason: `Tool "${event.toolName}" is not available to this subagent.` }
249
+ : undefined,
250
+ );
251
+ },
252
+ };
253
+ }
254
+
200
255
  /** Default max turns. undefined = unlimited (no turn limit). */
201
256
  let defaultMaxTurns: number | undefined;
202
257
 
@@ -250,6 +305,43 @@ function resolveDefaultModel(
250
305
  return parentModel;
251
306
  }
252
307
 
308
+ /**
309
+ * Resolve the child session's model scope from `scoped_models` frontmatter.
310
+ *
311
+ * undefined/true → inherit the parent session's resolved scope, so in-session
312
+ * extensions that read `ctx.scopedModels` — notably pi-fallback-provider's
313
+ * settle-boundary fallback — work inside subagents too. false → no scope;
314
+ * string[] → an explicit replacement list resolved like `model:` (exact, then
315
+ * fuzzy, then the same model under another provider). Unresolved entries are
316
+ * skipped with a warning; they never fail the spawn.
317
+ */
318
+ export function resolveScopedModels(
319
+ configured: true | string[] | false | undefined,
320
+ parentScoped: readonly ScopedModel[] | undefined,
321
+ registry: ModelLookupRegistry,
322
+ warn: (message: string) => void = () => {},
323
+ ): ScopedModel[] {
324
+ if (configured === undefined || configured === true) {
325
+ return parentScoped ? [...parentScoped] : [];
326
+ }
327
+ if (configured === false) return [];
328
+
329
+ const resolved: ScopedModel[] = [];
330
+ const seen = new Set<string>();
331
+ for (const entry of configured) {
332
+ const model = resolveModel(entry, registry);
333
+ if (typeof model === "string") {
334
+ warn(`scoped_models: "${entry}" did not resolve — skipping`);
335
+ continue;
336
+ }
337
+ const key = `${model.provider}/${model.id}`;
338
+ if (seen.has(key)) continue;
339
+ seen.add(key);
340
+ resolved.push({ model });
341
+ }
342
+ return resolved;
343
+ }
344
+
253
345
  /** Info about a tool event in the subagent. */
254
346
  export interface ToolActivity {
255
347
  type: "start" | "end";
@@ -289,6 +381,14 @@ export interface RunOptions {
289
381
  * parent's repo, so config resolving inside it is correct.)
290
382
  */
291
383
  configCwd?: string;
384
+ /**
385
+ * The agent config to run, when the caller already resolved it from its own
386
+ * registry. A nested spawn resolves agents from its branch's config root,
387
+ * which is not necessarily what the process-global registry holds — passing
388
+ * the resolved config keeps the run from silently falling back to the
389
+ * unknown-type default (general-purpose, every tool).
390
+ */
391
+ agentConfig?: AgentConfig;
292
392
  /** Called on tool start/end with activity info. */
293
393
  onToolActivity?: (activity: ToolActivity) => void;
294
394
  /** Called on streaming text deltas from the assistant response. */
@@ -300,13 +400,30 @@ export interface RunOptions {
300
400
  * Called once per assistant message_end with that message's usage delta.
301
401
  * Lets callers maintain a lifetime accumulator that survives compaction
302
402
  * (which replaces session.state.messages and resets stats-derived sums).
403
+ *
404
+ * `cost` is pi's own `usage.cost.total` for that message — priced from the
405
+ * model's rates, so it is 0 (not missing) for a model pi has no pricing for.
406
+ * We never price anything ourselves; every dollar figure this extension shows
407
+ * or reports traces back to this field.
303
408
  */
304
- onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
409
+ onAssistantUsage?: (usage: LifetimeUsage) => void;
305
410
  /**
306
411
  * Called when the session successfully compacts. `tokensBefore` is upstream's
307
412
  * pre-compaction context size estimate. Aborted compactions don't fire.
308
413
  */
309
414
  onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
415
+ /**
416
+ * Runtime bridge for opt-in nested delegation. Present only for agents the
417
+ * manager spawned (a top-level run has no parent to scope children to).
418
+ */
419
+ nestedRuntime?: {
420
+ manager: NestedAgentManager;
421
+ parentAgentId: string;
422
+ /** This agent's own depth; its children spawn at depth + 1. */
423
+ depth: number;
424
+ /** Branch cap inherited from the spawn, else the project setting. */
425
+ maxSubagentDepth?: number;
426
+ };
310
427
  }
311
428
 
312
429
  export interface RunResult {
@@ -329,11 +446,13 @@ export interface RunResult {
329
446
  }
330
447
 
331
448
  /**
332
- * Subscribe to a session and collect the last assistant message text.
333
- * Returns an object with a `getText()` getter and an `unsubscribe` function.
449
+ * Subscribe to a session and collect the last assistant message text, plus the
450
+ * last assistant message itself as reported by its `message_end` event.
451
+ * Returns `getText()` and `getLastAssistant()` getters and an `unsubscribe` function.
334
452
  */
335
453
  function collectResponseText(session: AgentSession) {
336
454
  let text = "";
455
+ let lastAssistant: AssistantMessage | undefined;
337
456
  const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
338
457
  // message_start also fires for user and toolResult messages — resetting on
339
458
  // those would wipe assistant text already collected. Reset only when a new
@@ -344,8 +463,11 @@ function collectResponseText(session: AgentSession) {
344
463
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
345
464
  text += event.assistantMessageEvent.delta;
346
465
  }
466
+ if (event.type === "message_end" && event.message.role === "assistant") {
467
+ lastAssistant = event.message;
468
+ }
347
469
  });
348
- return { getText: () => text, unsubscribe };
470
+ return { getText: () => text, getLastAssistant: () => lastAssistant, unsubscribe };
349
471
  }
350
472
 
351
473
  /**
@@ -376,20 +498,31 @@ function getLastAssistantText(session: AgentSession, startIndex = 0): string {
376
498
  * Everything else completes: a clean "stop"/"toolUse" final, and — crucially — a
377
499
  * "length" stop that DID produce text (a legitimate truncated-but-useful answer).
378
500
  * "aborted" is handled by the manager's abort flag / "stopped" guard, not here.
379
- * Bounded by `startIndex` (like the text fallback) so a resume that produced no
380
- * assistant message of its own never inherits a PRIOR turn's stop reason.
501
+ * `observed` is the final assistant message seen via `message_end` during this
502
+ * invocation, and it is authoritative: a context_edit can hide the failed turn
503
+ * from session.messages, and a walk-back would then report an older turn's
504
+ * success. The session.messages walk is only a fallback for runs that emitted no
505
+ * assistant message_end. Bounded by `startIndex` (like the text fallback) so a
506
+ * resume with no assistant message of its own never inherits a PRIOR turn's stop
507
+ * reason.
381
508
  */
382
- function finalTurnError(session: AgentSession, startIndex = 0): string | undefined {
383
- for (let i = session.messages.length - 1; i >= startIndex; i--) {
384
- const msg = session.messages[i];
385
- if (msg.role !== "assistant") continue;
386
- if (msg.stopReason === "error") {
387
- return (msg as { errorMessage?: string }).errorMessage?.trim() || "provider error with no output";
388
- }
389
- if (msg.stopReason === "length" && !extractText(msg.content).trim()) {
390
- return "run hit the output token limit before producing any text";
509
+ function finalTurnError(session: AgentSession, startIndex = 0, observed?: AssistantMessage): string | undefined {
510
+ let final = observed;
511
+ if (!final) {
512
+ for (let i = session.messages.length - 1; i >= startIndex; i--) {
513
+ const msg = session.messages[i];
514
+ if (msg.role === "assistant") {
515
+ final = msg;
516
+ break;
517
+ }
391
518
  }
392
- return undefined;
519
+ }
520
+ if (!final) return undefined;
521
+ if (final.stopReason === "error") {
522
+ return final.errorMessage?.trim() || "provider error with no output";
523
+ }
524
+ if (final.stopReason === "length" && !extractText(final.content).trim()) {
525
+ return "run hit the output token limit before producing any text";
393
526
  }
394
527
  return undefined;
395
528
  }
@@ -401,6 +534,12 @@ function finalTurnError(session: AgentSession, startIndex = 0): string | undefin
401
534
  function forwardAbortSignal(session: AgentSession, signal?: AbortSignal): () => void {
402
535
  if (!signal) return () => {};
403
536
  const onAbort = () => session.abort();
537
+ // An already-aborted signal never fires its event, so abort now instead of
538
+ // letting the child run on with a signal its owner believes is dead.
539
+ if (signal.aborted) {
540
+ onAbort();
541
+ return () => {};
542
+ }
404
543
  signal.addEventListener("abort", onAbort, { once: true });
405
544
  return () => signal.removeEventListener("abort", onAbort);
406
545
  }
@@ -418,8 +557,23 @@ export async function runAgent(
418
557
  prompt: string,
419
558
  options: RunOptions,
420
559
  ): Promise<RunResult> {
421
- const config = getConfig(type);
422
- const agentConfig = getAgentConfig(type);
560
+ const resolvedConfig = options.agentConfig;
561
+ const agentConfig = resolvedConfig ?? getAgentConfig(type);
562
+ // Every derived field comes from the same config: a nested spawn resolves the
563
+ // agent from its branch's config root, and re-deriving tools or extension
564
+ // policy from the process-global registry would run a different agent than the
565
+ // one the caller validated (all built-ins, general-purpose's extensions).
566
+ const config = resolvedConfig
567
+ ? {
568
+ displayName: resolvedConfig.displayName ?? resolvedConfig.name,
569
+ description: resolvedConfig.description,
570
+ builtinToolNames: resolvedConfig.builtinToolNames ?? [...BUILTIN_TOOL_NAMES],
571
+ extensions: resolvedConfig.extensions,
572
+ excludeExtensions: resolvedConfig.excludeExtensions,
573
+ skills: resolvedConfig.skills,
574
+ promptMode: resolvedConfig.promptMode,
575
+ }
576
+ : getConfig(type);
423
577
 
424
578
  // Resolve working directory: worktree override > parent cwd
425
579
  const effectiveCwd = options.cwd ?? ctx.cwd;
@@ -450,7 +604,7 @@ export async function runAgent(
450
604
  }
451
605
  }
452
606
 
453
- let toolNames = getToolNamesForType(type);
607
+ let toolNames = resolvedConfig ? [...config.builtinToolNames] : getToolNamesForType(type);
454
608
 
455
609
  // Persistent memory: detect write capability and branch accordingly.
456
610
  // Account for disallowedTools — a tool in the base set but on the denylist is not truly available.
@@ -512,6 +666,9 @@ export async function runAgent(
512
666
  options.isolated ? [] : (agentConfig?.extSelectors ?? []),
513
667
  );
514
668
  const noExtensions = extensions === false;
669
+ const disallowed = agentConfig?.disallowedTools?.length
670
+ ? toolNameMatcher(agentConfig.disallowedTools)
671
+ : undefined;
515
672
 
516
673
  const extensionsSpec = Array.isArray(extensions)
517
674
  ? parseExtensionsSpec(extensions, configCwd)
@@ -540,6 +697,7 @@ export async function runAgent(
540
697
  return {
541
698
  ...base,
542
699
  extensions: base.extensions.filter((e) => {
700
+ if (e.path === `<inline:${SCOPE_GATE_NAME}>`) return true; // ours, not the agent's
543
701
  const canons = extensionCanonicalNames(e.path);
544
702
  if (canons.some((n) => excludeNames.has(n))) return false; // exclude wins
545
703
  return loadAll || canons.some((n) => keepNames.has(n));
@@ -547,7 +705,169 @@ export async function runAgent(
547
705
  };
548
706
  };
549
707
 
550
- const loader = new DefaultResourceLoader({
708
+ // The child inherits the parent's project-trust decision. pi's SettingsManager
709
+ // defaults to trusted, so without this a subagent in a project the user declined to
710
+ // trust would load its `.pi` extensions and settings, and pi's built-in MCP would
711
+ // start the stdio servers in its `.pi/mcp.json`.
712
+ const projectTrusted = ctx.isProjectTrusted();
713
+ const settingsManager = SettingsManager.create(configCwd, agentDir, { projectTrusted });
714
+ // That trust covers the parent's project (`configCwd`), not a directory a spawn was
715
+ // sent into. The session answers `ctx.isProjectTrusted()` for its own cwd, which
716
+ // pi's MCP uses to read `<cwd>/.pi/mcp.json` — so a cross-directory session keeps the
717
+ // parent project's settings but reports its cwd as untrusted. The loader keeps the
718
+ // trusted manager: it loads the parent project's extensions.
719
+ const sessionSettingsManager =
720
+ resolve(effectiveCwd) === resolve(configCwd)
721
+ ? settingsManager
722
+ : Object.assign(SettingsManager.create(configCwd, agentDir, { projectTrusted }), {
723
+ isProjectTrusted: () => false,
724
+ });
725
+ const nestedCallGate: NestedCallGate = {};
726
+
727
+ // Nested delegation tools (opt-in, ownership-scoped). Empty unless the agent
728
+ // set `allowed_subagents`, the manager handed down a nestedRuntime, and this
729
+ // agent sits below the branch cap. Their names collide with EXCLUDED_TOOL_NAMES
730
+ // by design, so the scoping below re-admits them explicitly.
731
+ const inheritedMaxDepth = options.nestedRuntime?.maxSubagentDepth ?? getMaxSubagentDepth();
732
+ // A per-agent cap may only tighten the inherited one — an agent can never
733
+ // grant itself more nesting room than the project allows.
734
+ const effectiveMaxDepth = agentConfig?.maxSubagentDepth === undefined
735
+ ? inheritedMaxDepth
736
+ : Math.min(inheritedMaxDepth, agentConfig.maxSubagentDepth);
737
+ // At (or past) the cap this agent can never spawn, so it can never own a child
738
+ // to fetch from or steer either — inject nothing rather than four tools whose
739
+ // every call is an error. This is also what makes maxSubagentDepth 0/1 mean
740
+ // "nesting off" instead of "nesting always fails".
741
+ const nestedRuntime = options.nestedRuntime && options.nestedRuntime.depth < effectiveMaxDepth
742
+ ? options.nestedRuntime
743
+ : undefined;
744
+ const nestedTools = agentConfig?.allowedSubagents && nestedRuntime && !options.isolated
745
+ ? createNestedSubagentTools({
746
+ manager: nestedRuntime.manager,
747
+ pi: options.pi,
748
+ parentAgentId: nestedRuntime.parentAgentId,
749
+ depth: nestedRuntime.depth,
750
+ maxSubagentDepth: effectiveMaxDepth,
751
+ allowedSubagents: agentConfig.allowedSubagents,
752
+ configCwd,
753
+ })
754
+ : [];
755
+ const nestedToolNames = new Set(nestedTools.map((tool) => tool.name));
756
+
757
+ // Extension-registered tools the subagent may use, keyed by name, read from the
758
+ // loader's LIVE extension tool maps: extensions register tools long after load
759
+ // (pi's MCP registers a server's tools once it connects) and `registerTool` writes
760
+ // into the very maps this reads. The prompt override and the allowlist below take a
761
+ // snapshot; the scope gate re-derives it per call, so a late tool is in scope from
762
+ // the moment it exists.
763
+ //
764
+ // Opt-in flip: when any `ext:` selector is present, extension tools become an
765
+ // explicit allowlist — a loaded extension not named by a selector contributes
766
+ // no tools (its handlers still ran), and `ext:foo/bar` narrows `foo` to `bar`.
767
+ // Tools the parent session currently declares. A `defaultActive: false` tool —
768
+ // pi's `codemode` and `tool_search`, and anything an extension registers dormant —
769
+ // is activated in the child only when the parent has it active or this agent names
770
+ // its extension. Otherwise every subagent would carry codemode's tool spec, and the
771
+ // capability that comes with it, for a user who never turned it on: "as in the main
772
+ // session" has to mean the main session's actual tool set, not every tool that
773
+ // happens to be loaded. Read per run: the parent's set changes with `--tools` and
774
+ // with whatever `tool_search` activated.
775
+ const parentActiveTools = new Set(options.pi.getActiveTools?.() ?? []);
776
+ // Tools an `ext:` selector names explicitly. Naming an extension is the agent
777
+ // saying it wants its tools, which is the same consent `--tools` gives pi.
778
+ const explicitlySelected = new Set<string>();
779
+ const scopedExtensionTools = (loaded: LoadExtensionsResult): Map<string, ToolDefinition> => {
780
+ const tools = new Map<string, ToolDefinition>();
781
+ if (noExtensions) return tools;
782
+ const optInActive = extNames.size > 0;
783
+ for (const extension of loaded.extensions) {
784
+ const canons = extensionCanonicalNames(extension.path);
785
+ if (optInActive && !canons.some((c) => extNames.has(c))) continue;
786
+ // First alias that carries a narrowing set — a user won't narrow one
787
+ // extension under two different names, so first-match is correct.
788
+ const narrowed = canons.map((c) => narrowing.get(c)).find(Boolean);
789
+ for (const [toolName, tool] of extension.tools) {
790
+ if (narrowed && !narrowed.has(toolName)) continue;
791
+ tools.set(toolName, tool.definition);
792
+ // Every tool reaching this line belongs to an extension the agent named,
793
+ // so it is selected on purpose rather than merely loaded.
794
+ if (optInActive) explicitlySelected.add(toolName);
795
+ }
796
+ }
797
+ return tools;
798
+ };
799
+
800
+ // The master tool allowlist applied at session construction. pi-mono's
801
+ // `allowedToolNames` gates registration and activates what it names, so listing the
802
+ // exact final set here means the session is correctly scoped from the first instant.
803
+ // Naming a tool is also how pi activates one registered `defaultActive: false` (its
804
+ // `codemode` and `tool_search` among them), which is what a subagent wants for its
805
+ // own scope.
806
+ const scopedToolNames = (tools: Map<string, ToolDefinition>): string[] =>
807
+ [...toolNames, ...tools.keys(), ...nestedToolNames].filter((t) => {
808
+ // The orchestration tools stay stripped from every subagent — except the
809
+ // nested ones this agent opted into, which carry the same names on purpose.
810
+ if ((EXCLUDED_TOOL_NAMES.includes(t) && !nestedToolNames.has(t)) || disallowed?.(t)) return false;
811
+ // Built-ins carry no definition here and are always declared; the rule is
812
+ // about extension tools pi would leave dormant.
813
+ if (tools.get(t)?.defaultActive !== false) return true;
814
+ return explicitlySelected.has(t) || parentActiveTools.has(t);
815
+ });
816
+
817
+ // Names pi would DECLARE to the model: `direct`/`model-only` exposure only. A
818
+ // `codemode`/`deferred` tool (pi's MCP server tools by default) stays reachable
819
+ // through codemode and `tool_search` without being advertised.
820
+ const declarableTools = (tools: Map<string, ToolDefinition>): string[] =>
821
+ scopedToolNames(tools).filter((name) => {
822
+ const exposure = tools.get(name)?.exposure ?? "direct";
823
+ return exposure === "direct" || exposure === "model-only";
824
+ });
825
+
826
+ /**
827
+ * The child's prompt, with the `<tools>` block it inherits from the parent
828
+ * retargeted at the tools it actually declares — see `retargetToolsSection`.
829
+ */
830
+ const childPrompt = (loaded: LoadExtensionsResult): string => {
831
+ const tools = scopedExtensionTools(loaded);
832
+ // Built-ins first: the inherited block only describes the parent's *active*
833
+ // tools, so an agent whose `tools:` reaches beyond that set would otherwise
834
+ // be advertised fewer tools than it declares. Child definitions win on a
835
+ // name both have.
836
+ const snippets: Record<string, string> = { ...getBuiltinToolSnippets() };
837
+ for (const [name, definition] of tools) {
838
+ if (definition.promptSnippet) snippets[name] = definition.promptSnippet;
839
+ }
840
+ // Injected nested tools are not extension tools, so their snippets have to
841
+ // be added here — otherwise the child is handed Agent/get_subagent_result
842
+ // and told nothing about them.
843
+ for (const definition of nestedTools) {
844
+ if (definition.promptSnippet) snippets[definition.name] = definition.promptSnippet;
845
+ }
846
+ return retargetToolsSection(systemPrompt, declarableTools(tools), snippets);
847
+ };
848
+
849
+ const loader: DefaultResourceLoader = new DefaultResourceLoader({
850
+ settingsManager,
851
+ // pi's CLI hands its built-in extensions to the loader it builds; this one started
852
+ // with none, so a `builtin:<name>` entry in `extensions:` resolved to nothing and
853
+ // pi's MCP, codemode, and tool search were out of reach for every subagent. As
854
+ // `builtin:` resources they load as in the main session — on by default, disabled by
855
+ // `-builtin:<name>` in settings — and the per-agent allowlist filters them like any
856
+ // other extension. Fresh factories per spawn: MCP state lives in the closure.
857
+ extensionFactories: [
858
+ { name: "codemode", factory: createCodemodeExtension(), builtin: true, replaceable: true },
859
+ { name: "tool-search", factory: createToolSearchExtension(), builtin: true, replaceable: true },
860
+ { name: "mcp", factory: createMcpExtension(), builtin: true, replaceable: true },
861
+ // Under `noExtensions` the static allowlist gates the registry itself.
862
+ ...(noExtensions ? [] : [scopeGateExtension(nestedCallGate)]),
863
+ ],
864
+ // Mirrors pi's CLI: `--no-mcp` keeps MCP out of subagents too, and `--no-extensions`
865
+ // keeps all the built-ins out. Discovered extensions still follow `extensions:`.
866
+ disabledBuiltinExtensions: process.argv.some((a) => a === "--no-extensions" || a === "-ne")
867
+ ? ["codemode", "tool-search", "mcp"]
868
+ : process.argv.includes("--no-mcp")
869
+ ? ["mcp"]
870
+ : undefined,
551
871
  cwd: configCwd,
552
872
  agentDir,
553
873
  noExtensions,
@@ -557,10 +877,15 @@ export async function runAgent(
557
877
  noPromptTemplates: true,
558
878
  noThemes: true,
559
879
  noContextFiles: true,
560
- systemPromptOverride: () => systemPrompt,
880
+ // pi reads this once, at the end of `reload()`, with the extension set already
881
+ // filtered — so the prompt is retargeted at what the child really loads.
882
+ systemPromptOverride: () => childPrompt(loader.getExtensions()),
561
883
  appendSystemPromptOverride: () => [],
562
884
  });
563
- await loader.reload();
885
+ // Marked as a child session: the extension factory runs inside reload(), and a
886
+ // subagent activation must not register the parent-facing tools (see
887
+ // child-context.ts).
888
+ await runInChildSessionContext(() => loader.reload());
564
889
 
565
890
  // Plain entries in `tools:` are expected to be built-in names (extension tools
566
891
  // go through `ext:`), so an unknown name there is unambiguously a typo. Previously
@@ -639,78 +964,98 @@ export async function runAgent(
639
964
  // Resolve thinking level: explicit option > agent config > undefined (inherit)
640
965
  const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
641
966
 
642
- const disallowedSet = agentConfig?.disallowedTools
643
- ? new Set(agentConfig.disallowedTools)
644
- : undefined;
645
-
646
- // Enumerate extension-registered tool names from the loaded resource loader.
647
- // Extensions populate `extension.tools` during `loader.reload()` and the set
648
- // is stable afterwards — `bindExtensions` does not register new tools.
649
- //
650
- // Opt-in flip: when any `ext:` selector is present, extension tools become an
651
- // explicit allowlist — a loaded extension not named by a selector contributes
652
- // no tools (its handlers still ran), and `ext:foo/bar` narrows `foo` to `bar`.
653
- const extensionToolNames: string[] = [];
654
- if (!noExtensions) {
655
- const optInActive = extNames.size > 0;
656
- for (const extension of loader.getExtensions().extensions) {
657
- const canons = extensionCanonicalNames(extension.path);
658
- if (optInActive && !canons.some((c) => extNames.has(c))) continue;
659
- // First alias that carries a narrowing set — a user won't narrow one
660
- // extension under two different names, so first-match is correct.
661
- const narrowed = canons.map((c) => narrowing.get(c)).find(Boolean);
662
- for (const toolName of extension.tools.keys()) {
663
- if (narrowed && !narrowed.has(toolName)) continue;
664
- extensionToolNames.push(toolName);
665
- }
666
- }
967
+ // Child-session model scope: agent frontmatter override, else inherit the
968
+ // parent's resolved scope. Isolated agents load no extensions, so the
969
+ // fallback consumer can never bind — an empty scope is the honest value.
970
+ const scopedModels = resolveScopedModels(
971
+ options.isolated ? false : agentConfig?.scopedModels,
972
+ ctx.scopedModels,
973
+ ctx.modelRegistry,
974
+ (message) => options.onToolActivity?.({ type: "end", toolName: `scoped-models-error:${message}` }),
975
+ );
976
+ // An explicit list under isolation is silently unreachable — flag it the
977
+ // same way the loader flags contradictory extensions:/exclude_extensions:.
978
+ if (options.isolated && Array.isArray(agentConfig?.scopedModels)) {
979
+ options.onToolActivity?.({
980
+ type: "end",
981
+ toolName: `scoped-models-error:scoped_models has no effect for agent "${type}" — isolated agents load no extensions`,
982
+ });
667
983
  }
668
984
 
669
- // Build the master tool allowlist applied at session construction.
670
- // pi-mono's `allowedToolNames` gates BOTH registration and the initial active
671
- // set, so listing the exact final set here means the session is correctly
672
- // scoped from the first instant — no post-construction narrowing required.
673
- const builtinToolNameSet = new Set(toolNames);
674
- const allowedTools = [...toolNames, ...extensionToolNames].filter((t) => {
675
- if (EXCLUDED_TOOL_NAMES.includes(t)) return false;
676
- if (disallowedSet?.has(t)) return false;
677
- if (builtinToolNameSet.has(t)) return true;
678
- // Reached only for extension tools. The extension set was already filtered
679
- // at the loader (extensionsOverride / noExtensions) and at enumeration
680
- // (`ext:` opt-in flip), so any extension tool in `extensionToolNames` is allowed.
681
- return !noExtensions;
682
- });
985
+ const extensionTools = scopedExtensionTools(loader.getExtensions());
986
+ const allowedTools = scopedToolNames(extensionTools);
987
+ const declaredTools = declarableTools(extensionTools);
988
+ // The same scope, for calls that skip the allowlist: pi keeps a registered MCP tool
989
+ // callable through `ctx.executeTool()` (a codemode script) and through `tool_search`
990
+ // even when the agent never selected it.
991
+ nestedCallGate.allows = (name) =>
992
+ ((!EXCLUDED_TOOL_NAMES.includes(name) || nestedToolNames.has(name)) &&
993
+ !disallowed?.(name) &&
994
+ (toolNames.includes(name) || nestedToolNames.has(name) || scopedExtensionTools(loader.getExtensions()).has(name)));
683
995
 
684
- const settingsManager = SettingsManager.create(configCwd, agentDir);
685
996
  const configuredSessionDir = resolveConfiguredSessionDir(agentConfig?.sessionDir, effectiveCwd);
686
997
  const defaultSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR ?? settingsManager.getSessionDir?.();
687
- const sessionManager = agentConfig?.persistSession
688
- ? SessionManager.create(effectiveCwd, configuredSessionDir ?? defaultSessionDir)
689
- : SessionManager.inMemory(effectiveCwd);
690
-
691
- // Pi 0.80.8 replaced createAgentSession's modelRegistry option with
692
- // modelRuntime, but ExtensionContext still exposes only the registry facade.
693
- // Pass both so the full supported Pi range retains the parent's providers.
694
- const parentModelRuntime = (ctx.modelRegistry as unknown as { runtime?: unknown }).runtime;
695
- const sessionOpts: Parameters<typeof createAgentSession>[0] & {
696
- modelRegistry: ExtensionContext["modelRegistry"];
697
- modelRuntime?: unknown;
698
- } = {
998
+ // A subagent session is ALWAYS written to disk while it runs: a crashed or
999
+ // killed run must leave a transcript pi's /resume can recover. Retention
1000
+ // after the run settles is the manager's decision (`persist_session`), never a
1001
+ // creation-time one.
1002
+ //
1003
+ // The exception is a host session that is itself in-memory: pi's own
1004
+ // `--no-session`, or any SDK host that built `SessionManager.inMemory`. Its
1005
+ // absence of a session file is the exact signal — an argv scan would miss the
1006
+ // SDK host and let an ephemeral session leave children on disk.
1007
+ // The same path is `parentSession`, which nests the child under its spawner.
1008
+ const parentSession = ctx.sessionManager.getSessionFile();
1009
+ const sessionManager = parentSession === undefined
1010
+ ? SessionManager.inMemory(effectiveCwd)
1011
+ : SessionManager.create(
1012
+ effectiveCwd,
1013
+ configuredSessionDir ?? defaultSessionDir,
1014
+ { parentSession },
1015
+ );
1016
+
1017
+ type AgentSessionOptions = NonNullable<Parameters<typeof createAgentSession>[0]>;
1018
+ // ExtensionContext exposes only the ModelRegistry facade; the runtime it wraps
1019
+ // is what createAgentSession needs to keep extension-registered providers
1020
+ // (test/e2e/isolated-provider.e2e.test.ts guards the private `.runtime` reach).
1021
+ const parentModelRuntime = (
1022
+ ctx.modelRegistry as unknown as { runtime?: AgentSessionOptions["modelRuntime"] }
1023
+ ).runtime;
1024
+ const sessionOpts: AgentSessionOptions = {
699
1025
  cwd: effectiveCwd,
700
1026
  agentDir,
701
1027
  sessionManager,
702
- settingsManager,
703
- modelRegistry: ctx.modelRegistry,
1028
+ settingsManager: sessionSettingsManager,
704
1029
  ...(parentModelRuntime !== undefined && { modelRuntime: parentModelRuntime }),
705
1030
  model,
1031
+ scopedModels,
706
1032
  tools: allowedTools,
1033
+ // The denylist has to reach pi as well as the allowlist math: MCP tools are
1034
+ // kept registered even when the allowlist does not name them (pi only filters
1035
+ // MCP when the allowlist mentions `mcp__`), so a `disallowed_tools: mcp__…`
1036
+ // entry would otherwise stay registered, listable by `tool_search`, and merely
1037
+ // blocked at call time by the scope gate. Excluding it here removes it from the
1038
+ // registry instead.
1039
+ ...(disallowed && { excludeTools: agentConfig?.disallowedTools }),
1040
+ ...(nestedTools.length > 0 && { customTools: nestedTools }),
707
1041
  resourceLoader: loader,
708
1042
  };
709
1043
  if (thinkingLevel) {
710
1044
  sessionOpts.thinkingLevel = thinkingLevel;
711
1045
  }
712
1046
 
713
- const { session } = await createAgentSession(sessionOpts);
1047
+ const { session } = await runInChildSessionContext(() => createAgentSession(sessionOpts));
1048
+
1049
+ // pi activates every tool its allowlist names, `codemode`/`deferred` exposure
1050
+ // included. Those must stay undeclared, as in the main session, so take back the
1051
+ // ones pi would not declare. They stay registered and re-activatable: codemode and
1052
+ // `tool_search` reach them, and the scope gate above still applies.
1053
+ const undeclared = allowedTools.filter((name) => !declaredTools.includes(name));
1054
+ if (undeclared.length > 0) {
1055
+ const keep = new Set(session.getActiveToolNames());
1056
+ for (const name of undeclared) keep.delete(name);
1057
+ session.setActiveToolsByName([...keep]);
1058
+ }
714
1059
 
715
1060
  const baseSessionName = agentConfig?.name ?? type;
716
1061
  session.setSessionName(
@@ -772,6 +1117,8 @@ export async function runAgent(
772
1117
  input: u.input ?? 0,
773
1118
  output: u.output ?? 0,
774
1119
  cacheWrite: u.cacheWrite ?? 0,
1120
+ cacheRead: u.cacheRead ?? 0,
1121
+ cost: u.cost?.total ?? 0,
775
1122
  });
776
1123
  }
777
1124
  if (event.type === "compaction_end" && !event.aborted && event.result) {
@@ -779,9 +1126,6 @@ export async function runAgent(
779
1126
  }
780
1127
  });
781
1128
 
782
- const collector = collectResponseText(session);
783
- const cleanupAbort = forwardAbortSignal(session, options.signal);
784
-
785
1129
  // Build the effective prompt: optionally prepend parent context
786
1130
  let effectivePrompt = prompt;
787
1131
  if (options.inheritContext) {
@@ -794,6 +1138,9 @@ export async function runAgent(
794
1138
  // Boundary for the history fallback: only assistant text produced from here
795
1139
  // on counts as this run's output (a fresh session, so usually 0).
796
1140
  const startLen = session.messages.length;
1141
+ // Subscribed after the parent-context build so a throw there cannot leak them.
1142
+ const collector = collectResponseText(session);
1143
+ const cleanupAbort = forwardAbortSignal(session, options.signal);
797
1144
  try {
798
1145
  await session.prompt(effectivePrompt);
799
1146
  } finally {
@@ -803,7 +1150,13 @@ export async function runAgent(
803
1150
  }
804
1151
 
805
1152
  const responseText = collector.getText().trim() || getLastAssistantText(session, startLen);
806
- return { responseText, session, aborted, steered: softLimitReached, failure: finalTurnError(session, startLen) };
1153
+ return {
1154
+ responseText,
1155
+ session,
1156
+ aborted,
1157
+ steered: softLimitReached,
1158
+ failure: finalTurnError(session, startLen, collector.getLastAssistant()),
1159
+ };
807
1160
  }
808
1161
 
809
1162
  /**
@@ -814,7 +1167,9 @@ export async function resumeAgent(
814
1167
  prompt: string,
815
1168
  options: {
816
1169
  onToolActivity?: (activity: ToolActivity) => void;
817
- onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
1170
+ onTextDelta?: (delta: string, fullText: string) => void;
1171
+ onTurnEnd?: (turnCount: number) => void;
1172
+ onAssistantUsage?: (usage: LifetimeUsage) => void;
818
1173
  onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
819
1174
  signal?: AbortSignal;
820
1175
  } = {},
@@ -826,8 +1181,24 @@ export async function resumeAgent(
826
1181
  const collector = collectResponseText(session);
827
1182
  const cleanupAbort = forwardAbortSignal(session, options.signal);
828
1183
 
829
- const unsubEvents = (options.onToolActivity || options.onAssistantUsage || options.onCompaction)
1184
+ // Per-message bookkeeping mirrors runAgent: reset the accumulated text on
1185
+ // each new message_start and count completed turns, so the resumed run's
1186
+ // streaming/stat readouts are not stale.
1187
+ let currentMessageText = "";
1188
+ let turnCount = 0;
1189
+ const unsubEvents = (options.onToolActivity || options.onTextDelta || options.onTurnEnd || options.onAssistantUsage || options.onCompaction)
830
1190
  ? session.subscribe((event: AgentSessionEvent) => {
1191
+ if (event.type === "turn_end") {
1192
+ turnCount++;
1193
+ options.onTurnEnd?.(turnCount);
1194
+ }
1195
+ if (event.type === "message_start") {
1196
+ currentMessageText = "";
1197
+ }
1198
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
1199
+ currentMessageText += event.assistantMessageEvent.delta;
1200
+ options.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText);
1201
+ }
831
1202
  if (event.type === "tool_execution_start") options.onToolActivity?.({ type: "start", toolName: event.toolName });
832
1203
  if (event.type === "tool_execution_end") options.onToolActivity?.({ type: "end", toolName: event.toolName });
833
1204
  if (event.type === "message_end" && event.message.role === "assistant") {
@@ -836,6 +1207,8 @@ export async function resumeAgent(
836
1207
  input: u.input ?? 0,
837
1208
  output: u.output ?? 0,
838
1209
  cacheWrite: u.cacheWrite ?? 0,
1210
+ cacheRead: u.cacheRead ?? 0,
1211
+ cost: u.cost?.total ?? 0,
839
1212
  });
840
1213
  }
841
1214
  if (event.type === "compaction_end" && !event.aborted && event.result) {
@@ -854,7 +1227,7 @@ export async function resumeAgent(
854
1227
 
855
1228
  return {
856
1229
  text: collector.getText().trim() || getLastAssistantText(session, startLen),
857
- failure: finalTurnError(session, startLen),
1230
+ failure: finalTurnError(session, startLen, collector.getLastAssistant()),
858
1231
  };
859
1232
  }
860
1233