@nredd/pi-subagents 0.0.0-stage → 0.22.0-nredd.10

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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1444 -2
  3. package/dist/chunks/chunk-MSGGFB5Q.ts +6755 -0
  4. package/dist/chunks/chunk-MSGGFB5Q.ts.map +7 -0
  5. package/dist/chunks/conversation-viewer-A7AMRDVY.ts +15 -0
  6. package/dist/chunks/conversation-viewer-A7AMRDVY.ts.map +7 -0
  7. package/dist/index.ts +9874 -0
  8. package/dist/index.ts.map +7 -0
  9. package/package.json +82 -4
  10. package/src/abortable.ts +43 -0
  11. package/src/agent-color.ts +161 -0
  12. package/src/agent-file-toggle.ts +298 -0
  13. package/src/agent-manager.ts +2431 -0
  14. package/src/agent-runner.ts +1703 -0
  15. package/src/agent-source-badge.ts +75 -0
  16. package/src/agent-types.ts +346 -0
  17. package/src/child-context.ts +33 -0
  18. package/src/clock.ts +25 -0
  19. package/src/completion-outbox.ts +144 -0
  20. package/src/context.ts +58 -0
  21. package/src/cross-extension-rpc.ts +200 -0
  22. package/src/custom-agents.ts +441 -0
  23. package/src/default-agents.ts +126 -0
  24. package/src/detached-results.ts +150 -0
  25. package/src/enabled-models.ts +181 -0
  26. package/src/enterprise-local.ts +67 -0
  27. package/src/env.ts +33 -0
  28. package/src/error-message.ts +11 -0
  29. package/src/git-path.ts +66 -0
  30. package/src/group-join.ts +142 -0
  31. package/src/home-paths.ts +27 -0
  32. package/src/index.ts +5047 -0
  33. package/src/invocation-config.ts +158 -0
  34. package/src/memory.ts +179 -0
  35. package/src/mention-clone.ts +200 -0
  36. package/src/mention.ts +141 -0
  37. package/src/model-call.ts +83 -0
  38. package/src/model-resolver.ts +333 -0
  39. package/src/model-scope.ts +71 -0
  40. package/src/model-selection.ts +170 -0
  41. package/src/model-tiers.ts +176 -0
  42. package/src/nested-tools.ts +512 -0
  43. package/src/notification-result.ts +118 -0
  44. package/src/output-file.ts +186 -0
  45. package/src/package-resources.ts +639 -0
  46. package/src/plan-mode-children.ts +176 -0
  47. package/src/process-budget.ts +346 -0
  48. package/src/prompts.ts +161 -0
  49. package/src/quota-admission.ts +121 -0
  50. package/src/resource-loader.ts +65 -0
  51. package/src/schedule-store.ts +153 -0
  52. package/src/schedule.ts +514 -0
  53. package/src/settings.ts +851 -0
  54. package/src/skill-loader.ts +288 -0
  55. package/src/stale-ctx.ts +16 -0
  56. package/src/startup-error.ts +29 -0
  57. package/src/status-note.ts +113 -0
  58. package/src/structured-output.ts +130 -0
  59. package/src/types.ts +487 -0
  60. package/src/ui/agent-mention.ts +219 -0
  61. package/src/ui/agent-row.ts +245 -0
  62. package/src/ui/agent-widget.ts +722 -0
  63. package/src/ui/conversation-viewer.ts +837 -0
  64. package/src/ui/fallback-flag.ts +114 -0
  65. package/src/ui/fleet-list.ts +1020 -0
  66. package/src/ui/fleet-state.ts +154 -0
  67. package/src/ui/notification-view.ts +139 -0
  68. package/src/ui/schedule-menu.ts +105 -0
  69. package/src/ui/select-item.ts +45 -0
  70. package/src/ui/viewer-keys.ts +39 -0
  71. package/src/ui/workflow-card.ts +459 -0
  72. package/src/ui/workflow-dialog.ts +1138 -0
  73. package/src/ui/workflow-menu.ts +193 -0
  74. package/src/usage.ts +167 -0
  75. package/src/workflow/collisions.ts +123 -0
  76. package/src/workflow/entry.ts +47 -0
  77. package/src/workflow/host.ts +398 -0
  78. package/src/workflow/journal.ts +176 -0
  79. package/src/workflow/json-schema.ts +129 -0
  80. package/src/workflow/meta.ts +329 -0
  81. package/src/workflow/progress.ts +554 -0
  82. package/src/workflow/runtime.ts +1249 -0
  83. package/src/workflow/saved.ts +259 -0
  84. package/src/workflow/task.ts +317 -0
  85. package/src/workflow/tool-description.ts +200 -0
  86. package/src/workflow/worker-source.ts +903 -0
  87. package/src/worktree.ts +520 -0
  88. package/src/xml.ts +13 -0
