@ladbabynpm/picc-subagents 0.1.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.
@@ -0,0 +1,986 @@
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, posix, resolve } from "node:path";
8
+ import type { Model } from "@earendil-works/pi-ai";
9
+ import { cleanupSessionResources } from "@earendil-works/pi-ai";
10
+ import type { ExtensionContext, LoadExtensionsResult } from "@earendil-works/pi-coding-agent";
11
+ import {
12
+ type AgentSession,
13
+ type AgentSessionEvent,
14
+ createAgentSession,
15
+ DefaultResourceLoader,
16
+ type ExtensionAPI,
17
+ getAgentDir,
18
+ SessionManager,
19
+ SettingsManager,
20
+ } from "@earendil-works/pi-coding-agent";
21
+ import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
22
+ import { buildParentContext, extractText } from "./context.js";
23
+ import { DEFAULT_AGENTS } from "./default-agents.js";
24
+ import { detectEnv } from "./env.js";
25
+ import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
26
+ import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
27
+ import { preloadSkills } from "./skill-loader.js";
28
+ import type { SubagentType, ThinkingLevel } from "./types.js";
29
+
30
+ /**
31
+ * Tool names registered by THIS extension. Single source of truth so the
32
+ * registration sites (index.ts) and the subagent exclusion list below can't
33
+ * drift apart. These are our own tools, not pi built-ins, so they can't be
34
+ * derived from pi — but they only need defining once.
35
+ */
36
+ export const SUBAGENT_TOOL_NAMES = {
37
+ AGENT: "Agent",
38
+ } as const;
39
+
40
+ /** Names of tools registered by this extension that subagents must NOT inherit. */
41
+ const EXCLUDED_TOOL_NAMES: string[] = Object.values(SUBAGENT_TOOL_NAMES);
42
+
43
+ // In-process subagent lifecycle channels. These form a documented event
44
+ // contract for any extension that wants to observe the subagent lifecycle
45
+ // (e.g. a policy layer that registers in-process children and forwards
46
+ // permission prompts to the parent UI). Consumers subscribe independently on
47
+ // their own event bus; producers do not import them, so the two sides never
48
+ // depend on each other. Emitted on the PARENT's event bus (`options.pi.events`)
49
+ // so only the parent's extension instances observe a child's lifecycle (the
50
+ // child's own bus is separate and never sees these).
51
+ export const SUBAGENT_CHILD_SESSION_CREATED = "subagents:child:session-created";
52
+ export const SUBAGENT_CHILD_DISPOSED = "subagents:child:disposed";
53
+
54
+ /**
55
+ * Canonical name of an extension for `extensions: [...]` allowlist matching.
56
+ * Lowercased — extension names match case-insensitively so `extensions: [Mcp]`
57
+ * resolves the same as `[mcp]`. Tool names within `ext:foo/bar` are not affected.
58
+ * Directory extensions (`foo/index.ts`) resolve to the parent directory name;
59
+ * single-file extensions to the basename minus `.ts`/`.js`.
60
+ */
61
+ export function extensionCanonicalName(extPath: string): string {
62
+ const base = basename(extPath);
63
+ const name = base === "index.ts" || base === "index.js"
64
+ ? basename(dirname(extPath))
65
+ : base.replace(/\.(ts|js)$/, "");
66
+ return name.toLowerCase();
67
+ }
68
+
69
+ /**
70
+ * The unscoped, lowercased npm short name of the pi package that DECLARES
71
+ * `extPath` as an extension entry — or undefined if the entry doesn't belong to
72
+ * such a package.
73
+ *
74
+ * Climbs from the entry's directory looking for the package that owns it, and
75
+ * stays strictly within that package's tree by stopping at two structural
76
+ * boundaries — no hardcoded depth:
77
+ * - the FIRST `package.json` found (the package root); the entry's own
78
+ * manifest always sits at the root, above the entry, below any node_modules.
79
+ * - a `node_modules` directory: a package never spans one (it's where OTHER
80
+ * packages live), so reaching it means we've climbed out of the package —
81
+ * stop before reading a consumer's or parent package's manifest.
82
+ * The name is then taken only when that root's `pi.extensions` manifest actually
83
+ * lists this entry. That "declares this entry" check is deliberate: our own test
84
+ * fixtures live under this repo, whose root manifest declares `./src/index.ts`
85
+ * as `@tintinweb/pi-subagents`, so a looser rule would misattribute every
86
+ * co-located file to `pi-subagents`.
87
+ */
88
+ function extensionPackageName(extPath: string): string | undefined {
89
+ const entry = resolve(extPath);
90
+ let dir = dirname(extPath);
91
+ for (;;) {
92
+ // Climbing into node_modules means we've left the owning package's tree.
93
+ if (basename(dir) === "node_modules") return undefined;
94
+ let pkg: { name?: unknown; pi?: { extensions?: unknown } };
95
+ try {
96
+ pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
97
+ } catch {
98
+ const parent = dirname(dir);
99
+ if (parent === dir) return undefined; // walked to the filesystem root
100
+ dir = parent;
101
+ continue;
102
+ }
103
+ // First package.json wins — it's the package root; decide here.
104
+ const entries = pkg.pi?.extensions;
105
+ if (
106
+ typeof pkg.name === "string" &&
107
+ Array.isArray(entries) &&
108
+ entries.some((e) => typeof e === "string" && resolve(dir, e) === entry)
109
+ ) {
110
+ const short = pkg.name.startsWith("@") ? pkg.name.slice(pkg.name.indexOf("/") + 1) : pkg.name;
111
+ return short.toLowerCase();
112
+ }
113
+ return undefined;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * All names an extension answers to for allowlist matching (lowercased): its
119
+ * path-derived {@link extensionCanonicalName} plus, when a pi package manifest
120
+ * declares this entry, that package's unscoped short name (`@scope/foo` → `foo`).
121
+ * #143: an extension installed via `pi.extensions: ["./src/index.ts"]` would
122
+ * otherwise only ever match as `src` (the source directory), never by its
123
+ * package name. The path-derived name is preserved, so it keeps matching too.
124
+ */
125
+ export function extensionCanonicalNames(extPath: string): string[] {
126
+ const canonical = extensionCanonicalName(extPath);
127
+ const pkg = extensionPackageName(extPath);
128
+ return pkg && pkg !== canonical ? [canonical, pkg] : [canonical];
129
+ }
130
+
131
+ /**
132
+ * Classify `extensions: string[]` frontmatter entries for the loader-level filter.
133
+ *
134
+ * An entry is a PATH iff it contains a path separator or starts with `~`; otherwise
135
+ * it is a NAME. `"*"` sets the wildcard flag (keep all default-discovered extensions).
136
+ *
137
+ * Path entries are resolved (`~` expanded, made absolute against `cwd`) into `paths`
138
+ * — and their canonical name is also added to `names`. The loader override matches
139
+ * everything by canonical name, so path-loaded extensions are matched via their name
140
+ * rather than their post-staging `Extension.path`.
141
+ */
142
+ export function parseExtensionsSpec(
143
+ entries: string[],
144
+ cwd: string,
145
+ ): { names: Set<string>; paths: string[]; wildcard: boolean } {
146
+ const names = new Set<string>();
147
+ const paths: string[] = [];
148
+ let wildcard = false;
149
+ for (const entry of entries) {
150
+ if (!entry) continue;
151
+ if (entry === "*") {
152
+ wildcard = true;
153
+ continue;
154
+ }
155
+ const isPathEntry = entry.includes("/") || entry.includes("\\") || entry.startsWith("~");
156
+ if (!isPathEntry) {
157
+ names.add(entry.toLowerCase());
158
+ continue;
159
+ }
160
+ let p = entry;
161
+ if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) {
162
+ p = homedir() + p.slice(1);
163
+ }
164
+ const abs = resolveAgainstCwd(p, cwd);
165
+ paths.push(abs);
166
+ names.add(extensionCanonicalName(abs));
167
+ }
168
+ return { names, paths, wildcard };
169
+ }
170
+
171
+ /**
172
+ * Parse raw `ext:` selector strings (from the `tools:` CSV) into the set of
173
+ * extension names to keep loaded and a per-extension tool-narrowing map.
174
+ *
175
+ * `ext:foo` → `extNames` has `foo`, no narrowing entry (all of foo's tools).
176
+ * `ext:foo/bar` → `extNames` has `foo`, `narrowing.foo` has `bar` (only `bar`).
177
+ * A name lands in `narrowing` only when a `/tool` form is seen, so a bare
178
+ * `ext:foo` alongside `ext:foo/bar` leaves narrowing in effect (narrowing wins).
179
+ * The split is on the first `/`; extension canonical names never contain `/`.
180
+ */
181
+ export function parseExtSelectors(entries: string[]): {
182
+ extNames: Set<string>;
183
+ narrowing: Map<string, Set<string>>;
184
+ } {
185
+ const extNames = new Set<string>();
186
+ const narrowing = new Map<string, Set<string>>();
187
+ for (const raw of entries) {
188
+ if (!raw) continue;
189
+ const body = raw.slice("ext:".length);
190
+ const slash = body.indexOf("/");
191
+ // Extension name matches case-insensitively (matches the loader-side canonical
192
+ // name). Tool names are case-preserved — they're matched against pi-mono's
193
+ // registered identifiers, which are case-sensitive.
194
+ const name = (slash === -1 ? body : body.slice(0, slash)).trim().toLowerCase();
195
+ if (!name) continue;
196
+ extNames.add(name);
197
+ if (slash === -1) continue;
198
+ const tool = body.slice(slash + 1).trim();
199
+ if (!tool) continue;
200
+ let set = narrowing.get(name);
201
+ if (!set) {
202
+ set = new Set();
203
+ narrowing.set(name, set);
204
+ }
205
+ set.add(tool);
206
+ }
207
+ return { extNames, narrowing };
208
+ }
209
+
210
+ /** Default max turns. undefined = unlimited (no turn limit). */
211
+ let defaultMaxTurns: number | undefined;
212
+
213
+ /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
214
+ export function normalizeMaxTurns(n: number | undefined): number | undefined {
215
+ if (n == null || n === 0) return undefined;
216
+ return Math.max(1, n);
217
+ }
218
+
219
+ /** Get the default max turns value. undefined = unlimited. */
220
+ export function getDefaultMaxTurns(): number | undefined { return defaultMaxTurns; }
221
+ /** Set the default max turns value. undefined or 0 = unlimited, otherwise minimum 1. */
222
+ export function setDefaultMaxTurns(n: number | undefined): void { defaultMaxTurns = normalizeMaxTurns(n); }
223
+
224
+ /** Additional turns allowed after the soft limit steer message. */
225
+ let graceTurns = 5;
226
+
227
+ /** Get the grace turns value. */
228
+ export function getGraceTurns(): number { return graceTurns; }
229
+ /** Set the grace turns value (minimum 1). */
230
+ export function setGraceTurns(n: number): void { graceTurns = Math.max(1, n); }
231
+
232
+ /**
233
+ * Try to find the right model for an agent type.
234
+ * Priority: explicit option > config.model > parent model.
235
+ */
236
+ function resolveDefaultModel(
237
+ parentModel: Model<any> | undefined,
238
+ registry: { find(provider: string, modelId: string): Model<any> | undefined; getAvailable?(): Model<any>[] },
239
+ configModel?: string,
240
+ ): Model<any> | undefined {
241
+ if (configModel) {
242
+ const slashIdx = configModel.indexOf("/");
243
+ if (slashIdx !== -1) {
244
+ const provider = configModel.slice(0, slashIdx);
245
+ const modelId = configModel.slice(slashIdx + 1);
246
+
247
+ // Build a set of available model keys for fast lookup
248
+ const available = registry.getAvailable?.();
249
+ const availableKeys = available
250
+ ? new Set(available.map((m: any) => `${m.provider}/${m.id}`))
251
+ : undefined;
252
+ const isAvailable = (p: string, id: string) =>
253
+ !availableKeys || availableKeys.has(`${p}/${id}`);
254
+
255
+ const found = registry.find(provider, modelId);
256
+ if (found && isAvailable(provider, modelId)) return found;
257
+ }
258
+ }
259
+
260
+ return parentModel;
261
+ }
262
+
263
+ /** Info about a tool event in the subagent. */
264
+ export interface ToolActivity {
265
+ type: "start" | "end";
266
+ toolName: string;
267
+ }
268
+
269
+ export interface RunOptions {
270
+ /** ExtensionAPI instance — used for pi.exec() instead of execSync. */
271
+ pi: ExtensionAPI;
272
+ /** Manager-assigned id; suffixes session name to disambiguate parallel spawns (e.g. `Explore#a1b2c3d4`). */
273
+ agentId?: string;
274
+ model?: Model<any>;
275
+ maxTurns?: number;
276
+ signal?: AbortSignal;
277
+ isolated?: boolean;
278
+ inheritContext?: boolean;
279
+ thinkingLevel?: ThinkingLevel;
280
+ /** Override working directory (e.g. for worktree isolation). */
281
+ cwd?: string;
282
+ /**
283
+ * Where .pi config is discovered (project extensions, skills, pi settings,
284
+ * agent memory). Default: same as the working directory. The manager sets
285
+ * this to the parent session's cwd when `SpawnOptions.cwd` points the
286
+ * working directory elsewhere — the agent works *there* but carries the
287
+ * parent project's config (the target's `.pi` extensions never execute).
288
+ *
289
+ * WARNING for future callers: if you pass `cwd` pointing at a directory the
290
+ * user didn't open, you almost certainly must pass `configCwd` too —
291
+ * omitting it makes the target's `.pi` extensions execute in this process.
292
+ * (Worktree isolation is the one intentional exception: its copy IS the
293
+ * parent's repo, so config resolving inside it is correct.)
294
+ */
295
+ configCwd?: string;
296
+ /** Called on tool start/end with activity info. */
297
+ onToolActivity?: (activity: ToolActivity) => void;
298
+ /** Called on streaming text deltas from the assistant response. */
299
+ onTextDelta?: (delta: string, fullText: string) => void;
300
+ onSessionCreated?: (session: AgentSession) => void;
301
+ /** Called at the end of each agentic turn with the cumulative count. */
302
+ onTurnEnd?: (turnCount: number) => void;
303
+ /**
304
+ * Called once per assistant message_end with that message's usage delta.
305
+ * Lets callers maintain a lifetime accumulator that survives compaction
306
+ * (which replaces session.state.messages and resets stats-derived sums).
307
+ */
308
+ onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
309
+ /**
310
+ * Called when the session successfully compacts. `tokensBefore` is upstream's
311
+ * pre-compaction context size estimate. Aborted compactions don't fire.
312
+ */
313
+ onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
314
+ }
315
+
316
+ export interface RunResult {
317
+ responseText: string;
318
+ session: AgentSession;
319
+ /** True if the agent was hard-aborted (max_turns + grace exceeded). */
320
+ aborted: boolean;
321
+ /** True if the agent was steered to wrap up (hit soft turn limit) but finished in time. */
322
+ steered: boolean;
323
+ /**
324
+ * A failure message for the run's FINAL assistant turn, when that turn failed:
325
+ * a provider error (stopReason "error"), or a "length" stop that produced no
326
+ * text (a silent max-token death). pi resolves an exhausted-retries failure
327
+ * normally instead of rejecting, so without this the manager would report such
328
+ * a run as completed — with an empty result, or worse, an earlier turn's text
329
+ * presented as the answer (#144). Undefined for a clean stop, or a "length"
330
+ * stop that produced text (a legitimate truncated answer).
331
+ */
332
+ failure?: string;
333
+ }
334
+
335
+ /**
336
+ * Subscribe to a session and collect the last assistant message text.
337
+ * Returns an object with a `getText()` getter and an `unsubscribe` function.
338
+ */
339
+ function collectResponseText(session: AgentSession) {
340
+ let text = "";
341
+ const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
342
+ // message_start also fires for user and toolResult messages — resetting on
343
+ // those would wipe assistant text already collected. Reset only when a new
344
+ // ASSISTANT message begins, so getText() is the last assistant message's text.
345
+ if (event.type === "message_start" && event.message.role === "assistant") {
346
+ text = "";
347
+ }
348
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
349
+ text += event.assistantMessageEvent.delta;
350
+ }
351
+ });
352
+ return { getText: () => text, unsubscribe };
353
+ }
354
+
355
+ /**
356
+ * Get the last non-empty assistant text produced during THIS invocation.
357
+ * `startIndex` is the message count captured before the prompt, so the walk-back
358
+ * never crosses into a previous turn: on a resume whose new turn failed empty,
359
+ * this returns "" instead of the prior turn's answer (#144). Defaults to 0 (a
360
+ * fresh spawn, where the whole history belongs to this run).
361
+ */
362
+ function getLastAssistantText(session: AgentSession, startIndex = 0): string {
363
+ for (let i = session.messages.length - 1; i >= startIndex; i--) {
364
+ const msg = session.messages[i];
365
+ if (msg.role !== "assistant") continue;
366
+ const text = extractText(msg.content).trim();
367
+ if (text) return text;
368
+ }
369
+ return "";
370
+ }
371
+
372
+ /**
373
+ * Error message of THIS invocation's final assistant message, when that turn
374
+ * failed. Two failure shapes, both keyed off how the final turn STOPPED:
375
+ * - stopReason "error": a provider failure pi resolved instead of rejecting
376
+ * (any text; partial output is surfaced separately).
377
+ * - stopReason "length" with NO text: a silent max-token death — the run hit
378
+ * the output-token ceiling before writing anything, which would otherwise
379
+ * land as a "completed" run with an empty result (the #144 symptom).
380
+ * Everything else completes: a clean "stop"/"toolUse" final, and — crucially — a
381
+ * "length" stop that DID produce text (a legitimate truncated-but-useful answer).
382
+ * "aborted" is handled by the manager's abort flag / "stopped" guard, not here.
383
+ * Bounded by `startIndex` (like the text fallback) so a resume that produced no
384
+ * assistant message of its own never inherits a PRIOR turn's stop reason.
385
+ */
386
+ function finalTurnError(session: AgentSession, startIndex = 0): string | undefined {
387
+ for (let i = session.messages.length - 1; i >= startIndex; i--) {
388
+ const msg = session.messages[i];
389
+ if (msg.role !== "assistant") continue;
390
+ if (msg.stopReason === "error") {
391
+ return (msg as { errorMessage?: string }).errorMessage?.trim() || "provider error with no output";
392
+ }
393
+ if (msg.stopReason === "length" && !extractText(msg.content).trim()) {
394
+ return "run hit the output token limit before producing any text";
395
+ }
396
+ return undefined;
397
+ }
398
+ return undefined;
399
+ }
400
+
401
+ /**
402
+ * Wire an AbortSignal to abort a session.
403
+ * Returns a cleanup function to remove the listener.
404
+ */
405
+ function forwardAbortSignal(session: AgentSession, signal?: AbortSignal): () => void {
406
+ if (!signal) return () => {};
407
+ const onAbort = () => session.abort();
408
+ signal.addEventListener("abort", onAbort, { once: true });
409
+ return () => signal.removeEventListener("abort", onAbort);
410
+ }
411
+
412
+ /**
413
+ * Resolve `p` against `cwd`, choosing the path module to match the input's
414
+ * platform. A Windows-style cwd (`C:\...`) resolves natively so the result stays
415
+ * valid for downstream fs / pi loaders; a POSIX cwd (as in tests, e.g. `/work`)
416
+ * resolves with `path.posix` so it keeps its POSIX shape instead of being
417
+ * re-rooted onto `C:\` by the native resolver.
418
+ */
419
+ function isWindowsPath(p: string): boolean {
420
+ return /^[A-Za-z]:[\\/]/.test(p);
421
+ }
422
+
423
+ function resolveAgainstCwd(p: string, cwd: string): string {
424
+ if (isAbsolute(p) || posix.isAbsolute(p)) return p; // already absolute
425
+ return (isWindowsPath(cwd) ? resolve : posix.resolve)(cwd, p);
426
+ }
427
+
428
+ function resolveConfiguredSessionDir(sessionDir: string | undefined, cwd: string): string | undefined {
429
+ if (!sessionDir) return undefined;
430
+ // `~` is expanded against the host home (native, so it stays valid on disk).
431
+ if (sessionDir === "~" || sessionDir.startsWith("~/")) return resolve(homedir(), sessionDir.slice(2));
432
+ if (isAbsolute(sessionDir)) return sessionDir;
433
+ return resolveAgainstCwd(sessionDir, cwd);
434
+ }
435
+
436
+ export async function runAgent(
437
+ ctx: ExtensionContext,
438
+ type: SubagentType,
439
+ prompt: string,
440
+ options: RunOptions,
441
+ ): Promise<RunResult> {
442
+ const config = getConfig(type);
443
+ const agentConfig = getAgentConfig(type);
444
+
445
+ // Resolve working directory: worktree override > parent cwd
446
+ const effectiveCwd = options.cwd ?? ctx.cwd;
447
+ // Filesystem work happens in effectiveCwd; config discovery in configCwd.
448
+ // They differ only for SpawnOptions.cwd spawns (config stays with the parent).
449
+ const configCwd = options.configCwd ?? effectiveCwd;
450
+
451
+ const env = await detectEnv(options.pi, effectiveCwd);
452
+
453
+ // Get parent system prompt for append-mode agents
454
+ const parentSystemPrompt = ctx.getSystemPrompt();
455
+
456
+ // Build prompt extras (memory, skill preloading)
457
+ const extras: PromptExtras = {};
458
+
459
+ // Resolve extensions/skills: isolated overrides to false
460
+ const extensions = options.isolated ? false : config.extensions;
461
+ // Nulling excludes under isolated also suppresses the orphaned-exclude warning —
462
+ // isolation is an intentional override, not a misconfiguration.
463
+ const excludeExtensions = options.isolated ? undefined : config.excludeExtensions;
464
+ const skills = options.isolated ? false : config.skills;
465
+
466
+ // Skill preloading: when skills is string[], preload their content into prompt
467
+ if (Array.isArray(skills)) {
468
+ const loaded = preloadSkills(skills, configCwd);
469
+ if (loaded.length > 0) {
470
+ extras.skillBlocks = loaded;
471
+ }
472
+ }
473
+
474
+ let toolNames = getToolNamesForType(type);
475
+
476
+ // Persistent memory: detect write capability and branch accordingly.
477
+ // Account for disallowedTools — a tool in the base set but on the denylist is not truly available.
478
+ if (agentConfig?.memory) {
479
+ const existingNames = new Set(toolNames);
480
+ const denied = agentConfig.disallowedTools ? new Set(agentConfig.disallowedTools) : undefined;
481
+ const effectivelyHas = (name: string) => existingNames.has(name) && !denied?.has(name);
482
+ const hasWriteTools = effectivelyHas("write") || effectivelyHas("edit");
483
+
484
+ if (hasWriteTools) {
485
+ // Read-write memory: add any missing memory tool names (read/write/edit)
486
+ const extraNames = getMemoryToolNames(existingNames);
487
+ if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
488
+ extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
489
+ } else {
490
+ // Read-only memory: only add read tool name, use read-only prompt
491
+ const extraNames = getReadOnlyMemoryToolNames(existingNames);
492
+ if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
493
+ extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
494
+ }
495
+ }
496
+
497
+ // Build system prompt from agent config
498
+ let systemPrompt: string;
499
+ if (agentConfig) {
500
+ systemPrompt = buildAgentPrompt(agentConfig, effectiveCwd, env, parentSystemPrompt, extras);
501
+ } else {
502
+ // Unknown type fallback: spread the canonical general-purpose config (defensive —
503
+ // unreachable in practice since index.ts resolves unknown types before calling runAgent).
504
+ const fallback = DEFAULT_AGENTS.get("general-purpose");
505
+ if (!fallback) throw new Error(`No fallback config available for unknown type "${type}"`);
506
+ systemPrompt = buildAgentPrompt({ ...fallback, name: type }, effectiveCwd, env, parentSystemPrompt, extras);
507
+ }
508
+
509
+ // When skills is string[], we've already preloaded them into the prompt.
510
+ // Still pass noSkills: true since we don't need the skill loader to load them again.
511
+ const noSkills = skills === false || Array.isArray(skills);
512
+
513
+ const agentDir = getAgentDir();
514
+
515
+ // Extension loading:
516
+ // - true → all default-discovered extensions
517
+ // - false → none (noExtensions)
518
+ // - string[] → loader-level allowlist. Bare names keep the matching
519
+ // default-discovered extension; path entries load that extension fresh;
520
+ // "*" keeps all default-discovered extensions. Excluded extensions never
521
+ // bind handlers or register tools (their factory still runs once).
522
+ //
523
+ // Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md — upstream's
524
+ // buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
525
+ // would defeat prompt_mode: replace and isolated: true. Parent context, if
526
+ // wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt
527
+ // is embedded in systemPromptOverride) or inherit_context (conversation).
528
+ // `ext:` selectors from the `tools:` CSV narrow which extension tools surface to
529
+ // the LLM. They do NOT control loading — `extensions:` is the sole authority for
530
+ // which extensions load. `ext:foo` against an extension that `extensions:` excluded
531
+ // is an orphan and warns after reload. `isolated` means no extension tools at all.
532
+ const { extNames, narrowing } = parseExtSelectors(
533
+ options.isolated ? [] : (agentConfig?.extSelectors ?? []),
534
+ );
535
+ const noExtensions = extensions === false;
536
+
537
+ const extensionsSpec = Array.isArray(extensions)
538
+ ? parseExtensionsSpec(extensions, configCwd)
539
+ : undefined;
540
+ const keepNames = extensionsSpec?.names ?? new Set<string>();
541
+ // `exclude_extensions:` is a denylist applied AFTER the include set — exclude wins.
542
+ // Plain canonical names only (case-insensitive). Note: excluded extensions'
543
+ // factories still run once during reload() (see comment above) — exclusion
544
+ // suppresses handler binding and tool registration; it is not a sandbox.
545
+ const excludeNames = new Set((excludeExtensions ?? []).map((n) => n.toLowerCase()));
546
+ const hasExcludes = excludeNames.size > 0;
547
+ // The override filters loaded extensions down to `keepNames` minus `excludeNames`.
548
+ // It's only needed when we're neither loading everything without excludes
549
+ // (`extensions: true` or a `"*"` wildcard) nor nothing (`noExtensions`).
550
+ const loadAll = extensions === true || extensionsSpec?.wildcard === true;
551
+ const additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined;
552
+ // Pre-filter discovered set, captured by the override — the exclude-typo warning
553
+ // must compare against this, not the surviving set (absence from survivors is
554
+ // an exclude *succeeding*).
555
+ let discoveredNames: Set<string> | undefined;
556
+ const extensionsOverride: ((base: LoadExtensionsResult) => LoadExtensionsResult) | undefined =
557
+ noExtensions || (loadAll && !hasExcludes)
558
+ ? undefined
559
+ : (base) => {
560
+ discoveredNames = new Set(base.extensions.flatMap((e) => extensionCanonicalNames(e.path)));
561
+ return {
562
+ ...base,
563
+ extensions: base.extensions.filter((e) => {
564
+ const canons = extensionCanonicalNames(e.path);
565
+ if (canons.some((n) => excludeNames.has(n))) return false; // exclude wins
566
+ return loadAll || canons.some((n) => keepNames.has(n));
567
+ }),
568
+ };
569
+ };
570
+
571
+ const loader = new DefaultResourceLoader({
572
+ cwd: configCwd,
573
+ agentDir,
574
+ noExtensions,
575
+ additionalExtensionPaths,
576
+ extensionsOverride,
577
+ noSkills,
578
+ noPromptTemplates: true,
579
+ noThemes: true,
580
+ noContextFiles: true,
581
+ systemPromptOverride: () => systemPrompt,
582
+ appendSystemPromptOverride: () => [],
583
+ });
584
+ await loader.reload();
585
+
586
+ // Plain entries in `tools:` are expected to be built-in names (extension tools
587
+ // go through `ext:`), so an unknown name there is unambiguously a typo. Previously
588
+ // this produced a silently broken agent (#75) — pi-mono accepted the bogus name
589
+ // into the allowlist, then dropped it at registration with no signal back.
590
+ if (agentConfig?.builtinToolNames?.length) {
591
+ const knownBuiltins = new Set(BUILTIN_TOOL_NAMES);
592
+ for (const name of agentConfig.builtinToolNames) {
593
+ if (!knownBuiltins.has(name)) {
594
+ options.onToolActivity?.({
595
+ type: "end",
596
+ toolName: `tools-error:tool "${name}" requested by agent "${type}" is not a known built-in`,
597
+ });
598
+ }
599
+ }
600
+ }
601
+
602
+ // A subagent spawns mid-task, so a bad `extensions:`/`ext:` entry warns rather
603
+ // than aborts. Two distinct misconfigurations to catch:
604
+ // - `extensions: [foo]` but no extension named foo was discovered (typo or
605
+ // path that failed to load — path entries fold their canonical name into
606
+ // `keepNames`, so this covers them too).
607
+ // - `tools: ext:foo` but foo isn't in the loaded set (because `extensions:`
608
+ // didn't include it). Since v0.9, `ext:` no longer pulls extensions in;
609
+ // loading is `extensions:`-authoritative.
610
+ // An exclude_extensions: alongside extensions: false is contradictory — nothing
611
+ // loads, so there is nothing to exclude.
612
+ if (hasExcludes && noExtensions) {
613
+ options.onToolActivity?.({
614
+ type: "end",
615
+ toolName: `extension-error:exclude_extensions has no effect for agent "${type}" — extensions: false loads nothing`,
616
+ });
617
+ }
618
+ // Exclude typo check: compares against the PRE-filter discovered set (an excluded
619
+ // name absent from the surviving set is the exclude working as intended). Also
620
+ // flags path-like and "*" entries — excludes are plain names only.
621
+ if (hasExcludes && discoveredNames) {
622
+ for (const name of excludeNames) {
623
+ if (!discoveredNames.has(name)) {
624
+ options.onToolActivity?.({
625
+ type: "end",
626
+ toolName: `extension-error:exclude_extensions: "${name}" for agent "${type}" did not match any discovered extension`,
627
+ });
628
+ }
629
+ }
630
+ }
631
+ if (keepNames.size > 0 || extNames.size > 0) {
632
+ const survivingNames = new Set(
633
+ loader.getExtensions().extensions.flatMap((e) => extensionCanonicalNames(e.path)),
634
+ );
635
+ for (const name of keepNames) {
636
+ if (!survivingNames.has(name)) {
637
+ options.onToolActivity?.({
638
+ type: "end",
639
+ toolName: excludeNames.has(name)
640
+ ? `extension-error:extension "${name}" is in both extensions: and exclude_extensions: for agent "${type}" — exclude wins`
641
+ : `extension-error:extension "${name}" requested by agent "${type}" was not loaded`,
642
+ });
643
+ }
644
+ }
645
+ for (const name of extNames) {
646
+ if (!survivingNames.has(name)) {
647
+ options.onToolActivity?.({
648
+ type: "end",
649
+ toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (check extensions:/exclude_extensions:)`,
650
+ });
651
+ }
652
+ }
653
+ }
654
+
655
+ // Resolve model: explicit option > config.model > parent model
656
+ const model = options.model ?? resolveDefaultModel(
657
+ ctx.model, ctx.modelRegistry, agentConfig?.model,
658
+ );
659
+
660
+ // Resolve thinking level: explicit option > agent config > undefined (inherit)
661
+ const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
662
+
663
+ const disallowedSet = agentConfig?.disallowedTools
664
+ ? new Set(agentConfig.disallowedTools)
665
+ : undefined;
666
+
667
+ // Enumerate extension-registered tool names from the loaded resource loader.
668
+ // Extensions populate `extension.tools` during `loader.reload()` and the set
669
+ // is stable afterwards — `bindExtensions` does not register new tools.
670
+ //
671
+ // Opt-in flip: when any `ext:` selector is present, extension tools become an
672
+ // explicit allowlist — a loaded extension not named by a selector contributes
673
+ // no tools (its handlers still ran), and `ext:foo/bar` narrows `foo` to `bar`.
674
+ const extensionToolNames: string[] = [];
675
+ if (!noExtensions) {
676
+ const optInActive = extNames.size > 0;
677
+ for (const extension of loader.getExtensions().extensions) {
678
+ const canons = extensionCanonicalNames(extension.path);
679
+ if (optInActive && !canons.some((c) => extNames.has(c))) continue;
680
+ // First alias that carries a narrowing set — a user won't narrow one
681
+ // extension under two different names, so first-match is correct.
682
+ const narrowed = canons.map((c) => narrowing.get(c)).find(Boolean);
683
+ for (const toolName of extension.tools.keys()) {
684
+ if (narrowed && !narrowed.has(toolName)) continue;
685
+ extensionToolNames.push(toolName);
686
+ }
687
+ }
688
+ }
689
+
690
+ // Build the master tool allowlist applied at session construction.
691
+ // pi-mono's `allowedToolNames` gates BOTH registration and the initial active
692
+ // set, so listing the exact final set here means the session is correctly
693
+ // scoped from the first instant — no post-construction narrowing required.
694
+ const builtinToolNameSet = new Set(toolNames);
695
+ const allowedTools = [...toolNames, ...extensionToolNames].filter((t) => {
696
+ if (EXCLUDED_TOOL_NAMES.includes(t)) return false;
697
+ if (disallowedSet?.has(t)) return false;
698
+ if (builtinToolNameSet.has(t)) return true;
699
+ // Reached only for extension tools. The extension set was already filtered
700
+ // at the loader (extensionsOverride / noExtensions) and at enumeration
701
+ // (`ext:` opt-in flip), so any extension tool in `extensionToolNames` is allowed.
702
+ return !noExtensions;
703
+ });
704
+
705
+ const settingsManager = SettingsManager.create(configCwd, agentDir);
706
+ const configuredSessionDir = resolveConfiguredSessionDir(agentConfig?.sessionDir, effectiveCwd);
707
+ const defaultSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR ?? settingsManager.getSessionDir?.();
708
+ const sessionManager = agentConfig?.persistSession
709
+ ? SessionManager.create(effectiveCwd, configuredSessionDir ?? defaultSessionDir)
710
+ : SessionManager.inMemory(effectiveCwd);
711
+
712
+ const sessionOpts: Parameters<typeof createAgentSession>[0] = {
713
+ cwd: effectiveCwd,
714
+ agentDir,
715
+ sessionManager,
716
+ settingsManager,
717
+ modelRegistry: ctx.modelRegistry,
718
+ model,
719
+ tools: allowedTools,
720
+ resourceLoader: loader,
721
+ };
722
+ if (thinkingLevel) {
723
+ sessionOpts.thinkingLevel = thinkingLevel;
724
+ }
725
+
726
+ // Subagent sessions are headless by construction: `createAgentSession`
727
+ // below is called without a UI context, so pi marks the child
728
+ // `ctx.hasUI === false` throughout its run. The downstream permission gate
729
+ // (`picc-permission-modes`) keys off that single signal to avoid prompting
730
+ // and to inherit the parent's published mode — no environment markers are
731
+ // needed. (The old `PI_SUBAGENT_CHILD` / `PI_SUBAGENT_RUN_ID` env channel
732
+ // was removed: it ran on the shared in-process `process.env` and leaked
733
+ // across sessions.)
734
+ //
735
+ // In addition, we emit subagent-lifecycle events on the parent bus so any
736
+ // consumer that wants in-process child visibility (a policy layer, an
737
+ // observability tool, ...) can observe it — see the channel constants above.
738
+ // `session-created` MUST fire synchronously before `bindExtensions` so a
739
+ // consumer registers the child before the child's own session_start runs;
740
+ // `disposed` fires in the run's finally so the entry is always cleaned up.
741
+ // These are best-effort: no live consumer means they are simple no-ops.
742
+
743
+ const { session } = await createAgentSession(sessionOpts);
744
+
745
+ const baseSessionName = agentConfig?.name ?? type;
746
+ session.setSessionName(
747
+ options.agentId ? `${baseSessionName}#${options.agentId.slice(0, 8)}` : baseSessionName,
748
+ );
749
+
750
+ // Announce the child so any consumer of the subagent-lifecycle contract can
751
+ // detect the in-process child and, e.g., forward `ask` prompts to the
752
+ // parent UI. `parentSessionId` lets a consumer resolve the forwarding
753
+ // target.
754
+ const parentSessionId = ctx.sessionManager?.getSessionId?.() ?? undefined;
755
+ // Best-effort: a host that provides a minimal `pi` without an event bus
756
+ // (e.g. some SDK/test callers) simply skips the lifecycle emission.
757
+ options.pi.events?.emit(SUBAGENT_CHILD_SESSION_CREATED, {
758
+ sessionId: session.sessionId,
759
+ ...(parentSessionId ? { parentSessionId } : {}),
760
+ });
761
+
762
+ // Bind extensions so that session_start fires and extensions can initialize
763
+ // (e.g. loading credentials, setting up state). Tool gating already happened
764
+ // at session construction via the `tools:` allowlist above — no separate
765
+ // post-bind filter is needed. All ExtensionBindings fields are optional.
766
+ await session.bindExtensions({
767
+ onError: (err) => {
768
+ options.onToolActivity?.({
769
+ type: "end",
770
+ toolName: `extension-error:${err.extensionPath}`,
771
+ });
772
+ },
773
+ });
774
+
775
+ options.onSessionCreated?.(session);
776
+
777
+ // Track turns for graceful max_turns enforcement
778
+ let turnCount = 0;
779
+ const maxTurns = normalizeMaxTurns(options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns);
780
+ let softLimitReached = false;
781
+ let aborted = false;
782
+
783
+ let currentMessageText = "";
784
+ const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
785
+ if (event.type === "turn_end") {
786
+ turnCount++;
787
+ options.onTurnEnd?.(turnCount);
788
+ if (maxTurns != null) {
789
+ if (!softLimitReached && turnCount >= maxTurns) {
790
+ softLimitReached = true;
791
+ session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
792
+ } else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
793
+ aborted = true;
794
+ session.abort();
795
+ }
796
+ }
797
+ }
798
+ if (event.type === "message_start") {
799
+ currentMessageText = "";
800
+ }
801
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
802
+ currentMessageText += event.assistantMessageEvent.delta;
803
+ options.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText);
804
+ }
805
+ if (event.type === "tool_execution_start") {
806
+ options.onToolActivity?.({ type: "start", toolName: event.toolName });
807
+ }
808
+ if (event.type === "tool_execution_end") {
809
+ options.onToolActivity?.({ type: "end", toolName: event.toolName });
810
+ }
811
+ if (event.type === "message_end" && event.message.role === "assistant") {
812
+ const u = (event.message as any).usage;
813
+ if (u) options.onAssistantUsage?.({
814
+ input: u.input ?? 0,
815
+ output: u.output ?? 0,
816
+ cacheWrite: u.cacheWrite ?? 0,
817
+ });
818
+ }
819
+ if (event.type === "compaction_end" && !event.aborted && event.result) {
820
+ options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
821
+ }
822
+ });
823
+
824
+ const collector = collectResponseText(session);
825
+ const cleanupAbort = forwardAbortSignal(session, options.signal);
826
+
827
+ // Build the effective prompt: optionally prepend parent context
828
+ let effectivePrompt = prompt;
829
+ if (options.inheritContext) {
830
+ const parentContext = buildParentContext(ctx);
831
+ if (parentContext) {
832
+ effectivePrompt = parentContext + prompt;
833
+ }
834
+ }
835
+
836
+ // Boundary for the history fallback: only assistant text produced from here
837
+ // on counts as this run's output (a fresh session, so usually 0).
838
+ const startLen = session.messages.length;
839
+ try {
840
+ await session.prompt(effectivePrompt);
841
+ } finally {
842
+ unsubTurns();
843
+ collector.unsubscribe();
844
+ cleanupAbort();
845
+ // Announce the child's end of life so any consumer of the lifecycle
846
+ // contract can clean up its registry. Emitted on the parent bus, in the
847
+ // run's finally, so it fires on success, abort, and error alike.
848
+ // Best-effort (no-op) when the host provides no event bus.
849
+ options.pi.events?.emit(SUBAGENT_CHILD_DISPOSED, { sessionId: session.sessionId });
850
+ }
851
+
852
+ const responseText = collector.getText().trim() || getLastAssistantText(session, startLen);
853
+ return { responseText, session, aborted, steered: softLimitReached, failure: finalTurnError(session, startLen) };
854
+ }
855
+
856
+ /**
857
+ * Send a new prompt to an existing session (resume).
858
+ */
859
+ export async function resumeAgent(
860
+ session: AgentSession,
861
+ prompt: string,
862
+ options: {
863
+ onToolActivity?: (activity: ToolActivity) => void;
864
+ onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
865
+ onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
866
+ signal?: AbortSignal;
867
+ } = {},
868
+ ): Promise<{ text: string; failure?: string }> {
869
+ // Boundary for the history fallback: the session already holds prior turns,
870
+ // so only assistant text produced by THIS resume prompt counts as its output
871
+ // — a failed resume must not surface the previous turn's answer (#144).
872
+ const startLen = session.messages.length;
873
+ const collector = collectResponseText(session);
874
+ const cleanupAbort = forwardAbortSignal(session, options.signal);
875
+
876
+ const unsubEvents = (options.onToolActivity || options.onAssistantUsage || options.onCompaction)
877
+ ? session.subscribe((event: AgentSessionEvent) => {
878
+ if (event.type === "tool_execution_start") options.onToolActivity?.({ type: "start", toolName: event.toolName });
879
+ if (event.type === "tool_execution_end") options.onToolActivity?.({ type: "end", toolName: event.toolName });
880
+ if (event.type === "message_end" && event.message.role === "assistant") {
881
+ const u = (event.message as any).usage;
882
+ if (u) options.onAssistantUsage?.({
883
+ input: u.input ?? 0,
884
+ output: u.output ?? 0,
885
+ cacheWrite: u.cacheWrite ?? 0,
886
+ });
887
+ }
888
+ if (event.type === "compaction_end" && !event.aborted && event.result) {
889
+ options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
890
+ }
891
+ })
892
+ : () => {};
893
+
894
+ try {
895
+ await session.prompt(prompt);
896
+ } finally {
897
+ collector.unsubscribe();
898
+ unsubEvents();
899
+ cleanupAbort();
900
+ }
901
+
902
+ return {
903
+ text: collector.getText().trim() || getLastAssistantText(session, startLen),
904
+ failure: finalTurnError(session, startLen),
905
+ };
906
+ }
907
+
908
+ /**
909
+ * Send a steering message to a running subagent.
910
+ * The message will interrupt the agent after its current tool execution.
911
+ */
912
+ export async function steerAgent(
913
+ session: AgentSession,
914
+ message: string,
915
+ ): Promise<void> {
916
+ await session.steer(message);
917
+ }
918
+
919
+ /**
920
+ * Tear down a subagent session WITHOUT invalidating the shared extension
921
+ * runtime.
922
+ *
923
+ * AgentSession.dispose() is deliberately NOT used: it calls
924
+ * `this._extensionRunner.invalidate(...)`, which sets the extension runtime's
925
+ * `staleMessage` with `??=` — a one-way latch that is never cleared. Every
926
+ * extension api bound to that runtime then throws "This extension ctx is
927
+ * stale" on sendMessage for the rest of the process. Subagent sessions run
928
+ * in-process (their extension factories execute against the same module
929
+ * singleton state as the parent session's), so the poison would permanently
930
+ * break cron schedulers (pi-loop) and any other extension that captured a `pi`
931
+ * reference — even after the subagent has finished.
932
+ *
933
+ * Safe teardown instead:
934
+ * - `session.agent.abort()` — stop any in-flight agent work (a safe no-op
935
+ * after the run has settled; `forwardAbortSignal` already aborts on the
936
+ * caller's signal).
937
+ * - `cleanupSessionResources(sessionId)` — run per-session resource
938
+ * cleanups (bash watchers, etc.) exactly as dispose() would.
939
+ * - drop the reference — the session and its Agent are per-subagent and
940
+ * become garbage once the manager removes the record. Event
941
+ * subscriptions (turn tracking, text collection, abort forwarding) are
942
+ * unsubscribed in runAgent's finally block, so no listeners leak.
943
+ */
944
+ export function disposeSessionQuietly(session: AgentSession): void {
945
+ try {
946
+ session.agent.abort();
947
+ } catch {
948
+ /* best-effort */
949
+ }
950
+ try {
951
+ cleanupSessionResources(session.sessionId);
952
+ } catch {
953
+ /* best-effort */
954
+ }
955
+ }
956
+
957
+ /**
958
+ * Get the subagent's conversation messages as formatted text.
959
+ */
960
+ export function getAgentConversation(session: AgentSession): string {
961
+ const parts: string[] = [];
962
+
963
+ for (const msg of session.messages) {
964
+ if (msg.role === "user") {
965
+ const text = typeof msg.content === "string"
966
+ ? msg.content
967
+ : extractText(msg.content);
968
+ if (text.trim()) parts.push(`[User]: ${text.trim()}`);
969
+ } else if (msg.role === "assistant") {
970
+ const textParts: string[] = [];
971
+ const toolCalls: string[] = [];
972
+ for (const c of msg.content) {
973
+ if (c.type === "text" && c.text) textParts.push(c.text);
974
+ else if (c.type === "toolCall") toolCalls.push(` Tool: ${(c as any).name ?? (c as any).toolName ?? "unknown"}`);
975
+ }
976
+ if (textParts.length > 0) parts.push(`[Assistant]: ${textParts.join("\n")}`);
977
+ if (toolCalls.length > 0) parts.push(`[Tool Calls]:\n${toolCalls.join("\n")}`);
978
+ } else if (msg.role === "toolResult") {
979
+ const text = extractText(msg.content);
980
+ const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text;
981
+ parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`);
982
+ }
983
+ }
984
+
985
+ return parts.join("\n\n");
986
+ }