@@ -0,0 +1,1703 @@
1
+ /**
2
+ * agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
3
+ */
4
+
5
+ import { readFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
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";
10
+ import {
11
+ type AgentSession,
12
+ type AgentSessionEvent,
13
+ createAgentSession,
14
+ createCodemodeExtension,
15
+ createMcpExtension,
16
+ createToolSearchExtension,
17
+ type DefaultResourceLoader,
18
+ defineTool,
19
+ type ExtensionAPI,
20
+ type InlineExtension,
21
+ SessionManager,
22
+ SettingsManager,
23
+ } from "@earendil-works/pi-coding-agent";
24
+ import { Type } from "typebox";
25
+ import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
26
+ import { bindChildResourceLoader, runInChildSessionContext } from "./child-context.js";
27
+ import { type Clock, realClock } from "./clock.js";
28
+ import { buildParentContext, extractText } from "./context.js";
29
+ import { DEFAULT_AGENTS } from "./default-agents.js";
30
+ import { detectEnv } from "./env.js";
31
+ import { normalizeHomePath, resolveAgentDir } from "./home-paths.js";
32
+ import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
33
+ import type { ModelCallEvent } from "./model-call.js";
34
+ import type { ModelFallback } from "./model-resolver.js";
35
+ import type { ModelSelection } from "./model-selection.js";
36
+ import {
37
+ createNestedSubagentTools,
38
+ getMaxSubagentDepth,
39
+ hasNestableTypes,
40
+ NESTED_WORKFLOW_ERROR,
41
+ NESTED_WORKFLOW_NO_AGENT_ERROR,
42
+ type NestedAgentManager,
43
+ } from "./nested-tools.js";
44
+ import { type PlanGate, planGateReason } from "./plan-mode-children.js";
45
+ import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
46
+ import { createHomeResourceLoader } from "./resource-loader.js";
47
+ import { preloadSkills } from "./skill-loader.js";
48
+ import { isStaleCtxError } from "./stale-ctx.js";
49
+ import { createStructuredCapture, createStructuredOutputTool, structuredRetryPrompt } from "./structured-output.js";
50
+ import type { SubagentType, ThinkingLevel } from "./types.js";
51
+ import type { LifetimeUsage } from "./usage.js";
52
+ import type { CompiledSchema } from "./workflow/json-schema.js";
53
+
54
+ /**
55
+ * Tool names registered by THIS extension. Single source of truth so the
56
+ * registration sites (index.ts) and the subagent exclusion list below can't
57
+ * drift apart. These are our own tools, not pi built-ins, so they can't be
58
+ * derived from pi — but they only need defining once.
59
+ */
60
+ export const SUBAGENT_TOOL_NAMES = {
61
+ AGENT: "Agent",
62
+ WORKFLOW: "SubagentWorkflow",
63
+ GET_RESULT: "get_subagent_result",
64
+ STEER: "steer_subagent",
65
+ STOP: "stop_subagent",
66
+ } as const;
67
+
68
+ /**
69
+ * Workflow tools, ours and other extensions', that must fail inside a subagent
70
+ * with an explanation. The same names as `FOREIGN_WORKFLOW_TOOL_NAMES`, repeated
71
+ * because that module imports this one.
72
+ */
73
+ const BLOCKED_IN_SUBAGENT: ReadonlySet<string> = new Set([SUBAGENT_TOOL_NAMES.WORKFLOW, "Workflow", "workflow"]);
74
+
75
+ /**
76
+ * Stand-in for our own `SubagentWorkflow` in a child session, where the real
77
+ * tool is excluded. Without it a call never reaches `beforeToolCall` (core
78
+ * answers "Tool SubagentWorkflow not found" first), so the model gets no reason
79
+ * and retries; a foreign `workflow` tool is loaded in the child and does get
80
+ * one. The scope hook blocks the call with `NESTED_WORKFLOW_ERROR`; the throw
81
+ * covers a session without that hook.
82
+ */
83
+ function createWorkflowRefusalTool(reason: string) {
84
+ return defineTool({
85
+ name: SUBAGENT_TOOL_NAMES.WORKFLOW,
86
+ label: "SubagentWorkflow",
87
+ description: "Not available inside a subagent; calling it explains why.",
88
+ // Open object: whatever arguments the model sends, the refusal is the answer.
89
+ parameters: Type.Object({}),
90
+ execute: async () => {
91
+ throw new Error(reason);
92
+ },
93
+ });
94
+ }
95
+
96
+ /** Names of tools registered by this extension that subagents must NOT inherit. */
97
+ const EXCLUDED_TOOL_NAMES: string[] = Object.values(SUBAGENT_TOOL_NAMES);
98
+
99
+ /**
100
+ * Canonical name of an extension for `extensions: [...]` allowlist matching.
101
+ * Lowercased — extension names match case-insensitively so `extensions: [Mcp]`
102
+ * resolves the same as `[mcp]`. Tool names within `ext:foo/bar` are not affected.
103
+ * Directory extensions (`foo/index.ts`) resolve to the parent directory name;
104
+ * single-file extensions to the basename minus `.ts`/`.js`.
105
+ */
106
+ export function extensionCanonicalName(extPath: string): string {
107
+ const base = basename(extPath);
108
+ const name = base === "index.ts" || base === "index.js"
109
+ ? basename(dirname(extPath))
110
+ : base.replace(/\.(ts|js)$/, "");
111
+ return name.toLowerCase();
112
+ }
113
+
114
+ /**
115
+ * The unscoped, lowercased npm short name of the pi package that DECLARES
116
+ * `extPath` as an extension entry — or undefined if the entry doesn't belong to
117
+ * such a package.
118
+ *
119
+ * Climbs from the entry's directory looking for the package that owns it, and
120
+ * stays strictly within that package's tree by stopping at two structural
121
+ * boundaries — no hardcoded depth:
122
+ * - the FIRST `package.json` found (the package root); the entry's own
123
+ * manifest always sits at the root, above the entry, below any node_modules.
124
+ * - a `node_modules` directory: a package never spans one (it's where OTHER
125
+ * packages live), so reaching it means we've climbed out of the package —
126
+ * stop before reading a consumer's or parent package's manifest.
127
+ * The name is then taken only when that root's `pi.extensions` manifest actually
128
+ * lists this entry, either as the file itself or as a directory pi resolves to
129
+ * this `index.ts`/`index.js` (pi-web-access declares `./dist`). That "declares this entry" check is deliberate: our own test
130
+ * fixtures live under this repo, whose root manifest declares `./src/index.ts`
131
+ * as `@tintinweb/pi-subagents`, so a looser rule would misattribute every
132
+ * co-located file to `pi-subagents`.
133
+ */
134
+ function extensionPackageName(extPath: string): string | undefined {
135
+ // `builtin:<name>` names no file; climbing from it would read the process cwd's manifest.
136
+ if (extPath.startsWith("builtin:")) return undefined;
137
+ const entry = resolve(extPath);
138
+ const indexDir = /^index\.(ts|js)$/.test(basename(entry)) ? dirname(entry) : undefined;
139
+ let dir = dirname(extPath);
140
+ for (;;) {
141
+ // Climbing into node_modules means we've left the owning package's tree.
142
+ if (basename(dir) === "node_modules") return undefined;
143
+ let pkg: { name?: unknown; pi?: { extensions?: unknown } };
144
+ try {
145
+ pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
146
+ } catch {
147
+ const parent = dirname(dir);
148
+ if (parent === dir) return undefined; // walked to the filesystem root
149
+ dir = parent;
150
+ continue;
151
+ }
152
+ // First package.json wins — it's the package root; decide here.
153
+ const entries = pkg.pi?.extensions;
154
+ if (
155
+ typeof pkg.name === "string" &&
156
+ Array.isArray(entries) &&
157
+ entries.some((e) => {
158
+ if (typeof e !== "string") return false;
159
+ const declared = resolve(dir, e);
160
+ return declared === entry || declared === indexDir;
161
+ })
162
+ ) {
163
+ const short = pkg.name.startsWith("@") ? pkg.name.slice(pkg.name.indexOf("/") + 1) : pkg.name;
164
+ return short.toLowerCase();
165
+ }
166
+ return undefined;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * All names an extension answers to for allowlist matching (lowercased): its
172
+ * path-derived {@link extensionCanonicalName} plus, when a pi package manifest
173
+ * declares this entry, that package's unscoped short name (`@scope/foo` → `foo`).
174
+ * #143: an extension installed via `pi.extensions: ["./src/index.ts"]` would
175
+ * otherwise only ever match as `src` (the source directory), never by its
176
+ * package name. The path-derived name is preserved, so it keeps matching too.
177
+ */
178
+ export function extensionCanonicalNames(extPath: string): string[] {
179
+ const canonical = extensionCanonicalName(extPath);
180
+ const pkg = extensionPackageName(extPath);
181
+ return pkg && pkg !== canonical ? [canonical, pkg] : [canonical];
182
+ }
183
+
184
+ /**
185
+ * Classify `extensions: string[]` frontmatter entries for the loader-level filter.
186
+ *
187
+ * An entry is a PATH iff it contains a path separator or starts with `~`; otherwise
188
+ * it is a NAME. `"*"` sets the wildcard flag (keep all default-discovered extensions).
189
+ *
190
+ * Path entries are resolved (`~` expanded, made absolute against `cwd`) into `paths`
191
+ * — and their canonical name is also added to `names`. The loader override matches
192
+ * everything by canonical name, so path-loaded extensions are matched via their name
193
+ * rather than their post-staging `Extension.path`.
194
+ */
195
+ export function parseExtensionsSpec(
196
+ entries: string[],
197
+ cwd: string,
198
+ homeDir = homedir(),
199
+ ): { names: Set<string>; paths: string[]; wildcard: boolean } {
200
+ const names = new Set<string>();
201
+ const paths: string[] = [];
202
+ let wildcard = false;
203
+ for (const entry of entries) {
204
+ if (!entry) continue;
205
+ if (entry === "*") {
206
+ wildcard = true;
207
+ continue;
208
+ }
209
+ const isPathEntry = entry.includes("/") || entry.includes("\\") || entry.startsWith("~");
210
+ if (!isPathEntry) {
211
+ names.add(entry.toLowerCase());
212
+ continue;
213
+ }
214
+ // This frontmatter caller historically expands both slash forms on every
215
+ // platform, without interpreting file URLs or Windows shell drive forms.
216
+ let p = entry;
217
+ if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) p = homeDir + p.slice(1);
218
+ const abs = isAbsolute(p) ? p : resolve(cwd, p);
219
+ paths.push(abs);
220
+ names.add(extensionCanonicalName(abs));
221
+ }
222
+ return { names, paths, wildcard };
223
+ }
224
+
225
+ /**
226
+ * Parse raw `ext:` selector strings (from the `tools:` CSV) into the set of
227
+ * extension names to keep loaded and a per-extension tool-narrowing map.
228
+ *
229
+ * `ext:foo` → `extNames` has `foo`, no narrowing entry (all of foo's tools).
230
+ * `ext:foo/bar` → `extNames` has `foo`, `narrowing.foo` has `bar` (only `bar`).
231
+ * A name lands in `narrowing` only when a `/tool` form is seen, so a bare
232
+ * `ext:foo` alongside `ext:foo/bar` leaves narrowing in effect (narrowing wins).
233
+ * The split is on the first `/`; extension canonical names never contain `/`.
234
+ */
235
+ export function parseExtSelectors(entries: string[]): {
236
+ extNames: Set<string>;
237
+ narrowing: Map<string, Set<string>>;
238
+ } {
239
+ const extNames = new Set<string>();
240
+ const narrowing = new Map<string, Set<string>>();
241
+ for (const raw of entries) {
242
+ if (!raw) continue;
243
+ const body = raw.slice("ext:".length);
244
+ const slash = body.indexOf("/");
245
+ // Extension name matches case-insensitively (matches the loader-side canonical
246
+ // name). Tool names are case-preserved — they're matched against pi-mono's
247
+ // registered identifiers, which are case-sensitive.
248
+ const name = (slash === -1 ? body : body.slice(0, slash)).trim().toLowerCase();
249
+ if (!name) continue;
250
+ extNames.add(name);
251
+ if (slash === -1) continue;
252
+ const tool = body.slice(slash + 1).trim();
253
+ if (!tool) continue;
254
+ let set = narrowing.get(name);
255
+ if (!set) {
256
+ set = new Set();
257
+ narrowing.set(name, set);
258
+ }
259
+ set.add(tool);
260
+ }
261
+ return { extNames, narrowing };
262
+ }
263
+
264
+ /**
265
+ * Keep a subagent's tool scope correct as extensions register tools over time.
266
+ *
267
+ * Extensions may call `registerTool` long after load — pi-mcp from `session_start`,
268
+ * context-mode from `before_agent_start` — so scope has to be re-derived rather than
269
+ * snapshotted. `registerTool` writes into the very `extension.tools` maps this reads,
270
+ * so `inScope()` sees late arrivals on the next call.
271
+ *
272
+ * Two enforcement points, because neither covers the whole picture:
273
+ *
274
+ * - `turn_end` re-narrows the ACTIVE set. pi emits `turn_end` immediately before
275
+ * `prepareNextTurn` re-snapshots `agent.state.tools`, and session listeners run
276
+ * synchronously, so the narrow lands in time for turns 2..N.
277
+ * - `beforeToolCall` blocks out-of-scope calls. Turn 1 cannot be narrowed at all:
278
+ * `before_agent_start` fires INSIDE `prompt()` and may widen the tool set, but
279
+ * `createContextSnapshot()` freezes that turn's tools immediately after — there
280
+ * is no hook in between. A call-time check is the only correct guard there.
281
+ *
282
+ * Both are installed on the session and deliberately NOT unsubscribed: they must
283
+ * outlive the `runAgent` call so resumed/steered turns stay scoped. pi's `dispose()`
284
+ * clears `_eventListeners`, so they die with the session rather than leaking.
285
+ *
286
+ * Only meaningful when extensions are loaded — under `noExtensions`/`isolated` the
287
+ * static `allowedToolNames` allowlist already gates the registry itself.
288
+ */
289
+ export function installExtensionToolScope(
290
+ session: AgentSession,
291
+ ctx: {
292
+ loader: DefaultResourceLoader;
293
+ toolNames: string[];
294
+ disallowedSet: Set<string> | undefined;
295
+ extNames: Set<string>;
296
+ narrowing: Map<string, Set<string>>;
297
+ /**
298
+ * Injected `customTools` to keep active regardless of the built-in list.
299
+ *
300
+ * Two kinds arrive here and they are blocked for different reasons: opt-in
301
+ * nested-delegation tools share EXCLUDED_TOOL_NAMES' names, and
302
+ * StructuredOutput is simply not a built-in, so neither survives a `keep`
303
+ * seeded from `toolNames`.
304
+ */
305
+ readmitToolNames: Set<string>;
306
+ /** Receives the scope check the child's nested-call gate extension applies. */
307
+ nestedCallGate: NestedCallGate;
308
+ /** Why workflow tools are refused. */
309
+ workflowError: string;
310
+ },
311
+ ): void {
312
+ const { loader, toolNames, disallowedSet, extNames, narrowing, readmitToolNames, nestedCallGate, workflowError } = ctx;
313
+
314
+ // The names allowed right now. Mirrors the `ext:` opt-in flip: when any `ext:`
315
+ // selector is present, extension tools become an explicit allowlist — a loaded
316
+ // extension not named by a selector contributes nothing (its handlers still ran),
317
+ // and `ext:foo/bar` narrows `foo` to just `bar`.
318
+ const inScope = (): Set<string> => {
319
+ const keep = new Set(toolNames.filter((t) => !disallowedSet?.has(t)));
320
+ const optInActive = extNames.size > 0;
321
+ for (const extension of loader.getExtensions().extensions) {
322
+ const canons = extensionCanonicalNames(extension.path);
323
+ if (optInActive && !canons.some((c) => extNames.has(c))) continue;
324
+ // First alias that carries a narrowing set — a user won't narrow one
325
+ // extension under two different names, so first-match is correct.
326
+ const narrowed = canons.map((c) => narrowing.get(c)).find(Boolean);
327
+ for (const name of extension.tools.keys()) {
328
+ if (narrowed && !narrowed.has(name)) continue;
329
+ if (disallowedSet?.has(name)) continue;
330
+ keep.add(name);
331
+ }
332
+ }
333
+ for (const name of EXCLUDED_TOOL_NAMES) keep.delete(name);
334
+ // Injected tools are legitimately active for this agent — re-admit them so
335
+ // the renarrow keeps them in the active set and beforeToolCall doesn't
336
+ // block them. Already vetted against `disallowed_tools` by the caller,
337
+ // which is the only place that knows which kind may be taken back.
338
+ for (const name of readmitToolNames) keep.add(name);
339
+ return keep;
340
+ };
341
+
342
+ // Names this agent selects explicitly: its built-ins, injected tools, and
343
+ // `ext:foo/bar` narrowings. pi activates a named tool even when it is
344
+ // `defaultActive: false`, as `--tools` does.
345
+ const named = new Set([...toolNames, ...readmitToolNames, ...[...narrowing.values()].flatMap((s) => [...s])]);
346
+
347
+ // Activate what pi itself would: only tools declared to the model (`direct`/
348
+ // `model-only` exposure), and of those only named ones or ones not opted out
349
+ // with `defaultActive: false`. `codemode`/`deferred` tools (pi's MCP tools by
350
+ // default) stay undeclared until their extension or `tool_search` activates
351
+ // them — so an already-active tool is kept, or turn_end would undo that.
352
+ const renarrow = () => {
353
+ const allowed = inScope();
354
+ const current = session.getActiveToolNames();
355
+ const active = new Set(current);
356
+ const next = session
357
+ .getAllTools()
358
+ .filter(({ name, exposure = "direct" }) => {
359
+ if (!allowed.has(name)) return false;
360
+ if (active.has(name)) return true;
361
+ if (exposure !== "direct" && exposure !== "model-only") return false;
362
+ return named.has(name) || session.getToolDefinition(name)?.defaultActive !== false;
363
+ })
364
+ .map((t) => t.name);
365
+ // setActiveToolsByName unconditionally rebuilds the system prompt, so skip
366
+ // the no-op that steady-state turns would otherwise pay for every turn.
367
+ if (next.length !== current.length || next.some((n, i) => n !== current[i])) {
368
+ session.setActiveToolsByName(next);
369
+ }
370
+ };
371
+
372
+ // Activate what registered during session_start (eager MCP servers); pi would
373
+ // otherwise leave only its four default built-ins active at turn 1.
374
+ renarrow();
375
+
376
+ session.subscribe((event: AgentSessionEvent) => {
377
+ if (event.type === "turn_end") renarrow();
378
+ });
379
+
380
+ nestedCallGate.allows = (name) => inScope().has(name);
381
+
382
+ const priorBeforeToolCall = session.agent.beforeToolCall;
383
+ session.agent.beforeToolCall = async (context, signal) => {
384
+ // Ahead of the scope check so a workflow tool registered by another
385
+ // extension gets the reason, not "not available": it is loaded here like
386
+ // any extension tool, and would otherwise run and report to a dead session.
387
+ if (BLOCKED_IN_SUBAGENT.has(context.toolCall.name)) {
388
+ return { block: true, reason: workflowError };
389
+ }
390
+ if (!inScope().has(context.toolCall.name)) {
391
+ return {
392
+ block: true,
393
+ reason: `Tool "${context.toolCall.name}" is not available to this subagent.`,
394
+ };
395
+ }
396
+ return priorBeforeToolCall?.(context, signal);
397
+ };
398
+ }
399
+
400
+ /**
401
+ * Set once the child's tool scope (and, while planning, its Plan check) is
402
+ * installed; until then the gate admits everything.
403
+ */
404
+ type NestedCallGate = {
405
+ allows?: (name: string) => boolean;
406
+ planReason?: (toolName: string, input: unknown, cwd: string) => string | undefined;
407
+ };
408
+
409
+ /**
410
+ * Inline extension that applies the subagent's tool scope to calls a tool makes
411
+ * through `ctx.executeTool()` — codemode scripts calling MCP tools. pi runs those
412
+ * past extensions' `tool_call` handlers only, never `agent.beforeToolCall`, and
413
+ * every registered `codemode`/`deferred` tool is callable there whether or not
414
+ * it is in scope.
415
+ */
416
+ const SCOPE_GATE_NAME = "pi-subagents-scope";
417
+
418
+ function scopeGateExtension(gate: NestedCallGate): InlineExtension {
419
+ return {
420
+ name: SCOPE_GATE_NAME,
421
+ hidden: true,
422
+ factory: (ext) => {
423
+ ext.on("tool_call", (event, ctx) => {
424
+ if (gate.allows?.(event.toolName) === false) {
425
+ return { block: true, reason: `Tool "${event.toolName}" is not available to this subagent.` };
426
+ }
427
+ const reason = gate.planReason?.(event.toolName, event.input, ctx.cwd);
428
+ return reason === undefined ? undefined : { block: true, reason };
429
+ });
430
+ },
431
+ };
432
+ }
433
+
434
+ /** Default max turns. undefined = unlimited (no turn limit). */
435
+ let defaultMaxTurns: number | undefined;
436
+
437
+ /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
438
+ export function normalizeMaxTurns(n: number | undefined): number | undefined {
439
+ if (n == null || n === 0) return undefined;
440
+ return Math.max(1, n);
441
+ }
442
+
443
+ /** Get the default max turns value. undefined = unlimited. */
444
+ export function getDefaultMaxTurns(): number | undefined { return defaultMaxTurns; }
445
+ /** Set the default max turns value. undefined or 0 = unlimited, otherwise minimum 1. */
446
+ export function setDefaultMaxTurns(n: number | undefined): void { defaultMaxTurns = normalizeMaxTurns(n); }
447
+
448
+ /**
449
+ * The turn limit a run of `type` will actually enforce: an explicit value if the
450
+ * caller supplied one, else the agent's own `max_turns`, else the project
451
+ * default. `undefined` = unlimited.
452
+ *
453
+ * Exported because the widget's turn counter (`↻3≤20`) has to predict this
454
+ * before the run starts, and a second copy of the expression would drift from
455
+ * the one below that enforces it.
456
+ */
457
+ export function resolveEffectiveMaxTurns(type: string, explicit?: number): number | undefined {
458
+ return normalizeMaxTurns(explicit ?? getAgentConfig(type)?.maxTurns ?? defaultMaxTurns);
459
+ }
460
+
461
+ /**
462
+ * Project default for `persist_session`, from the `rememberAgents` setting.
463
+ * On by default: a persisted session is what lets `@handle` reopen an agent's
464
+ * conversation after its record has been evicted, which is the whole point of
465
+ * addressing an agent by a name that outlives one run. Per-agent frontmatter
466
+ * still overrides it in both directions.
467
+ */
468
+ let rememberAgents = true;
469
+
470
+ /** Whether subagent sessions are persisted by default. */
471
+ export function getRememberAgents(): boolean { return rememberAgents; }
472
+ /** Set whether subagent sessions are persisted by default. */
473
+ export function setRememberAgents(b: boolean): void { rememberAgents = b; }
474
+
475
+ /** Additional turns allowed after the soft limit steer message. */
476
+ let graceTurns = 5;
477
+
478
+ /** Get the grace turns value. */
479
+ export function getGraceTurns(): number { return graceTurns; }
480
+ /** Set the grace turns value (minimum 1). */
481
+ export function setGraceTurns(n: number): void { graceTurns = Math.max(1, n); }
482
+
483
+ /**
484
+ * Sent as a tool-less turn when a turn cap or its grace wrap-up fired, so the
485
+ * result is a report rather than whatever the agent last narrated before it
486
+ * was cut off ("Now let me check the tests...").
487
+ */
488
+ const FINAL_REPORT_PROMPT =
489
+ "You are out of turns and have no tools left. Do NOT call any tool or say what you would do next. "
490
+ + "Write your complete final report now: everything you found, concrete file paths and line numbers, "
491
+ + "what you changed or concluded, and anything unfinished or unverified.";
492
+
493
+ /** Info about a tool event in the subagent. */
494
+ export interface ToolActivity {
495
+ type: "start" | "end";
496
+ toolName: string;
497
+ }
498
+
499
+ /** Physical model identity carried by an assistant message. */
500
+ export interface AssistantMessageInfo {
501
+ api?: string;
502
+ provider?: string;
503
+ model?: string;
504
+ }
505
+
506
+ export interface RunOptions {
507
+ /** Backoff before each stale-ctx re-prompt (#145); defaults to 1s, 3s, 9s. `[]` disables recovery. */
508
+ staleRetryDelaysMs?: readonly number[];
509
+ /** Clock that times the stale-ctx backoff; the manager passes its own. Defaults to native timers. */
510
+ clock?: Clock;
511
+ /** ExtensionAPI instance — used for pi.exec() instead of execSync. */
512
+ pi: ExtensionAPI;
513
+ /** Manager-assigned id; suffixes session name to disambiguate parallel spawns (e.g. `Explore#a1b2c3d4`). */
514
+ agentId?: string;
515
+ model?: Model<any>;
516
+ /** Required admitted spawn outcome. The runner never resolves or admits models. */
517
+ modelSelection: ModelSelection;
518
+ maxTurns?: number;
519
+ signal?: AbortSignal;
520
+ /** Applies only when the agent file leaves `isolated:` unset — frontmatter is authoritative. */
521
+ isolated?: boolean;
522
+ inheritContext?: boolean;
523
+ thinkingLevel?: ThinkingLevel;
524
+ /**
525
+ * Reopen this pi session file rather than starting an empty conversation.
526
+ * `createAgentSession` seeds itself from whatever its SessionManager holds,
527
+ * so pointing it at an existing file rehydrates that agent's history and the
528
+ * prompt continues it. Everything else — tools, model, system prompt, turn
529
+ * caps — is still resolved from the agent type, so the continuation runs
530
+ * under the type's *current* definition, not the one the original run used.
531
+ */
532
+ resumeSessionFile?: string;
533
+ /**
534
+ * True when another agent spawned this one. Only top-level agents get a
535
+ * handle, so only they can be reopened by name — which is the whole reason
536
+ * `rememberAgents` persists a session at all. A nested run's transcript would
537
+ * be unreachable by anything, so it stays in memory unless its own
538
+ * frontmatter asks otherwise.
539
+ */
540
+ nested?: boolean;
541
+ /**
542
+ * True when a workflow run spawned this agent. Its final text is the value
543
+ * `agent()` resolves to rather than a report a person reads, and the prompt
544
+ * says so — but only when `structuredOutput` is unset, since that child
545
+ * already has a `StructuredOutput` tool to answer through and two competing
546
+ * "this is how you return your answer" instructions is worse than one.
547
+ */
548
+ workflow?: boolean;
549
+ /** Override working directory (e.g. for worktree isolation). */
550
+ cwd?: string;
551
+ /** Home directory for tilde expansion and user-scoped storage. Defaults at run time. */
552
+ homeDir?: string;
553
+ /**
554
+ * Directory the worktree copy was created from. Set only when `cwd` points
555
+ * into a worktree — the prompt then tells the agent to stay in the copy
556
+ * instead of following the inherited parent prompt back to the main tree.
557
+ */
558
+ worktreeBase?: string;
559
+ /**
560
+ * Where .pi config is discovered (project extensions, skills, pi settings,
561
+ * agent memory). Default: same as the working directory. The manager sets
562
+ * this to the parent session's cwd when `SpawnOptions.cwd` points the
563
+ * working directory elsewhere — the agent works *there* but carries the
564
+ * parent project's config (the target's `.pi` extensions never execute).
565
+ *
566
+ * WARNING for future callers: if you pass `cwd` pointing at a directory the
567
+ * user didn't open, you almost certainly must pass `configCwd` too —
568
+ * omitting it makes the target's `.pi` extensions execute in this process.
569
+ * (Worktree isolation is the one intentional exception: its copy IS the
570
+ * parent's repo, so config resolving inside it is correct.)
571
+ */
572
+ configCwd?: string;
573
+ /**
574
+ * Project-trust decision for `configCwd`, inherited from the spawning session.
575
+ * Honored only alongside an explicit `configCwd`: a nested spawn after a
576
+ * custom-cwd spawn runs under a child ctx that deliberately reports its own
577
+ * (execution) cwd as untrusted, so re-deriving from `ctx` would drop the
578
+ * config root's trust. Never marks the execution cwd trusted.
579
+ */
580
+ configProjectTrusted?: boolean;
581
+ /** Called on tool start/end with activity info. */
582
+ onToolActivity?: (activity: ToolActivity) => void;
583
+ /** Called on streaming text deltas from the assistant response. */
584
+ onTextDelta?: (delta: string, fullText: string) => void;
585
+ /** Called for every streaming event of each model call, so a UI can say what an in-flight call is doing. */
586
+ onModelCall?: (event: ModelCallEvent) => void;
587
+ onSessionCreated?: (session: AgentSession) => void;
588
+ /** Direct runner callers can observe the same configured fallback as the shared spawn. */
589
+ onModelFallback?: (fallback: ModelFallback) => void;
590
+ /** Called at the end of each agentic turn with the cumulative count. */
591
+ onTurnEnd?: (turnCount: number) => void;
592
+ /**
593
+ * Called once per assistant message_end with that message's usage delta.
594
+ * Lets callers maintain a lifetime accumulator that survives compaction
595
+ * (which replaces session.state.messages and resets stats-derived sums).
596
+ *
597
+ * `cost` is pi's own `usage.cost.total` for that message — priced from the
598
+ * model's rates, so it is 0 (not missing) for a model pi has no pricing for.
599
+ * We never price anything ourselves; every dollar figure this extension shows
600
+ * or reports traces back to this field.
601
+ */
602
+ onAssistantUsage?: (usage: LifetimeUsage, message: AssistantMessageInfo) => void;
603
+ /**
604
+ * Called when the session successfully compacts. `tokensBefore` is upstream's
605
+ * pre-compaction context size estimate. Aborted compactions don't fire.
606
+ */
607
+ onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
608
+ /**
609
+ * Make this child report through a `StructuredOutput` tool built from this
610
+ * schema, and put the validated payload on {@link RunResult.structuredJson}.
611
+ *
612
+ * Already compiled by the caller, so a schema this runtime cannot validate
613
+ * fails at the call that wrote it rather than inside the child.
614
+ */
615
+ structuredOutput?: CompiledSchema;
616
+ /**
617
+ * The Plan restriction for this child's runs (#437). Installed once on the
618
+ * session and read per call; the manager sets `policy` per run.
619
+ */
620
+ planGate?: PlanGate;
621
+ /** Runtime bridge for opt-in child-safe nested delegation. */
622
+ nestedRuntime?: {
623
+ manager: NestedAgentManager;
624
+ parentAgentId: string;
625
+ depth: number;
626
+ maxSubagentDepth?: number;
627
+ };
628
+ }
629
+
630
+ export interface RunResult {
631
+ responseText: string;
632
+ session: AgentSession;
633
+ /** True if the agent was hard-aborted (max_turns + grace exceeded). */
634
+ aborted: boolean;
635
+ /** True if the agent was steered to wrap up (hit soft turn limit) but finished in time. */
636
+ steered: boolean;
637
+ /**
638
+ * A failure message for the run's FINAL assistant turn, when that turn failed:
639
+ * a provider error (stopReason "error"), or a "length" stop that produced no
640
+ * text (a silent max-token death). pi resolves an exhausted-retries failure
641
+ * normally instead of rejecting, so without this the manager would report such
642
+ * a run as completed — with an empty result, or worse, an earlier turn's text
643
+ * presented as the answer (#144). Undefined for a clean stop, or a "length"
644
+ * stop that produced text (a legitimate truncated answer).
645
+ */
646
+ failure?: string;
647
+ /**
648
+ * The validated `StructuredOutput` payload as canonical JSON, when the caller
649
+ * asked for a schema and the child produced one.
650
+ *
651
+ * Deliberately not folded into {@link responseText}: `record.result` picks up
652
+ * a worktree branch note on the way out, which would leave the caller with
653
+ * unparseable JSON, and merging the two would make "produced structured
654
+ * output" indistinguishable from "happened to answer in JSON".
655
+ */
656
+ structuredJson?: string;
657
+ /** Whether the extra structured-output prompt had to be sent. */
658
+ structuredRetried?: boolean;
659
+ }
660
+
661
+ /**
662
+ * Subscribe to a session and collect the last assistant message text.
663
+ * Returns an object with a `getText()` getter and an `unsubscribe` function.
664
+ */
665
+ function collectResponseText(session: AgentSession) {
666
+ let text = "";
667
+ let laterTool = false;
668
+ let previousText = "";
669
+ let previousLaterTool = false;
670
+ const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
671
+ // message_start also fires for user and toolResult messages — resetting on
672
+ // those would wipe assistant text already collected. Reset only when a new
673
+ // ASSISTANT message begins, so getText() is the last assistant message's text.
674
+ if (event.type === "message_start" && event.message.role === "assistant") {
675
+ if (text.trim()) {
676
+ previousText = text;
677
+ previousLaterTool = laterTool;
678
+ }
679
+ text = "";
680
+ laterTool = false;
681
+ }
682
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
683
+ text += event.assistantMessageEvent.delta;
684
+ }
685
+ if (event.type === "tool_execution_end") {
686
+ if (text.trim()) laterTool = true;
687
+ else if (previousText.trim()) previousLaterTool = true;
688
+ }
689
+ });
690
+ return { getText: () => text, getPreviousText: () => previousText, hasLaterTool: () => text.trim() ? laterTool : previousLaterTool, unsubscribe };
691
+ }
692
+
693
+ /**
694
+ * Get the last non-empty assistant text produced during THIS invocation.
695
+ * `startIndex` is the message count captured before the prompt, so the walk-back
696
+ * never crosses into a previous turn: on a resume whose new turn failed empty,
697
+ * this returns "" instead of the prior turn's answer (#144). A fresh spawn passes
698
+ * 0, since the whole history belongs to that run.
699
+ */
700
+ function getLastAssistantText(session: AgentSession, startIndex: number): string {
701
+ for (let i = session.messages.length - 1; i >= startIndex; i--) {
702
+ const msg = session.messages[i];
703
+ if (msg.role !== "assistant") continue;
704
+ const text = extractText(msg.content).trim();
705
+ if (text) return text;
706
+ }
707
+ return "";
708
+ }
709
+
710
+ /**
711
+ * Translate a session event into the in-flight model call it belongs to. Only
712
+ * assistant messages count; user and tool-result messages are not model calls.
713
+ * Tool-call argument streaming counts as writing: the model is producing output.
714
+ */
715
+ export function forwardModelCall(event: AgentSessionEvent, emit: (event: ModelCallEvent) => void): void {
716
+ if (event.type === "message_start") {
717
+ if (event.message.role === "assistant") emit({ type: "start" });
718
+ } else if (event.type === "message_end") {
719
+ if (event.message.role === "assistant") emit({ type: "end" });
720
+ } else if (event.type === "message_update") {
721
+ const e = event.assistantMessageEvent;
722
+ if (e.type === "thinking_delta") emit({ type: "delta", kind: "thinking", chars: e.delta.length });
723
+ else if (e.type === "text_delta" || e.type === "toolcall_delta") emit({ type: "delta", kind: "writing", chars: e.delta.length });
724
+ else emit({ type: "beat" });
725
+ } else if (event.type === "tool_execution_start") {
726
+ // Tools run between calls: the call that requested them has ended.
727
+ emit({ type: "end" });
728
+ }
729
+ }
730
+
731
+ /**
732
+ * Whether THIS invocation's last assistant message is a finished answer: text,
733
+ * no tool call pending, and a stop that was not an abort, a tool-use handoff or
734
+ * a token-limit cut. Anything else is a teaser or a cut-off report.
735
+ */
736
+ function endedWithReport(session: AgentSession, startIndex: number): boolean {
737
+ for (let i = session.messages.length - 1; i >= startIndex; i--) {
738
+ const msg = session.messages[i];
739
+ if (msg.role !== "assistant") continue;
740
+ const hasToolCall = msg.content.some((c) => c.type === "toolCall");
741
+ const cutOff = msg.stopReason === "aborted" || msg.stopReason === "toolUse" || msg.stopReason === "length";
742
+ return !hasToolCall && !cutOff && extractText(msg.content).trim() !== "";
743
+ }
744
+ return false;
745
+ }
746
+
747
+ /** The reason a tool call gets during the forced report turn. */
748
+ const FINAL_REPORT_TOOL_BLOCKED = "No tools are available for the final report. Write the report as text now.";
749
+
750
+ /**
751
+ * Prompt the session once more with its tools switched off and ask for the full
752
+ * report. Returns whether that produced assistant text. Never throws: the run
753
+ * keeps whatever it had if the extra turn fails.
754
+ *
755
+ * Clearing the active set alone is not enough: `prompt()` fires
756
+ * `before_agent_start`, and an extension may re-activate its tools there
757
+ * (pi-deferred-context-engine re-pins its always-active spine), after which the
758
+ * turn's tools are snapshotted. So for this turn the loadout is pinned empty by
759
+ * shadowing `setActiveToolsByName` (the path `pi.setActiveTools` takes), and any
760
+ * call that still gets through (a tool activated by a registry refresh) is
761
+ * blocked with `terminate`, which ends the loop after that batch. The caller's
762
+ * turn hook is the hard stop behind both.
763
+ */
764
+ async function forceFinalReport(session: AgentSession, startIndex: number, signal?: AbortSignal): Promise<boolean> {
765
+ const previousTools = session.getActiveToolNames();
766
+ const setActiveTools = session.setActiveToolsByName;
767
+ const priorBeforeToolCall = session.agent.beforeToolCall;
768
+ try {
769
+ await session.waitForIdle?.();
770
+ // A stop can arrive while an aborted tool batch drains. Never start a new
771
+ // prompt after that stop just because the report was already scheduled.
772
+ if (signal?.aborted) return false;
773
+ setActiveTools.call(session, []);
774
+ session.setActiveToolsByName = () => setActiveTools.call(session, []);
775
+ session.agent.beforeToolCall = async () => ({ block: true, terminate: true, reason: FINAL_REPORT_TOOL_BLOCKED });
776
+ await session.prompt(FINAL_REPORT_PROMPT);
777
+ } catch {
778
+ return false;
779
+ } finally {
780
+ session.setActiveToolsByName = setActiveTools;
781
+ session.agent.beforeToolCall = priorBeforeToolCall;
782
+ session.setActiveToolsByName(previousTools);
783
+ }
784
+ return endedWithReport(session, startIndex);
785
+ }
786
+
787
+ /**
788
+ * Error message of THIS invocation's final assistant message, when that turn
789
+ * failed. Two failure shapes, both keyed off how the final turn STOPPED:
790
+ * - stopReason "error": a provider failure pi resolved instead of rejecting
791
+ * (any text; partial output is surfaced separately).
792
+ * - stopReason "length" with NO text: a silent max-token death — the run hit
793
+ * the output-token ceiling before writing anything, which would otherwise
794
+ * land as a "completed" run with an empty result (the #144 symptom).
795
+ * Everything else completes: a clean "stop"/"toolUse" final, and — crucially — a
796
+ * "length" stop that DID produce text (a legitimate truncated-but-useful answer).
797
+ * "aborted" is handled by the manager's abort flag / "stopped" guard, not here.
798
+ * Bounded by `startIndex` (like the text fallback) so a resume that produced no
799
+ * assistant message of its own never inherits a PRIOR turn's stop reason.
800
+ */
801
+ function finalTurnError(session: AgentSession, startIndex: number): string | undefined {
802
+ for (let i = session.messages.length - 1; i >= startIndex; i--) {
803
+ const msg = session.messages[i];
804
+ if (msg.role !== "assistant") continue;
805
+ if (msg.stopReason === "error") {
806
+ return (msg as { errorMessage?: string }).errorMessage?.trim() || "provider error with no output";
807
+ }
808
+ if (msg.stopReason === "length" && !extractText(msg.content).trim()) {
809
+ return "run hit the output token limit before producing any text";
810
+ }
811
+ return undefined;
812
+ }
813
+ return undefined;
814
+ }
815
+
816
+ /** Waits before each re-prompt of a step lost to a stale ctx: a reload re-registers within seconds. */
817
+ const STALE_CTX_RETRY_DELAYS_MS: readonly number[] = [1_000, 3_000, 9_000];
818
+
819
+ /** Sent when the session's last step died on a stale ctx; the transcript already holds the work so far. */
820
+ const STALE_CTX_RETRY_PROMPT = "Your previous step was interrupted by a session reload before it produced output. Continue the task from where you left off.";
821
+
822
+ /**
823
+ * Resolve after `ms`, or as soon as `signal` aborts. Never rejects.
824
+ */
825
+ function sleep(ms: number, signal: AbortSignal | undefined, clock: Clock): Promise<void> {
826
+ return new Promise((resolve) => {
827
+ if (signal?.aborted) return resolve();
828
+ const done = () => {
829
+ timer.cancel();
830
+ signal?.removeEventListener("abort", done);
831
+ resolve();
832
+ };
833
+ const timer = clock.setTimeout(done, ms);
834
+ signal?.addEventListener("abort", done, { once: true });
835
+ });
836
+ }
837
+
838
+ /**
839
+ * Re-prompt a session whose final step died on a stale extension ctx, backing
840
+ * off between attempts so a reload in progress can re-register its routes.
841
+ * Returns once the final step no longer fails that way, retries are spent, or
842
+ * `signal` aborts. A permanently dead runtime (the parent session was replaced)
843
+ * exhausts the retries and is reported by `staleCtxOutcome`.
844
+ *
845
+ * Why appending a continue prompt is safe, and re-sending the task is not: the
846
+ * failed step died in model routing or an observer, before the model produced
847
+ * anything, so the transcript already holds every tool call and result the run
848
+ * made. Re-sending the task would show the model the same work twice and invite
849
+ * it to repeat side effects (a push, a PR). The continue prompt adds one user
850
+ * turn and nothing else, so the tool batch is never replayed; a tool that did
851
+ * run is in context as a result.
852
+ *
853
+ * Kept for stale-ctx failures that routing no longer causes itself: core now hands
854
+ * `route` the requesting session's context (#581), so another session's reload or
855
+ * disposal cannot invalidate this request, but an extension callback that captured
856
+ * a dead ctx can still throw, and only this turns that into a bounded retry and a report.
857
+ */
858
+ export async function retryStaleCtx(
859
+ session: AgentSession,
860
+ startIndex: number,
861
+ signal: AbortSignal | undefined,
862
+ delays: readonly number[] = STALE_CTX_RETRY_DELAYS_MS,
863
+ clock: Clock = realClock,
864
+ ): Promise<void> {
865
+ for (const delay of delays) {
866
+ if (!isStaleCtxError(finalTurnError(session, startIndex))) return;
867
+ await sleep(delay, signal, clock);
868
+ if (signal?.aborted) return;
869
+ await session.prompt(STALE_CTX_RETRY_PROMPT);
870
+ }
871
+ }
872
+
873
+ /**
874
+ * Whether a follow-up prompt (the structured-output retry, the forced final
875
+ * report) may still be sent. Not once the run was stopped, and not while the
876
+ * final step is still a stale ctx: the runtime is dead, so the prompt would
877
+ * fail the same way and mask the real outcome behind a doomed turn.
878
+ */
879
+ function mayFollowUp(session: AgentSession, startIndex: number, signal: AbortSignal | undefined): boolean {
880
+ return !signal?.aborted && staleCtxOutcome(session, startIndex) === undefined;
881
+ }
882
+
883
+ /** Bounded activity only: tool arguments, results and unknown names are never copied. */
884
+ function partialProgress(session: AgentSession, startIndex: number): string {
885
+ const calls: string[] = [];
886
+ for (let i = session.messages.length - 1; i >= startIndex && calls.length < 5; i--) {
887
+ const msg = session.messages[i];
888
+ if (msg.role !== "toolResult") continue;
889
+ const name = BUILTIN_TOOL_NAMES.includes(msg.toolName) ? msg.toolName : "other tool";
890
+ calls.unshift(`${name} (${msg.isError ? "failed" : "completed"})`);
891
+ }
892
+ return calls.length ? `Latest activity: ${calls.join(", ")}.` : "";
893
+ }
894
+
895
+ /** Mark prose as earlier progress when subsequent tool results exist. */
896
+ function failureText(session: AgentSession, startIndex: number, text: string, partial: string, laterTool: boolean, streamed: boolean): string {
897
+ if (!text) return partial;
898
+ if (streamed) return laterTool ? `Earlier progress:\n${text}\n\n${partial}` : text;
899
+ let hasLaterResult = false;
900
+ for (let i = session.messages.length - 1; i >= startIndex; i--) {
901
+ const msg = session.messages[i];
902
+ if (msg.role === "assistant" && extractText(msg.content).trim() === text) break;
903
+ if (msg.role === "toolResult") hasLaterResult = true;
904
+ }
905
+ return hasLaterResult ? `Earlier progress:\n${text}\n\n${partial}` : text;
906
+ }
907
+
908
+ /**
909
+ * The failure and partial result for a run whose final step is still a stale
910
+ * ctx after retries; undefined for any other outcome.
911
+ */
912
+ export function staleCtxOutcome(session: AgentSession, startIndex: number): { failure: string; partial: string } | undefined {
913
+ const error = finalTurnError(session, startIndex);
914
+ if (!isStaleCtxError(error)) return undefined;
915
+ return {
916
+ failure: `interrupted by a session reload or replacement; the agent's model runtime went stale and retries did not recover it: ${error}`,
917
+ partial: partialProgress(session, startIndex),
918
+ };
919
+ }
920
+
921
+ /** The per-run callbacks a spawn and a resume share, plus the resolved turn limit. */
922
+ type RunObserverOptions = Pick<RunOptions, "onTurnEnd" | "onTextDelta" | "onModelCall" | "onToolActivity" | "onAssistantUsage" | "onCompaction"> & {
923
+ /** Already resolved: undefined = unlimited. */
924
+ maxTurns?: number;
925
+ };
926
+
927
+ /**
928
+ * Observe one prompt of a session — a spawn's or a resume's — and enforce its
929
+ * turn limit: steer once at `maxTurns`, hard-abort after the grace turns.
930
+ * `steered` / `aborted` are live fields, read while the prompt is still in
931
+ * flight (the structured-output retry) and after it settles (the result).
932
+ * Shared by `runAgent` and `resumeAgent` so a resumed run is bounded exactly
933
+ * like the first one (#368).
934
+ */
935
+ function observeRun(session: AgentSession, options: RunObserverOptions) {
936
+ let turnCount = 0;
937
+ let currentMessageText = "";
938
+ let finalizingTurns = 0;
939
+ const run = {
940
+ steered: false,
941
+ aborted: false,
942
+ finalizing: false,
943
+ };
944
+ const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
945
+ if (event.type === "turn_end") {
946
+ turnCount++;
947
+ options.onTurnEnd?.(turnCount);
948
+ if (run.finalizing) {
949
+ // The report gets one tool-free turn, even if an extension promotes tools.
950
+ finalizingTurns++;
951
+ if (finalizingTurns > 1 || (event.toolResults?.length ?? 0) > 0) {
952
+ run.aborted = true;
953
+ session.abort();
954
+ }
955
+ } else if (options.maxTurns != null) {
956
+ if (!run.steered && turnCount >= options.maxTurns) {
957
+ run.steered = true;
958
+ session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
959
+ } else if (run.steered && turnCount >= options.maxTurns + graceTurns) {
960
+ run.aborted = true;
961
+ session.abort();
962
+ }
963
+ }
964
+ }
965
+ if (options.onModelCall) forwardModelCall(event, options.onModelCall);
966
+ if (event.type === "message_start") {
967
+ currentMessageText = "";
968
+ }
969
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
970
+ currentMessageText += event.assistantMessageEvent.delta;
971
+ options.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText);
972
+ }
973
+ if (event.type === "tool_execution_start") {
974
+ options.onToolActivity?.({ type: "start", toolName: event.toolName });
975
+ }
976
+ if (event.type === "tool_execution_end") {
977
+ options.onToolActivity?.({ type: "end", toolName: event.toolName });
978
+ }
979
+ if (event.type === "message_end" && event.message.role === "assistant") {
980
+ const u = (event.message as any).usage;
981
+ if (u) options.onAssistantUsage?.({
982
+ input: u.input ?? 0,
983
+ output: u.output ?? 0,
984
+ cacheWrite: u.cacheWrite ?? 0,
985
+ cacheRead: u.cacheRead ?? 0,
986
+ cost: u.cost?.total ?? 0,
987
+ }, event.message as AssistantMessageInfo);
988
+ }
989
+ if (event.type === "compaction_end" && !event.aborted && event.result) {
990
+ options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
991
+ }
992
+ });
993
+ // Keep the live state object captured by the listener, adding only the real cleanup.
994
+ return Object.assign(run, { unsubscribe });
995
+ }
996
+
997
+ /**
998
+ * Wire an AbortSignal to abort a session.
999
+ * Returns a cleanup function to remove the listener.
1000
+ */
1001
+ function forwardAbortSignal(session: AgentSession, signal?: AbortSignal): () => void {
1002
+ if (!signal) return () => {};
1003
+ const onAbort = () => session.abort();
1004
+ signal.addEventListener("abort", onAbort, { once: true });
1005
+ return () => signal.removeEventListener("abort", onAbort);
1006
+ }
1007
+
1008
+ function resolveConfiguredSessionDir(sessionDir: string | undefined, cwd: string, homeDir = homedir()): string | undefined {
1009
+ if (!sessionDir) return undefined;
1010
+ if (sessionDir === "~" || sessionDir.startsWith("~/")) return resolve(homeDir, sessionDir.slice(2));
1011
+ if (isAbsolute(sessionDir)) return sessionDir;
1012
+ return resolve(cwd, sessionDir);
1013
+ }
1014
+
1015
+ export async function runAgent(
1016
+ ctx: ExtensionContext,
1017
+ type: SubagentType,
1018
+ prompt: string,
1019
+ options: RunOptions,
1020
+ ): Promise<RunResult> {
1021
+ const config = getConfig(type);
1022
+ const agentConfig = getAgentConfig(type);
1023
+
1024
+ // Resolve working directory: worktree override > parent cwd
1025
+ const effectiveCwd = options.cwd ?? ctx.cwd;
1026
+ // Filesystem work happens in effectiveCwd; config discovery in configCwd.
1027
+ // They differ only for SpawnOptions.cwd spawns (config stays with the parent).
1028
+ const configCwd = options.configCwd ?? effectiveCwd;
1029
+
1030
+ const env = await detectEnv(options.pi, effectiveCwd);
1031
+
1032
+ // Get parent system prompt for append-mode agents
1033
+ const parentSystemPrompt = ctx.getSystemPrompt();
1034
+ const agentDir = resolveAgentDir(options.homeDir);
1035
+ // The child inherits the parent's project-trust decision. Pi otherwise defaults
1036
+ // SettingsManager to trusted, which could load project MCP configuration; resolve
1037
+ // it before resource loading so untrusted skills cannot shadow trusted resources.
1038
+ const projectTrusted = options.configCwd !== undefined && options.configProjectTrusted !== undefined
1039
+ ? options.configProjectTrusted
1040
+ : ctx.isProjectTrusted();
1041
+ const settingsManager = SettingsManager.create(configCwd, agentDir, { projectTrusted });
1042
+
1043
+ // Build prompt extras (memory, skill preloading)
1044
+ const extras: PromptExtras = {};
1045
+ if (options.worktreeBase) extras.worktreeBase = options.worktreeBase;
1046
+ if (options.workflow && !options.structuredOutput) extras.workflowChild = true;
1047
+ if (options.planGate?.policy) extras.planMode = true;
1048
+
1049
+ // Resolved here, not by each caller: mention and RPC spawns pass no `isolated`,
1050
+ // and the agent file's value must hold on every path.
1051
+ const isolated = agentConfig?.isolated ?? options.isolated;
1052
+ // Resolve extensions/skills: isolated overrides to false
1053
+ const extensions = isolated ? false : config.extensions;
1054
+ // Nulling excludes under isolated also suppresses the orphaned-exclude warning —
1055
+ // isolation is an intentional override, not a misconfiguration.
1056
+ const excludeExtensions = isolated ? undefined : config.excludeExtensions;
1057
+ const skills = isolated ? false : config.skills;
1058
+
1059
+ // Skill preloading: when skills is string[], preload their content into prompt
1060
+ if (Array.isArray(skills)) {
1061
+ const loaded = await preloadSkills(skills, configCwd, { agentDir, settingsManager, homeDir: options.homeDir });
1062
+ if (loaded.length > 0) {
1063
+ extras.skillBlocks = loaded;
1064
+ }
1065
+ }
1066
+
1067
+ let toolNames = getToolNamesForType(type);
1068
+
1069
+ // Persistent memory: detect write capability and branch accordingly.
1070
+ // Account for disallowedTools — a tool in the base set but on the denylist is not truly available.
1071
+ if (agentConfig?.memory) {
1072
+ const existingNames = new Set(toolNames);
1073
+ const denied = agentConfig.disallowedTools ? new Set(agentConfig.disallowedTools) : undefined;
1074
+ const effectivelyHas = (name: string) => existingNames.has(name) && !denied?.has(name);
1075
+ const hasWriteTools = effectivelyHas("write") || effectivelyHas("edit");
1076
+
1077
+ if (hasWriteTools) {
1078
+ // Read-write memory: add any missing memory tool names (read/write/edit)
1079
+ const extraNames = getMemoryToolNames(existingNames);
1080
+ if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
1081
+ extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, configCwd, options.homeDir);
1082
+ } else {
1083
+ // Read-only memory: only add read tool name, use read-only prompt
1084
+ const extraNames = getReadOnlyMemoryToolNames(existingNames);
1085
+ if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
1086
+ extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, configCwd, options.homeDir);
1087
+ }
1088
+ }
1089
+
1090
+ // At (or past) the cap this agent can never spawn, so it can never own a child
1091
+ // to fetch from or steer either — inject nothing rather than three tools whose
1092
+ // every call is an error. This is also what makes `maxSubagentDepth` 0/1 mean
1093
+ // "nesting off" instead of "nesting always fails". Resolved before the system
1094
+ // prompt because an agent that asked for subagents and was refused is told so.
1095
+ const effectiveMaxDepth = options.nestedRuntime?.maxSubagentDepth ?? getMaxSubagentDepth();
1096
+ const nestedRuntime = options.nestedRuntime && options.nestedRuntime.depth < effectiveMaxDepth
1097
+ ? options.nestedRuntime
1098
+ : undefined;
1099
+ // One answer for the prompt and the workflow refusal: does this child end up with `Agent`?
1100
+ // An allowlist that resolves to no enabled agent leaves Agent nothing to
1101
+ // accept; omit the tools rather than advertise "none" as a type (#331, #333).
1102
+ const nestingPossible = Boolean(
1103
+ agentConfig?.allowedSubagents && nestedRuntime && !isolated
1104
+ && hasNestableTypes(agentConfig.allowedSubagents, { homeDir: options.homeDir, configCwd, projectTrusted }),
1105
+ );
1106
+ const agentAvailable = nestingPossible && !agentConfig?.disallowedTools?.includes("Agent");
1107
+ if (agentConfig?.allowedSubagents && !agentAvailable) extras.nestingDisabled = true;
1108
+
1109
+ // Build system prompt from agent config
1110
+ let systemPrompt: string;
1111
+ if (agentConfig) {
1112
+ systemPrompt = buildAgentPrompt(agentConfig, effectiveCwd, env, parentSystemPrompt, extras);
1113
+ } else {
1114
+ // Unknown type fallback: spread the canonical general-purpose config (defensive —
1115
+ // unreachable in practice since index.ts resolves unknown types before calling runAgent).
1116
+ const fallback = DEFAULT_AGENTS.get("general-purpose");
1117
+ if (!fallback) throw new Error(`No fallback config available for unknown type "${type}"`);
1118
+ systemPrompt = buildAgentPrompt({ ...fallback, name: type }, effectiveCwd, env, parentSystemPrompt, extras);
1119
+ }
1120
+
1121
+ // When skills is string[], we've already preloaded them into the prompt.
1122
+ // Still pass noSkills: true since we don't need the skill loader to load them again.
1123
+ const noSkills = skills === false || Array.isArray(skills);
1124
+
1125
+ // Extension loading:
1126
+ // - true → all default-discovered extensions
1127
+ // - false → none (noExtensions)
1128
+ // - string[] → loader-level allowlist. Bare names keep the matching
1129
+ // default-discovered extension; path entries load that extension fresh;
1130
+ // "*" keeps all default-discovered extensions. Excluded extensions never
1131
+ // bind handlers or register tools (their factory still runs once).
1132
+ //
1133
+ // Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md — upstream's
1134
+ // buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
1135
+ // would defeat prompt_mode: replace and isolated: true. Parent context, if
1136
+ // wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt
1137
+ // is embedded in systemPromptOverride) or inherit_context (conversation).
1138
+ // `ext:` selectors from the `tools:` CSV narrow which extension tools surface to
1139
+ // the LLM. They do NOT control loading — `extensions:` is the sole authority for
1140
+ // which extensions load. `ext:foo` against an extension that `extensions:` excluded
1141
+ // is an orphan and warns after reload. `isolated` means no extension tools at all.
1142
+ const { extNames, narrowing } = parseExtSelectors(
1143
+ isolated ? [] : (agentConfig?.extSelectors ?? []),
1144
+ );
1145
+ const noExtensions = extensions === false;
1146
+
1147
+ const extensionsSpec = Array.isArray(extensions)
1148
+ ? parseExtensionsSpec(extensions, configCwd, options.homeDir)
1149
+ : undefined;
1150
+ const keepNames = extensionsSpec?.names ?? new Set<string>();
1151
+ // `exclude_extensions:` is a denylist applied AFTER the include set — exclude wins.
1152
+ // Plain canonical names only (case-insensitive). Note: excluded extensions'
1153
+ // factories still run once during reload() (see comment above) — exclusion
1154
+ // suppresses handler binding and tool registration; it is not a sandbox.
1155
+ const excludeNames = new Set((excludeExtensions ?? []).map((n) => n.toLowerCase()));
1156
+ const hasExcludes = excludeNames.size > 0;
1157
+ // The override filters loaded extensions down to `keepNames` minus `excludeNames`.
1158
+ // It's only needed when we're neither loading everything without excludes
1159
+ // (`extensions: true` or a `"*"` wildcard) nor nothing (`noExtensions`).
1160
+ const loadAll = extensions === true || extensionsSpec?.wildcard === true;
1161
+ const additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined;
1162
+ // Pre-filter discovered set, captured by the override — the exclude-typo warning
1163
+ // must compare against this, not the surviving set (absence from survivors is
1164
+ // an exclude *succeeding*).
1165
+ let discoveredNames: Set<string> | undefined;
1166
+ const extensionsOverride: ((base: LoadExtensionsResult) => LoadExtensionsResult) | undefined =
1167
+ noExtensions || (loadAll && !hasExcludes)
1168
+ ? undefined
1169
+ : (base) => {
1170
+ discoveredNames = new Set(base.extensions.flatMap((e) => extensionCanonicalNames(e.path)));
1171
+ return {
1172
+ ...base,
1173
+ extensions: base.extensions.filter((e) => {
1174
+ if (e.path === `<inline:${SCOPE_GATE_NAME}>`) return true; // ours, not the agent's
1175
+ const canons = extensionCanonicalNames(e.path);
1176
+ if (canons.some((n) => excludeNames.has(n))) return false; // exclude wins
1177
+ return loadAll || canons.some((n) => keepNames.has(n));
1178
+ }),
1179
+ };
1180
+ };
1181
+
1182
+ // That trust covers the parent's project (`configCwd`), not a directory a spawn
1183
+ // was sent into. The session answers `ctx.isProjectTrusted()` for its own cwd,
1184
+ // which pi's MCP uses to read `<cwd>/.pi/mcp.json` — so a cross-directory session
1185
+ // keeps the parent project's settings but reports its cwd as untrusted. The
1186
+ // loader keeps the trusted manager: it loads the parent project's extensions.
1187
+ const sessionSettingsManager =
1188
+ resolve(effectiveCwd) === resolve(configCwd)
1189
+ ? settingsManager
1190
+ : Object.assign(SettingsManager.create(configCwd, agentDir, { projectTrusted }), {
1191
+ isProjectTrusted: () => false,
1192
+ });
1193
+ const nestedCallGate: NestedCallGate = {};
1194
+
1195
+ const loader = createHomeResourceLoader({
1196
+ settingsManager,
1197
+ // pi's CLI hands its built-in extensions to the loader it builds; this one starts
1198
+ // with none. Forwarded as `builtin:` resources they load as in the main session —
1199
+ // default-on, disabled by `-builtin:<name>` in settings — and the per-agent
1200
+ // allowlist filters them like any other extension. pi's `llama.cpp` built-in is
1201
+ // CLI-only (not exported). Fresh factories per spawn: MCP state lives in the closure.
1202
+ extensionFactories: [
1203
+ { name: "codemode", factory: createCodemodeExtension(), builtin: true, replaceable: true },
1204
+ { name: "tool-search", factory: createToolSearchExtension(), builtin: true, replaceable: true },
1205
+ { name: "mcp", factory: createMcpExtension(), builtin: true, replaceable: true },
1206
+ // Under `noExtensions` the static allowlist gates the registry itself.
1207
+ ...(noExtensions ? [] : [scopeGateExtension(nestedCallGate)]),
1208
+ ],
1209
+ // Mirrors pi's CLI: `--no-mcp` keeps MCP out of subagents too, and `--no-extensions`
1210
+ // keeps all the built-ins out. Discovered extensions still follow `extensions:`.
1211
+ disabledBuiltinExtensions: process.argv.some((a) => a === "--no-extensions" || a === "-ne")
1212
+ ? ["codemode", "tool-search", "mcp"]
1213
+ : process.argv.includes("--no-mcp")
1214
+ ? ["mcp"]
1215
+ : undefined,
1216
+ cwd: configCwd,
1217
+ agentDir,
1218
+ noExtensions,
1219
+ additionalExtensionPaths,
1220
+ extensionsOverride,
1221
+ noSkills,
1222
+ noPromptTemplates: true,
1223
+ noThemes: true,
1224
+ noContextFiles: true,
1225
+ systemPromptOverride: () => systemPrompt,
1226
+ appendSystemPromptOverride: () => [],
1227
+ }, options.homeDir);
1228
+ await bindChildResourceLoader(loader).reload();
1229
+
1230
+ // Plain entries in `tools:` are expected to be built-in names (extension tools
1231
+ // go through `ext:`), so an unknown name there is unambiguously a typo. Previously
1232
+ // this produced a silently broken agent (#75) — pi-mono accepted the bogus name
1233
+ // into the allowlist, then dropped it at registration with no signal back.
1234
+ if (agentConfig?.builtinToolNames?.length) {
1235
+ const knownBuiltins = new Set(BUILTIN_TOOL_NAMES);
1236
+ for (const name of agentConfig.builtinToolNames) {
1237
+ if (!knownBuiltins.has(name)) {
1238
+ options.onToolActivity?.({
1239
+ type: "end",
1240
+ toolName: `tools-error:tool "${name}" requested by agent "${type}" is not a known built-in`,
1241
+ });
1242
+ }
1243
+ }
1244
+ }
1245
+
1246
+ // A subagent spawns mid-task, so a bad `extensions:`/`ext:` entry warns rather
1247
+ // than aborts. Two distinct misconfigurations to catch:
1248
+ // - `extensions: [foo]` but no extension named foo was discovered (typo or
1249
+ // path that failed to load — path entries fold their canonical name into
1250
+ // `keepNames`, so this covers them too).
1251
+ // - `tools: ext:foo` but foo isn't in the loaded set (because `extensions:`
1252
+ // didn't include it). Since v0.9, `ext:` no longer pulls extensions in;
1253
+ // loading is `extensions:`-authoritative.
1254
+ // An exclude_extensions: alongside extensions: false is contradictory — nothing
1255
+ // loads, so there is nothing to exclude.
1256
+ if (hasExcludes && noExtensions) {
1257
+ options.onToolActivity?.({
1258
+ type: "end",
1259
+ toolName: `extension-error:exclude_extensions has no effect for agent "${type}" — extensions: false loads nothing`,
1260
+ });
1261
+ }
1262
+ // Exclude typo check: compares against the PRE-filter discovered set (an excluded
1263
+ // name absent from the surviving set is the exclude working as intended). Also
1264
+ // flags path-like and "*" entries — excludes are plain names only.
1265
+ if (hasExcludes && discoveredNames) {
1266
+ for (const name of excludeNames) {
1267
+ if (!discoveredNames.has(name)) {
1268
+ options.onToolActivity?.({
1269
+ type: "end",
1270
+ toolName: `extension-error:exclude_extensions: "${name}" for agent "${type}" did not match any discovered extension`,
1271
+ });
1272
+ }
1273
+ }
1274
+ }
1275
+ if (keepNames.size > 0 || extNames.size > 0) {
1276
+ const survivingNames = new Set(
1277
+ loader.getExtensions().extensions.flatMap((e) => extensionCanonicalNames(e.path)),
1278
+ );
1279
+ for (const name of keepNames) {
1280
+ if (!survivingNames.has(name)) {
1281
+ options.onToolActivity?.({
1282
+ type: "end",
1283
+ toolName: excludeNames.has(name)
1284
+ ? `extension-error:extension "${name}" is in both extensions: and exclude_extensions: for agent "${type}" — exclude wins`
1285
+ : `extension-error:extension "${name}" requested by agent "${type}" was not loaded`,
1286
+ });
1287
+ }
1288
+ }
1289
+ for (const name of extNames) {
1290
+ if (!survivingNames.has(name)) {
1291
+ options.onToolActivity?.({
1292
+ type: "end",
1293
+ toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (check extensions:/exclude_extensions:)`,
1294
+ });
1295
+ }
1296
+ }
1297
+ }
1298
+
1299
+ const model = options.modelSelection.model;
1300
+ if (options.modelSelection.fallback) options.onModelFallback?.(options.modelSelection.fallback);
1301
+
1302
+ // Resolve thinking level: explicit option > agent config > undefined (inherit)
1303
+ const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
1304
+
1305
+ const disallowedSet = agentConfig?.disallowedTools
1306
+ ? new Set(agentConfig.disallowedTools)
1307
+ : undefined;
1308
+
1309
+ // Nested delegation tools (opt-in, ownership-scoped). Empty unless the agent
1310
+ // set `allowed_subagents` and a nestedRuntime was provided — and never when
1311
+ // isolated. Their names collide with EXCLUDED_TOOL_NAMES by design, so the
1312
+ // scoping below re-admits them explicitly (registry deny + active-set narrow).
1313
+ const nestedTools = agentConfig?.allowedSubagents && nestedRuntime && nestingPossible
1314
+ ? createNestedSubagentTools({
1315
+ manager: nestedRuntime.manager,
1316
+ pi: options.pi,
1317
+ parentAgentId: nestedRuntime.parentAgentId,
1318
+ depth: nestedRuntime.depth,
1319
+ maxSubagentDepth: effectiveMaxDepth,
1320
+ allowedSubagents: agentConfig.allowedSubagents,
1321
+ homeDir: options.homeDir,
1322
+ configCwd,
1323
+ projectTrusted,
1324
+ })
1325
+ : [];
1326
+ const nestedToolNames = new Set(nestedTools.map(tool => tool.name));
1327
+
1328
+ // The `agent({ schema })` contract: this child reports its answer by calling
1329
+ // StructuredOutput, and `structuredJson` below is what the caller reads. The
1330
+ // schema was already compiled by whoever asked for it, so a bad one failed
1331
+ // before any of this ran.
1332
+ const structuredCapture = options.structuredOutput ? createStructuredCapture() : undefined;
1333
+ const structuredTools = options.structuredOutput && structuredCapture
1334
+ ? [createStructuredOutputTool(options.structuredOutput, structuredCapture)]
1335
+ : [];
1336
+ const structuredToolNames = new Set(structuredTools.map(tool => tool.name));
1337
+ // Re-admitted together at every gate below. Kept as one set so a new injected
1338
+ // tool cannot be added to some of the three gates and forgotten at the rest.
1339
+ //
1340
+ // `disallowed_tools` is applied HERE rather than at the gates, because the two
1341
+ // kinds answer to it differently: a nested delegation tool is an opt-in the
1342
+ // agent's own frontmatter can take back, while StructuredOutput exists only
1343
+ // because this call asked for a schema — removing it would make the request
1344
+ // unsatisfiable by construction rather than merely restricted.
1345
+ // Only where extensions load, which is also where a foreign `workflow` tool
1346
+ // would be visible and refused; an isolated child gets no workflow tool at all.
1347
+ const workflowError = agentAvailable ? NESTED_WORKFLOW_ERROR : NESTED_WORKFLOW_NO_AGENT_ERROR;
1348
+ const refusalTools = !noExtensions && !disallowedSet?.has(SUBAGENT_TOOL_NAMES.WORKFLOW)
1349
+ ? [createWorkflowRefusalTool(workflowError)]
1350
+ : [];
1351
+ const readmitToolNames = new Set([
1352
+ ...[...nestedToolNames].filter(name => !disallowedSet?.has(name)),
1353
+ ...structuredToolNames,
1354
+ ...refusalTools.map(tool => tool.name),
1355
+ ]);
1356
+
1357
+ // ─── Tool scoping ───────────────────────────────────────────────────────
1358
+ //
1359
+ // Some extensions register their tools ASYNCHRONOUSLY, long after the
1360
+ // `loader.reload()` above: pi-mcp calls registerTool from `session_start`
1361
+ // (once its MCP servers connect), context-mode from `before_agent_start`.
1362
+ // That is deliberate on their part — eagerly spawning an MCP bridge during
1363
+ // extension discovery orphans child processes on pi's non-agent code paths
1364
+ // (--help, config, trust probing).
1365
+ //
1366
+ // So the tool set cannot be snapshotted here. pi's `allowedToolNames` gates
1367
+ // tool *registration* (`_refreshToolRegistry`'s `isAllowedTool`), not merely
1368
+ // the active set, and is frozen at construction — a name absent from the
1369
+ // snapshot is dropped forever, even once the tool actually registers (#125).
1370
+ //
1371
+ // Whenever extensions are in play we therefore:
1372
+ // - leave `allowedToolNames` unset, so pi's live gate admits tools whenever
1373
+ // they register;
1374
+ // - express the name-stable, permanent part of the scope (our own
1375
+ // orchestration tools, built-ins the agent didn't ask for, and
1376
+ // `disallowedTools`) as `excludeTools`, which pi re-applies on every
1377
+ // registry refresh;
1378
+ // - enforce `ext:` narrowing on the ACTIVE set via the live `inScope()`
1379
+ // predicate installed after bind — the active set is what the LLM sees,
1380
+ // so a registry tool that is never activated is invisible and uncallable.
1381
+ //
1382
+ // `noExtensions`/`isolated` keeps the historical static allowlist: nothing
1383
+ // async can appear there, and a hard registry gate is the correct boundary.
1384
+ const builtinToolNameSet = new Set(toolNames);
1385
+
1386
+ let sessionTools: string[] | undefined;
1387
+ let sessionExcludeTools: string[] | undefined;
1388
+ if (noExtensions) {
1389
+ // Strict allowlist: built-ins the agent asked for, plus any opt-in nested
1390
+ // tools (whose names would otherwise be dropped as EXCLUDED_TOOL_NAMES).
1391
+ sessionTools = [
1392
+ ...toolNames.filter(
1393
+ (t) => !EXCLUDED_TOOL_NAMES.includes(t) && !disallowedSet?.has(t),
1394
+ ),
1395
+ ...[...nestedToolNames].filter((t) => !disallowedSet?.has(t)),
1396
+ // Not filtered through `disallowedSet`, unlike the nested tools above:
1397
+ // the caller asked for a schema, and removing the only tool that can
1398
+ // satisfy it would make the request unsatisfiable by construction rather
1399
+ // than merely restricted.
1400
+ ...structuredToolNames,
1401
+ ];
1402
+ } else {
1403
+ // Deny the orchestration tools EXCEPT the nested ones this agent opted into —
1404
+ // those are injected as customTools and must survive the registry gate.
1405
+ const denyTools = new Set<string>(
1406
+ EXCLUDED_TOOL_NAMES.filter((t) => !nestedToolNames.has(t) && !readmitToolNames.has(t)),
1407
+ );
1408
+ // Keep only the built-ins the agent asked for — deny the rest.
1409
+ for (const name of BUILTIN_TOOL_NAMES) {
1410
+ if (!builtinToolNameSet.has(name)) denyTools.add(name);
1411
+ }
1412
+ if (disallowedSet) {
1413
+ // disallowed_tools wins even over an opt-in nested tool of the same name.
1414
+ // Not over StructuredOutput, though — see the allowlist branch above.
1415
+ for (const name of disallowedSet) {
1416
+ if (!structuredToolNames.has(name)) denyTools.add(name);
1417
+ }
1418
+ }
1419
+ sessionExcludeTools = [...denyTools];
1420
+ }
1421
+
1422
+ const configuredSessionDir = resolveConfiguredSessionDir(agentConfig?.sessionDir, effectiveCwd, options.homeDir);
1423
+ const sessionDirSetting = process.env.PI_CODING_AGENT_SESSION_DIR ?? settingsManager.getSessionDir?.();
1424
+ // SessionManager's implicit default consults the host home again. Pin the
1425
+ // same cwd encoding under our injected agentDir instead when isolating a run.
1426
+ // SDK settings go straight through SessionManager's native normalization;
1427
+ // unlike frontmatter, file URLs expand and relative settings stay relative.
1428
+ const defaultSessionDir = options.homeDir === undefined
1429
+ ? sessionDirSetting
1430
+ : sessionDirSetting
1431
+ ? normalizeHomePath(sessionDirSetting, options.homeDir)
1432
+ : join(resolve(agentDir), "sessions", `--${resolve(effectiveCwd).replace(/^[/\\\\]/, "").replace(/[/\\\\:]/g, "-")}--`);
1433
+ // Frontmatter wins when it says anything; otherwise the project default,
1434
+ // which `rememberAgents` supplies for top-level agents only. Same precedence
1435
+ // as `outputTranscript`.
1436
+ const persistSession = agentConfig?.persistSession ?? (options.nested ? false : rememberAgents);
1437
+ const sessionManager = options.resumeSessionFile
1438
+ // Reopening an existing conversation: the file already carries its own
1439
+ // header (cwd, parent) and history, so none of the create-time options
1440
+ // apply. `sessionDir` still matters for a later /new or /branch off it.
1441
+ ? SessionManager.open(options.resumeSessionFile, configuredSessionDir ?? defaultSessionDir)
1442
+ : persistSession
1443
+ ? SessionManager.create(effectiveCwd, configuredSessionDir ?? defaultSessionDir, {
1444
+ // Optional metadata — it only nests the subagent under its spawner in
1445
+ // `/resume`. Until `rememberAgents` this ran solely for the rare
1446
+ // `persist_session: true` agent; now it runs for every spawn, so a
1447
+ // context without a session manager (a bare programmatic ctx) must
1448
+ // still persist rather than take the whole spawn down.
1449
+ parentSession: ctx.sessionManager?.getSessionFile?.(),
1450
+ })
1451
+ : SessionManager.inMemory(effectiveCwd);
1452
+
1453
+ // Pi 0.80.8 replaced createAgentSession's modelRegistry option with
1454
+ // modelRuntime, but ExtensionContext still exposes only the registry facade.
1455
+ // Pass both so the full supported Pi range retains the parent's providers.
1456
+ const parentModelRuntime = (ctx.modelRegistry as unknown as { runtime?: unknown }).runtime;
1457
+ const sessionOpts: Parameters<typeof createAgentSession>[0] & {
1458
+ modelRegistry: ExtensionContext["modelRegistry"];
1459
+ modelRuntime?: unknown;
1460
+ } = {
1461
+ cwd: effectiveCwd,
1462
+ agentDir,
1463
+ sessionManager,
1464
+ settingsManager: sessionSettingsManager,
1465
+ modelRegistry: ctx.modelRegistry,
1466
+ // `as never` is what keeps this assignable across the supported Pi range:
1467
+ // pre-0.80.8 the field exists only via the `modelRuntime?: unknown` shim
1468
+ // above, while newer Pi types it as `ModelRuntime` — a shape an opaque
1469
+ // `unknown` read off the private facade field can never satisfy.
1470
+ ...(parentModelRuntime !== undefined && { modelRuntime: parentModelRuntime as never }),
1471
+ model,
1472
+ tools: sessionTools,
1473
+ customTools: [...nestedTools, ...structuredTools, ...refusalTools],
1474
+ resourceLoader: loader,
1475
+ };
1476
+ if (sessionExcludeTools) {
1477
+ sessionOpts.excludeTools = sessionExcludeTools;
1478
+ }
1479
+ if (thinkingLevel) {
1480
+ sessionOpts.thinkingLevel = thinkingLevel;
1481
+ }
1482
+
1483
+ const { session } = await runInChildSessionContext(() => createAgentSession(sessionOpts));
1484
+
1485
+ const baseSessionName = agentConfig?.name ?? type;
1486
+ session.setSessionName(
1487
+ options.agentId ? `${baseSessionName}#${options.agentId.slice(0, 8)}` : baseSessionName,
1488
+ );
1489
+
1490
+ // Bind extensions so that session_start fires and extensions can initialize
1491
+ // (e.g. loading credentials, setting up state). Tool gating already happened
1492
+ // at session construction via the `tools:` allowlist above — no separate
1493
+ // post-bind filter is needed. All ExtensionBindings fields are optional.
1494
+ await session.bindExtensions({
1495
+ onError: (err) => {
1496
+ options.onToolActivity?.({
1497
+ type: "end",
1498
+ toolName: `extension-error:${err.extensionPath}`,
1499
+ });
1500
+ },
1501
+ });
1502
+
1503
+ // With `allowedToolNames` unset, the registry is scoped by `excludeTools` but
1504
+ // the ACTIVE set still needs managing: pi activates only its four default
1505
+ // built-ins at turn 1, and `ext:` narrowing has no registry-level expression
1506
+ // (we can't deny the name of a tool that hasn't registered yet). Both are
1507
+ // handled below by re-deriving scope from the loader's live extension maps —
1508
+ // `registerTool` writes into those same maps, so late arrivals are judged too.
1509
+ if (!noExtensions) {
1510
+ installExtensionToolScope(session, {
1511
+ loader,
1512
+ toolNames,
1513
+ disallowedSet,
1514
+ extNames,
1515
+ narrowing,
1516
+ readmitToolNames,
1517
+ nestedCallGate,
1518
+ workflowError,
1519
+ });
1520
+ }
1521
+
1522
+ // Plan mode (#437): outermost, so a call Plan refuses reaches no other hook.
1523
+ // Read per call, because the manager decides the gate per run (spawn, resume).
1524
+ const planGate = options.planGate;
1525
+ if (planGate) {
1526
+ const planReason = (toolName: string, input: unknown, cwd: string) =>
1527
+ planGateReason(planGate, { toolName, input, cwd }, () => session.getAllTools(), readmitToolNames);
1528
+ nestedCallGate.planReason = planReason;
1529
+ const priorBeforeToolCall = session.agent.beforeToolCall;
1530
+ session.agent.beforeToolCall = async (context, signal) => {
1531
+ const reason = planReason(context.toolCall.name, context.args, effectiveCwd);
1532
+ if (reason !== undefined) return { block: true, reason };
1533
+ return priorBeforeToolCall?.(context, signal);
1534
+ };
1535
+ }
1536
+
1537
+ options.onSessionCreated?.(session);
1538
+
1539
+ const run = observeRun(session, { ...options, maxTurns: resolveEffectiveMaxTurns(type, options.maxTurns) });
1540
+
1541
+ const collector = collectResponseText(session);
1542
+ const cleanupAbort = forwardAbortSignal(session, options.signal);
1543
+
1544
+ // Build the effective prompt: optionally prepend parent context
1545
+ let effectivePrompt = prompt;
1546
+ if (options.inheritContext) {
1547
+ const parentContext = buildParentContext(ctx);
1548
+ if (parentContext) {
1549
+ effectivePrompt = parentContext + prompt;
1550
+ }
1551
+ }
1552
+
1553
+ // Boundary for the history fallback: only assistant text produced from here
1554
+ // on counts as this run's output (a fresh session, so usually 0).
1555
+ const startLen = session.messages.length;
1556
+ let structuredRetried = false;
1557
+ try {
1558
+ // A signal that fired before this point never calls the listener above, and
1559
+ // pi's `session.abort()` is a no-op until a run is active — so a child whose
1560
+ // parent is already gone is not prompted at all.
1561
+ if (options.signal?.aborted !== true) await session.prompt(effectivePrompt);
1562
+ await retryStaleCtx(session, startLen, options.signal, options.staleRetryDelaysMs, options.clock);
1563
+
1564
+ // A cap fired. The wrap-up steer is only a request: a model deep in a tool
1565
+ // loop keeps calling tools through the grace turns and is then aborted
1566
+ // mid-turn, leaving its last narration as the "result". Demand the report
1567
+ // in a turn that cannot call tools.
1568
+ if (run.steered && mayFollowUp(session, startLen, options.signal) && !endedWithReport(session, startLen)) {
1569
+ run.finalizing = true;
1570
+ const reported = await forceFinalReport(session, startLen, options.signal);
1571
+ // A delivered report is a wrap-up, not a failure: the agent did hand back
1572
+ // its findings, just later than it would have chosen.
1573
+ if (reported) run.aborted = false;
1574
+ }
1575
+
1576
+ // One more prompt when a schema was asked for and nothing usable came back
1577
+ // — the model answered in prose, or only ever called the tool invalidly.
1578
+ // Inside this `try`, so the turn tracking, the text collector and above all
1579
+ // the abort forwarding are still live: torn down first, a retry would be
1580
+ // unkillable.
1581
+ if (structuredCapture !== undefined && structuredCapture.json === undefined
1582
+ && !run.aborted && mayFollowUp(session, startLen, options.signal)) {
1583
+ structuredRetried = true;
1584
+ await session.prompt(structuredRetryPrompt(structuredCapture));
1585
+ }
1586
+ } finally {
1587
+ run.unsubscribe();
1588
+ collector.unsubscribe();
1589
+ cleanupAbort();
1590
+ }
1591
+
1592
+ const stale = staleCtxOutcome(session, startLen);
1593
+ const text = collector.getText().trim() || getLastAssistantText(session, startLen);
1594
+ // A child asked for structured output that never gave any has failed, however
1595
+ // articulate its prose was. Reported through `failure` so it travels the same
1596
+ // path as a provider error rather than arriving as a successful empty answer.
1597
+ const structuredFailure = structuredCapture !== undefined && structuredCapture.json === undefined
1598
+ ? structuredCapture.lastError !== undefined
1599
+ ? `The agent's StructuredOutput call did not match the required schema: ${structuredCapture.lastError}`
1600
+ : "The agent did not report its answer through StructuredOutput."
1601
+ : undefined;
1602
+ const failure = stale?.failure ?? finalTurnError(session, startLen) ?? structuredFailure;
1603
+ const streamed = collector.getText().trim() || collector.getPreviousText().trim();
1604
+ return {
1605
+ responseText: failure ? failureText(session, startLen, streamed || text, stale?.partial ?? partialProgress(session, startLen), collector.hasLaterTool(), Boolean(streamed)) : text,
1606
+ session,
1607
+ aborted: run.aborted,
1608
+ steered: run.steered,
1609
+ failure,
1610
+ ...(structuredCapture?.json !== undefined ? { structuredJson: structuredCapture.json } : {}),
1611
+ ...(structuredRetried ? { structuredRetried } : {}),
1612
+ };
1613
+ }
1614
+
1615
+ /**
1616
+ * Send a new prompt to an existing session (resume). A fresh run of the same
1617
+ * agent: the turn limit is resolved as `runAgent` resolves it — an explicit
1618
+ * `maxTurns`, else the agent type's own `max_turns`, else the project default
1619
+ * — and counted from this prompt (#368).
1620
+ */
1621
+ export async function resumeAgent(
1622
+ session: AgentSession,
1623
+ prompt: string,
1624
+ options: Pick<RunOptions, "staleRetryDelaysMs" | "clock" | "maxTurns" | "onTurnEnd" | "onTextDelta" | "onModelCall" | "onToolActivity" | "onAssistantUsage" | "onCompaction" | "signal"> & {
1625
+ /** The agent's type, for its own `max_turns`. */
1626
+ type: SubagentType;
1627
+ },
1628
+ ): Promise<{ text: string; failure?: string } & Pick<RunResult, "aborted" | "steered">> {
1629
+ // Boundary for the history fallback: the session already holds prior turns,
1630
+ // so only assistant text produced by THIS resume prompt counts as its output
1631
+ // — a failed resume must not surface the previous turn's answer (#144).
1632
+ const startLen = session.messages.length;
1633
+ const collector = collectResponseText(session);
1634
+ const cleanupAbort = forwardAbortSignal(session, options.signal);
1635
+ const run = observeRun(session, { ...options, maxTurns: resolveEffectiveMaxTurns(options.type, options.maxTurns) });
1636
+
1637
+ try {
1638
+ // Already aborted: not prompted, as in `runAgent`.
1639
+ if (options.signal?.aborted !== true) await session.prompt(prompt);
1640
+ await retryStaleCtx(session, startLen, options.signal, options.staleRetryDelaysMs, options.clock);
1641
+ if (run.steered && mayFollowUp(session, startLen, options.signal) && !endedWithReport(session, startLen)) {
1642
+ run.finalizing = true;
1643
+ if (await forceFinalReport(session, startLen, options.signal)) run.aborted = false;
1644
+ }
1645
+ } finally {
1646
+ collector.unsubscribe();
1647
+ run.unsubscribe();
1648
+ cleanupAbort();
1649
+ }
1650
+
1651
+ const stale = staleCtxOutcome(session, startLen);
1652
+ const failure = stale?.failure ?? finalTurnError(session, startLen);
1653
+ const text = collector.getText().trim() || getLastAssistantText(session, startLen);
1654
+ const streamed = collector.getText().trim() || collector.getPreviousText().trim();
1655
+ return {
1656
+ text: failure ? failureText(session, startLen, streamed || text, stale?.partial ?? partialProgress(session, startLen), collector.hasLaterTool(), Boolean(streamed)) : text,
1657
+ failure,
1658
+ aborted: run.aborted,
1659
+ steered: run.steered,
1660
+ };
1661
+ }
1662
+
1663
+ /**
1664
+ * Send a steering message to a running subagent.
1665
+ * The message will interrupt the agent after its current tool execution.
1666
+ */
1667
+ export async function steerAgent(
1668
+ session: AgentSession,
1669
+ message: string,
1670
+ ): Promise<void> {
1671
+ await session.steer(message);
1672
+ }
1673
+
1674
+ /**
1675
+ * Get the subagent's conversation messages as formatted text.
1676
+ */
1677
+ export function getAgentConversation(session: AgentSession): string {
1678
+ const parts: string[] = [];
1679
+
1680
+ for (const msg of session.messages) {
1681
+ if (msg.role === "user") {
1682
+ const text = typeof msg.content === "string"
1683
+ ? msg.content
1684
+ : extractText(msg.content);
1685
+ if (text.trim()) parts.push(`[User]: ${text.trim()}`);
1686
+ } else if (msg.role === "assistant") {
1687
+ const textParts: string[] = [];
1688
+ const toolCalls: string[] = [];
1689
+ for (const c of msg.content) {
1690
+ if (c.type === "text" && c.text) textParts.push(c.text);
1691
+ else if (c.type === "toolCall") toolCalls.push(` Tool: ${(c as any).name ?? (c as any).toolName ?? "unknown"}`);
1692
+ }
1693
+ if (textParts.length > 0) parts.push(`[Assistant]: ${textParts.join("\n")}`);
1694
+ if (toolCalls.length > 0) parts.push(`[Tool Calls]:\n${toolCalls.join("\n")}`);
1695
+ } else if (msg.role === "toolResult") {
1696
+ const text = extractText(msg.content);
1697
+ const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text;
1698
+ parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`);
1699
+ }
1700
+ }
1701
+
1702
+ return parts.join("\n\n");
1703
+ }