pi-subagent-feather 0.0.0-stage → 0.2.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,693 @@
1
+ // Derived from @eggmasonvalue/pi-subagent 2.0.1 (MIT, copyright eggmasonvalue).
2
+ // Lite receipt format: no child transcript, args, thinking or images in parent details.
3
+ import { spawn, type ChildProcess } from "node:child_process";
4
+ import * as fs from "node:fs";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+ import type { AgentToolResult, ThinkingLevel } from "@earendil-works/pi-agent-core";
8
+ import { getSupportedThinkingLevels, StringEnum, type Model } from "@earendil-works/pi-ai";
9
+ import { type ExtensionAPI, copyToClipboard, getAgentDir, getPackageDir, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
10
+ import { Container, Markdown, Text } from "@earendil-works/pi-tui";
11
+ import { Type } from "typebox";
12
+ import { ChildCollector, DEFAULT_LIMITS, type CollectorLimits, type CollectorUsage, type KeepMode } from "../../src/collector.ts";
13
+
14
+ const CHILD_METADATA_SUFFIX = ".subagent.json";
15
+ const INTERNAL_TOOL_NAMES = new Set(["subagent", "subagent_models"]);
16
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const satisfies readonly ThinkingLevel[];
17
+ const THINKING_LEVEL_SET = new Set<string>(THINKING_LEVELS);
18
+
19
+ interface ChildMetadata {
20
+ version: 2;
21
+ cwd: string;
22
+ model?: string;
23
+ thinking?: string;
24
+ tools: string[];
25
+ }
26
+
27
+ type Status = "running" | "done" | "failed" | "timeout" | "aborted";
28
+ interface Receipt {
29
+ version: 1;
30
+ status: Status;
31
+ label?: string;
32
+ resumed: boolean;
33
+ timeoutMs?: number;
34
+ exitCode: number;
35
+ sessionFile?: string;
36
+ sessionId?: string;
37
+ model?: string;
38
+ thinking?: string;
39
+ usage: CollectorUsage;
40
+ toolCount: number;
41
+ droppedEvents: number;
42
+ truncated: boolean;
43
+ currentTool?: string;
44
+ }
45
+ interface RunResult extends Receipt {
46
+ output: string;
47
+ stderr: string;
48
+ errorMessage?: string;
49
+ }
50
+ type Update = (partial: AgentToolResult<Receipt>) => void;
51
+
52
+ interface ModelAllowlistLevel {
53
+ artificialAnalysis?: { intelligence?: number; coding?: number; cost?: number };
54
+ deepSWE?: { pass?: number; cost?: number };
55
+ }
56
+
57
+ interface ModelAllowlistEntry {
58
+ id: string;
59
+ levels?: Record<string, ModelAllowlistLevel>;
60
+ description?: string;
61
+ [key: string]: unknown;
62
+ }
63
+
64
+ interface ModelAllowlistConfig {
65
+ enabled?: boolean;
66
+ allowed?: (string | ModelAllowlistEntry)[];
67
+ default?: string;
68
+ defaultThinking?: string;
69
+ }
70
+
71
+ interface ModelPolicy {
72
+ enabled: boolean;
73
+ allowed: Set<string>;
74
+ metadata: Map<string, ModelAllowlistEntry>;
75
+ defaultModel?: string;
76
+ defaultThinking?: string;
77
+ configPath: string;
78
+ }
79
+
80
+ interface RunConfig {
81
+ cwd: string;
82
+ model?: string;
83
+ thinking?: string;
84
+ tools: string[];
85
+ }
86
+
87
+
88
+ function getModelAllowlistPath(): string {
89
+ return path.join(getAgentDir(), "pi-subagent", "models-allowlist.json");
90
+ }
91
+
92
+ function loadModelPolicy(configPath = getModelAllowlistPath()): { policy: ModelPolicy; error?: string } {
93
+ const disabled: ModelPolicy = {
94
+ enabled: false,
95
+ allowed: new Set(),
96
+ metadata: new Map(),
97
+ configPath,
98
+ };
99
+ if (!fs.existsSync(configPath)) return { policy: disabled };
100
+
101
+ let parsed: unknown;
102
+ try {
103
+ parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
104
+ } catch (error) {
105
+ return {
106
+ policy: disabled,
107
+ error: `Invalid JSON in model allowlist: ${error instanceof Error ? error.message : String(error)}`,
108
+ };
109
+ }
110
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
111
+ return { policy: disabled, error: "Model allowlist must be a JSON object." };
112
+ }
113
+
114
+ const config = parsed as ModelAllowlistConfig;
115
+ if (config.enabled !== undefined && typeof config.enabled !== "boolean") {
116
+ return { policy: disabled, error: 'Model allowlist field "enabled" must be boolean.' };
117
+ }
118
+ if (config.allowed !== undefined && !Array.isArray(config.allowed)) {
119
+ return { policy: disabled, error: 'Model allowlist field "allowed" must be an array.' };
120
+ }
121
+ if (config.default !== undefined && typeof config.default !== "string") {
122
+ return { policy: disabled, error: 'Model allowlist field "default" must be a string.' };
123
+ }
124
+ if (config.defaultThinking !== undefined && typeof config.defaultThinking !== "string") {
125
+ return { policy: disabled, error: 'Model allowlist field "defaultThinking" must be a string.' };
126
+ }
127
+ const defaultThinking = config.defaultThinking?.trim() || undefined;
128
+ if (defaultThinking && !THINKING_LEVEL_SET.has(defaultThinking)) {
129
+ return { policy: disabled, error: `Default thinking level "${defaultThinking}" is not recognized by Pi.` };
130
+ }
131
+
132
+ const metadata = new Map<string, ModelAllowlistEntry>();
133
+ const allowed = new Set<string>();
134
+ for (const raw of config.allowed ?? []) {
135
+ if (typeof raw === "string") {
136
+ const id = raw.trim();
137
+ if (id) allowed.add(id);
138
+ continue;
139
+ }
140
+ if (raw && typeof raw === "object" && typeof raw.id === "string" && raw.id.trim()) {
141
+ const entry = { ...raw, id: raw.id.trim() };
142
+ allowed.add(entry.id);
143
+ metadata.set(entry.id, entry);
144
+ }
145
+ }
146
+
147
+ const enabled = config.enabled ?? true;
148
+ const defaultModel = config.default?.trim() || undefined;
149
+ if (enabled && allowed.size === 0) {
150
+ return { policy: disabled, error: 'Model allowlist is enabled but "allowed" is empty.' };
151
+ }
152
+ if (enabled && defaultModel && !allowed.has(defaultModel)) {
153
+ return { policy: disabled, error: 'The default model must also appear in "allowed".' };
154
+ }
155
+
156
+ return { policy: { enabled, allowed, metadata, defaultModel, defaultThinking, configPath } };
157
+ }
158
+
159
+ function allowedThinkingLevels(entry: ModelAllowlistEntry | undefined): string[] | undefined {
160
+ if (!entry?.levels || typeof entry.levels !== "object" || Array.isArray(entry.levels)) return undefined;
161
+ const levels = Object.keys(entry.levels);
162
+ return levels.length > 0 ? levels : undefined;
163
+ }
164
+
165
+ interface ModelRegistryView {
166
+ getAll(): Model<any>[];
167
+ }
168
+
169
+ function modelMap(registry: ModelRegistryView): Map<string, Model<any>> {
170
+ return new Map(registry.getAll().map((model) => [`${model.provider}/${model.id}`, model]));
171
+ }
172
+
173
+ function effectiveThinkingLevels(entry: ModelAllowlistEntry | undefined, model: Model<any>): ThinkingLevel[] {
174
+ const supported = getSupportedThinkingLevels(model) as ThinkingLevel[];
175
+ const configured = allowedThinkingLevels(entry);
176
+ return configured
177
+ ? supported.filter((level) => configured.includes(level))
178
+ : supported;
179
+ }
180
+
181
+ function validateModelPolicy(policy: ModelPolicy, registry: ModelRegistryView): string[] {
182
+ if (!policy.enabled) return [];
183
+ const errors: string[] = [];
184
+ const known = modelMap(registry);
185
+
186
+ for (const id of policy.allowed) {
187
+ const model = known.get(id);
188
+ if (!model) {
189
+ errors.push(`Allowlisted model "${id}" is not known to Pi.`);
190
+ continue;
191
+ }
192
+ const supported = new Set<string>(getSupportedThinkingLevels(model));
193
+ for (const level of allowedThinkingLevels(policy.metadata.get(id)) ?? []) {
194
+ if (!THINKING_LEVEL_SET.has(level)) {
195
+ errors.push(`Thinking level "${level}" configured for "${id}" is not recognized by Pi.`);
196
+ } else if (!supported.has(level)) {
197
+ errors.push(`Thinking level "${level}" is unsupported by "${id}".`);
198
+ }
199
+ }
200
+ }
201
+ if (policy.defaultThinking && policy.defaultModel) {
202
+ const defaultModel = known.get(policy.defaultModel);
203
+ if (defaultModel && !effectiveThinkingLevels(policy.metadata.get(policy.defaultModel), defaultModel).includes(policy.defaultThinking as ThinkingLevel)) {
204
+ errors.push(`Default thinking level "${policy.defaultThinking}" is not allowed for "${policy.defaultModel}".`);
205
+ }
206
+ }
207
+ return errors;
208
+ }
209
+
210
+ function resolveFreshModel(
211
+ requestedModel: string | undefined,
212
+ thinking: string | undefined,
213
+ policy: ModelPolicy,
214
+ registry?: ModelRegistryView,
215
+ ): { model?: string; thinking?: string; error?: string } {
216
+ if (!policy.enabled) return { model: requestedModel?.trim() || undefined };
217
+ const model = requestedModel?.trim() || policy.defaultModel;
218
+ if (!model) return { error: "Model policy requires `model`, but no default is configured." };
219
+ if (!policy.allowed.has(model)) {
220
+ return { error: `Model "${model}" is not allowed. Call subagent_models for permitted models.` };
221
+ }
222
+ const entry = policy.metadata.get(model);
223
+ const configuredLevels = allowedThinkingLevels(entry);
224
+ const knownModel = registry ? modelMap(registry).get(model) : undefined;
225
+ const levels = knownModel ? effectiveThinkingLevels(entry, knownModel) : configuredLevels;
226
+ const selectedThinking = thinking || policy.defaultThinking;
227
+ if (!selectedThinking && configuredLevels) {
228
+ return { error: `Thinking level is required for "${model}". Allowed: ${levels?.join(", ") || "none"}.` };
229
+ }
230
+ if (selectedThinking && levels && !levels.includes(selectedThinking)) {
231
+ return { error: `Thinking level "${selectedThinking}" is not allowed for "${model}". Allowed: ${levels.join(", ") || "none"}.` };
232
+ }
233
+ return selectedThinking && !thinking ? { model, thinking: selectedThinking } : { model };
234
+ }
235
+
236
+ function compactModelCatalog(policy: ModelPolicy, validationErrors: string[], registry: ModelRegistryView) {
237
+ const known = modelMap(registry);
238
+ return {
239
+ policy: policy.enabled,
240
+ default: policy.defaultModel ?? null,
241
+ thinking: policy.defaultThinking ?? null,
242
+ models: Array.from(policy.allowed, id => {
243
+ const entry = policy.metadata.get(id);
244
+ const model = known.get(id);
245
+ return { id, levels: model ? effectiveThinkingLevels(entry, model) : [],
246
+ ...(entry?.description ? { note: bounded(entry.description, 256) } : {}) };
247
+ }),
248
+ ...(validationErrors.length ? { errors: validationErrors } : {}),
249
+ };
250
+ }
251
+
252
+ function metadataPath(sessionFile: string): string {
253
+ return `${sessionFile}${CHILD_METADATA_SUFFIX}`;
254
+ }
255
+
256
+ function writeChildMetadata(sessionFile: string, metadata: ChildMetadata): void {
257
+ const destination = metadataPath(sessionFile);
258
+ const temporary = `${destination}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
259
+ try {
260
+ fs.writeFileSync(temporary, `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
261
+ fs.renameSync(temporary, destination);
262
+ } finally {
263
+ try {
264
+ if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
265
+ } catch {
266
+ // Preserve the original metadata error.
267
+ }
268
+ }
269
+ }
270
+
271
+ function readChildMetadata(sessionFile: string): ChildMetadata {
272
+ const file = metadataPath(sessionFile);
273
+ let parsed: unknown;
274
+ try {
275
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
276
+ } catch {
277
+ throw new Error(`Subagent runtime metadata not found or invalid: ${file}`);
278
+ }
279
+ if (!parsed || typeof parsed !== "object") throw new Error(`Invalid subagent runtime metadata: ${file}`);
280
+ const value = parsed as Partial<ChildMetadata>;
281
+ if (value.version !== 2 || typeof value.cwd !== "string" || !Array.isArray(value.tools)) {
282
+ throw new Error(`Invalid subagent runtime metadata: ${file}`);
283
+ }
284
+ return {
285
+ version: 2,
286
+ cwd: value.cwd,
287
+ model: typeof value.model === "string" ? value.model : undefined,
288
+ thinking: typeof value.thinking === "string" ? value.thinking : undefined,
289
+ tools: value.tools.filter((tool): tool is string => typeof tool === "string"),
290
+ };
291
+ }
292
+
293
+ function expandHome(input: string): string {
294
+ if (input === "~") return os.homedir();
295
+ if (input.startsWith("~/") || input.startsWith("~\\")) return path.join(os.homedir(), input.slice(2));
296
+ return input;
297
+ }
298
+
299
+ function resolveResumePath(input: string): string {
300
+ const expanded = expandHome(input.trim());
301
+ if (!path.isAbsolute(expanded) || path.extname(expanded) !== ".jsonl") {
302
+ throw new Error("`resume` must be the exact absolute JSONL path returned by an earlier subagent call.");
303
+ }
304
+ const resolved = path.resolve(expanded);
305
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
306
+ throw new Error(`Subagent session not found: ${resolved}`);
307
+ }
308
+ return resolved;
309
+ }
310
+
311
+ function bounded(text: string, bytes: number): string {
312
+ let used = 0, end = 0;
313
+ for (const char of text) {
314
+ const size = Buffer.byteLength(char);
315
+ if (used + size > bytes) break;
316
+ used += size; end += char.length;
317
+ }
318
+ return Buffer.from(text.slice(0, end), "utf8").toString("utf8");
319
+ }
320
+
321
+ interface FeatherConfig {
322
+ limits: CollectorLimits;
323
+ offlineChildren: boolean;
324
+ defaultTimeoutMs?: number;
325
+ resultKeep: KeepMode;
326
+ }
327
+
328
+ function loadConfig(agentDir = getAgentDir()): FeatherConfig {
329
+ const file = path.join(agentDir, "pi-subagent-feather", "config.json");
330
+ if (!fs.existsSync(file)) return { limits: { ...DEFAULT_LIMITS }, offlineChildren: false, resultKeep: "head" };
331
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
332
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid config: ${file}`);
333
+ const limits = { ...DEFAULT_LIMITS };
334
+ const config: FeatherConfig = { limits, offlineChildren: false, resultKeep: "head" };
335
+ const maximum: CollectorLimits = { resultBytes: 51200, resultLines: 2000, progressBytes: 8192, stderrBytes: 16384, eventBytes: 64 * 1024 * 1024 };
336
+ for (const key of Object.keys(value)) {
337
+ if (key in limits) {
338
+ if (!Number.isInteger(value[key]) || value[key] < 1 || value[key] > maximum[key as keyof CollectorLimits]) throw new Error(`Invalid ${key} in ${file}`);
339
+ limits[key as keyof CollectorLimits] = value[key];
340
+ } else if (key === "offlineChildren") {
341
+ if (typeof value[key] !== "boolean") throw new Error(`Invalid offlineChildren in ${file}`);
342
+ config.offlineChildren = value[key];
343
+ } else if (key === "defaultTimeoutMs") {
344
+ if (!Number.isInteger(value[key]) || value[key] < 1 || value[key] > 2147483647) throw new Error(`Invalid defaultTimeoutMs in ${file}`);
345
+ config.defaultTimeoutMs = value[key];
346
+ } else if (key === "resultKeep") {
347
+ if (value[key] !== "head" && value[key] !== "tail") throw new Error(`Invalid resultKeep in ${file}`);
348
+ config.resultKeep = value[key];
349
+ } else {
350
+ throw new Error(`Unknown config key "${key}" in ${file}`);
351
+ }
352
+ }
353
+ return config;
354
+ }
355
+
356
+ function resolveSessionFile(sessionDir: string): string | undefined {
357
+ try {
358
+ const file = fs.readdirSync(sessionDir).find(name => name.endsWith(".jsonl"));
359
+ return file ? path.join(sessionDir, file) : undefined;
360
+ } catch { return undefined; }
361
+ }
362
+
363
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
364
+ const executable = path.basename(process.execPath).toLowerCase();
365
+ if (!/^(node|bun)(\.exe)?$/.test(executable)) return { command: process.execPath, args };
366
+ const script = process.argv[1];
367
+ // Do not relaunch an arbitrary embedding app/test script as if it were Pi.
368
+ if (script && script.replaceAll("\\", "/").includes("pi-coding-agent/") && fs.existsSync(script)) {
369
+ return { command: process.execPath, args: [script, ...args] };
370
+ }
371
+ const cli = path.join(getPackageDir(), "dist", "cli.js");
372
+ if (fs.existsSync(cli)) return { command: process.execPath, args: [cli, ...args] };
373
+ throw new Error("Cannot locate the host Pi CLI for a child process.");
374
+ }
375
+
376
+ function killChild(proc: ChildProcess): void {
377
+ if (proc.exitCode !== null || proc.signalCode !== null) return;
378
+ if (process.platform === "win32" && proc.pid) {
379
+ const killer = spawn("taskkill.exe", ["/pid", String(proc.pid), "/t", "/f"], { stdio: "ignore", windowsHide: true });
380
+ killer.on("error", () => proc.kill());
381
+ killer.on("close", code => { if (code !== 0 && proc.exitCode === null && proc.signalCode === null) proc.kill(); });
382
+ killer.unref();
383
+ } else {
384
+ const stop = (signal: NodeJS.Signals) => {
385
+ try { if (proc.pid) process.kill(-proc.pid, signal); else proc.kill(signal); } catch { proc.kill(signal); }
386
+ };
387
+ stop("SIGTERM");
388
+ setTimeout(() => { if (proc.exitCode === null && proc.signalCode === null) stop("SIGKILL"); }, 5000).unref();
389
+ }
390
+ }
391
+
392
+ // Explicit projection: never spread a RunResult into persisted details.
393
+ function receiptOf(result: Receipt): Receipt {
394
+ return { version: 1, status: result.status, label: result.label, resumed: result.resumed,
395
+ timeoutMs: result.timeoutMs, exitCode: result.exitCode, sessionFile: result.sessionFile,
396
+ sessionId: result.sessionId, model: result.model, thinking: result.thinking,
397
+ usage: { ...result.usage, cost: { ...result.usage.cost } }, toolCount: result.toolCount,
398
+ droppedEvents: result.droppedEvents, truncated: result.truncated, currentTool: result.currentTool };
399
+ }
400
+
401
+ function modelFacingResult(result: RunResult): string {
402
+ const fields = [`status=${result.status}`];
403
+ if (result.label) fields.push(`label=${result.label}`);
404
+ if (result.sessionFile) fields.push(`session=${result.sessionFile}`);
405
+ const error = result.errorMessage ? `Error: ${result.errorMessage}\n` : "";
406
+ const output = result.output || (result.status === "failed" ? result.stderr.trim() : "") || "(no output)";
407
+ const hint = result.truncated ? "\n[Truncated; full text is in the child session.]" : "";
408
+ return `[${fields.join(" ")}]\n${error}${output}${hint}`;
409
+ }
410
+
411
+ interface RunChildDependencies {
412
+ agentDir?: string;
413
+ invoke?: (args: string[]) => { command: string; args: string[] };
414
+ limits?: Partial<CollectorLimits>;
415
+ resultKeep?: KeepMode;
416
+ offlineChildren?: boolean;
417
+ }
418
+
419
+ async function runChild(
420
+ config: RunConfig, task: string, label: string | undefined, timeoutMs: number | undefined,
421
+ resumePath: string | undefined, signal: AbortSignal | undefined, onUpdate: Update | undefined,
422
+ dependencies: RunChildDependencies = {},
423
+ ): Promise<RunResult> {
424
+ const feather = loadConfig(dependencies.agentDir);
425
+ const collector = new ChildCollector({ ...feather.limits, ...dependencies.limits }, { resultKeep: dependencies.resultKeep ?? feather.resultKeep });
426
+ let sessionFile = resumePath, exitCode = -1, status: Status = "running";
427
+ const snapshot = (): Receipt => ({ version: 1, status, label, resumed: Boolean(resumePath), timeoutMs, exitCode,
428
+ sessionFile, sessionId: collector.sessionId, model: config.model ?? collector.model, thinking: config.thinking,
429
+ usage: collector.usage, toolCount: collector.toolCount, droppedEvents: collector.droppedEvents,
430
+ truncated: collector.truncated, currentTool: collector.currentTool });
431
+ if (signal?.aborted) return { ...receiptOf({ ...snapshot(), status: "aborted", exitCode: 1 }), output: "", stderr: "" };
432
+ const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
433
+ const sessionDir = path.join(dependencies.agentDir ?? getAgentDir(), "sessions", "subagent-lite", runId);
434
+ if (!resumePath) await fs.promises.mkdir(sessionDir, { recursive: true });
435
+ const args = ["--mode", "json", "-p"];
436
+ if (resumePath) args.push("--session", resumePath); else args.push("--session-dir", sessionDir);
437
+ if (dependencies.offlineChildren ?? feather.offlineChildren) args.push("--offline");
438
+ if (config.model) args.push("--model", config.model);
439
+ if (config.thinking) args.push("--thinking", config.thinking);
440
+ if (!config.tools.length) args.push("--no-tools"); else args.push("--tools", config.tools.join(","));
441
+ if (config.tools.some(tool => INTERNAL_TOOL_NAMES.has(tool))) args.push("--extension", path.join(import.meta.dirname, "index.ts"));
442
+ // No append-system-prompt, promptGuidelines or promptSnippet injection.
443
+ let aborted = false, timedOut = false;
444
+ let lastUpdate = 0;
445
+ let updateTimer: ReturnType<typeof setTimeout> | undefined;
446
+ const emitUpdate = (force = false) => {
447
+ if (!onUpdate) return;
448
+ const remaining = 100 - (Date.now() - lastUpdate);
449
+ if (!force && remaining > 0) {
450
+ if (!updateTimer) updateTimer = setTimeout(() => { updateTimer = undefined; emitUpdate(true); }, remaining);
451
+ return;
452
+ }
453
+ if (updateTimer) { clearTimeout(updateTimer); updateTimer = undefined; }
454
+ lastUpdate = Date.now();
455
+ if (!sessionFile) sessionFile = resolveSessionFile(sessionDir);
456
+ // Fresh detached metadata; no mutable collector/event references in the UI.
457
+ try {
458
+ onUpdate({ content: [{ type: "text", text: collector.progress() || "(running)" }], details: receiptOf(snapshot()) });
459
+ } catch { /* Progress UI must not crash a child run or leave it orphaned. */ }
460
+ };
461
+ exitCode = await new Promise<number>(resolve => {
462
+ const invocation = (dependencies.invoke ?? getPiInvocation)(args);
463
+ const proc = spawn(invocation.command, invocation.args, { cwd: config.cwd, shell: false,
464
+ detached: process.platform !== "win32", windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
465
+ let settled = false;
466
+ let timer: ReturnType<typeof setTimeout> | undefined;
467
+ const onAbort = () => { if (!timedOut) { aborted = true; if (timer) clearTimeout(timer); killChild(proc); } };
468
+ const finish = (code: number) => {
469
+ if (settled) return;
470
+ settled = true;
471
+ if (timer) clearTimeout(timer);
472
+ if (updateTimer) { clearTimeout(updateTimer); updateTimer = undefined; }
473
+ signal?.removeEventListener("abort", onAbort);
474
+ collector.endStdout(); collector.endStderr(); collector.finishUsage();
475
+ resolve(code);
476
+ };
477
+ proc.stdin.on("error", () => {});
478
+ proc.stdout.on("data", (data: Buffer) => { collector.pushStdout(data); emitUpdate(); });
479
+ proc.stderr.on("data", (data: Buffer) => collector.pushStderr(data));
480
+ proc.on("error", error => { collector.errorMessage = bounded(error.message, 2048); finish(1); });
481
+ proc.on("close", code => finish(code ?? 1));
482
+ if (timeoutMs) timer = setTimeout(() => { if (!aborted) { timedOut = true; killChild(proc); } }, timeoutMs);
483
+ if (signal?.aborted) onAbort(); else signal?.addEventListener("abort", onAbort, { once: true });
484
+ proc.stdin.end(task);
485
+ });
486
+ if (!sessionFile) sessionFile = resolveSessionFile(sessionDir);
487
+ if (!resumePath && sessionFile) {
488
+ try {
489
+ writeChildMetadata(sessionFile, { version: 2, cwd: config.cwd, model: config.model ?? collector.model, thinking: config.thinking, tools: config.tools });
490
+ } catch (error) {
491
+ collector.errorMessage = bounded(`Resume metadata could not be saved: ${String(error)}`, 2048);
492
+ exitCode = 1;
493
+ }
494
+ } else if (!sessionFile && !aborted && !timedOut) {
495
+ collector.errorMessage = "Child session was not created; this run cannot be resumed."; exitCode = 1;
496
+ }
497
+ status = timedOut ? "timeout" : aborted ? "aborted" : exitCode !== 0 || collector.stopReason === "error" || collector.errorMessage ? "failed" : "done";
498
+ collector.currentTool = undefined;
499
+ emitUpdate(true);
500
+ return { ...receiptOf(snapshot()), output: collector.answer(), stderr: collector.stderr, errorMessage: collector.errorMessage };
501
+ }
502
+
503
+ const SubagentParams = Type.Object({
504
+ task: Type.String({ minLength: 1, maxLength: 262144, description: "Task or resume instruction; include needed context and desired output." }),
505
+ label: Type.Optional(Type.String({ minLength: 1, maxLength: 64, pattern: "^[A-Za-z0-9._:-]+$" })),
506
+ model: Type.Optional(Type.String({ minLength: 1, maxLength: 512, description: "New only. Omit for default; see subagent_models." })),
507
+ thinking: Type.Optional(StringEnum(THINKING_LEVELS, { description: "New only. Omit for policy/Pi default." })),
508
+ tools: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 256 }), { uniqueItems: true, maxItems: 256, description: "New only. Omit=inherit except delegation; []=none. Include subagent to allow recursion." })),
509
+ cwd: Type.Optional(Type.String({ minLength: 1, maxLength: 4096, description: "New only. Defaults to parent cwd." })),
510
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 1, maximum: 2147483647, description: "Stop after this many ms; partial result is resumable." })),
511
+ resume: Type.Optional(Type.String({ minLength: 1, maxLength: 4096, description: "Absolute child JSONL path. Cannot combine with model/thinking/tools/cwd." })),
512
+ });
513
+
514
+ export const __testing = { allowedThinkingLevels, compactModelCatalog, loadModelPolicy, metadataPath,
515
+ modelFacingResult, readChildMetadata, resolveFreshModel, resolveResumePath, runChild,
516
+ validateModelPolicy, writeChildMetadata, receiptOf, loadConfig, recentChildSessions, formatBytes,
517
+ getPiInvocation, SubagentParams };
518
+
519
+ interface ChildSessionInfo {
520
+ path: string;
521
+ mtimeMs: number;
522
+ bytes: number;
523
+ sessionId?: string;
524
+ model?: string;
525
+ cwd?: string;
526
+ }
527
+
528
+ function formatBytes(n: number): string {
529
+ if (n < 1024) return `${n}B`;
530
+ if (n < 1024 * 1024) return `${Math.round(n / 1024)}KB`;
531
+ return `${(n / (1024 * 1024)).toFixed(1)}MB`;
532
+ }
533
+
534
+ /** Recent child sessions (newest first). Read-only: stats, sidecar metadata, first-line id. */
535
+ function recentChildSessions(agentDir = getAgentDir(), max = 24): ChildSessionInfo[] {
536
+ const root = path.join(agentDir, "sessions", "subagent-lite");
537
+ let runDirs: string[];
538
+ try {
539
+ runDirs = fs.readdirSync(root);
540
+ } catch {
541
+ return [];
542
+ }
543
+ const sessions: ChildSessionInfo[] = [];
544
+ for (const run of runDirs) {
545
+ const dir = path.join(root, run);
546
+ let names: string[];
547
+ try {
548
+ names = fs.readdirSync(dir);
549
+ } catch {
550
+ continue;
551
+ }
552
+ for (const name of names) {
553
+ if (!name.endsWith(".jsonl")) continue;
554
+ const file = path.join(dir, name);
555
+ let stat: fs.Stats;
556
+ try {
557
+ stat = fs.statSync(file);
558
+ } catch {
559
+ continue;
560
+ }
561
+ if (!stat.isFile()) continue;
562
+ const info: ChildSessionInfo = { path: file, mtimeMs: stat.mtimeMs, bytes: stat.size };
563
+ try {
564
+ const meta = readChildMetadata(file);
565
+ info.model = meta.model;
566
+ info.cwd = meta.cwd;
567
+ } catch {
568
+ // Sidecar metadata is optional for listing.
569
+ }
570
+ try {
571
+ const fd = fs.openSync(file, "r");
572
+ try {
573
+ const buf = Buffer.alloc(512);
574
+ const read = fs.readSync(fd, buf, 0, 512, 0);
575
+ const first = JSON.parse(buf.toString("utf8", 0, read).split("\n", 1)[0]);
576
+ if (first?.type === "session" && first.id) info.sessionId = String(first.id).slice(0, 64);
577
+ } finally {
578
+ fs.closeSync(fd);
579
+ }
580
+ } catch {
581
+ // First-line probe is optional.
582
+ }
583
+ sessions.push(info);
584
+ }
585
+ }
586
+ return sessions.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, max);
587
+ }
588
+
589
+ export default function (pi: ExtensionAPI) {
590
+ const running = new Map<AbortController, Promise<RunResult>>();
591
+ pi.on("session_shutdown", async () => {
592
+ for (const controller of running.keys()) controller.abort();
593
+ await Promise.allSettled(running.values());
594
+ running.clear();
595
+ });
596
+ pi.registerCommand("subagents", {
597
+ description: "List recent subagent child sessions (pick one to copy its resume path)",
598
+ handler: async (_args, ctx) => {
599
+ const sessions = recentChildSessions();
600
+ if (sessions.length === 0) {
601
+ ctx.ui.notify("No subagent child sessions found.", "info");
602
+ return;
603
+ }
604
+ const lines = sessions.map(s => `${new Date(s.mtimeMs).toLocaleString()} ${formatBytes(s.bytes)} ${s.model ?? "?"} ${s.path}`);
605
+ if (!ctx.hasUI) {
606
+ ctx.ui.notify(lines.join("\n"), "info");
607
+ return;
608
+ }
609
+ let picked: string | undefined;
610
+ try {
611
+ picked = await ctx.ui.select(`Subagent sessions (${sessions.length})`, lines);
612
+ } catch {
613
+ picked = undefined;
614
+ }
615
+ if (!picked) return;
616
+ const chosen = sessions[lines.indexOf(picked)];
617
+ if (!chosen) return;
618
+ try {
619
+ await copyToClipboard(chosen.path);
620
+ ctx.ui.notify(`Copied resume path: ${chosen.path}`, "info");
621
+ } catch {
622
+ ctx.ui.notify(`Resume path: ${chosen.path}`, "info");
623
+ }
624
+ },
625
+ });
626
+ pi.registerTool({
627
+ name: "subagent_models", label: "Subagent Models",
628
+ description: "List child model policy, defaults and supported thinking levels. No child starts.",
629
+ parameters: Type.Object({}),
630
+ async execute(_id, _params, _signal, _update, ctx) {
631
+ const { policy, error } = loadModelPolicy();
632
+ if (error) throw new Error(error);
633
+ const catalog = compactModelCatalog(policy, validateModelPolicy(policy, ctx.modelRegistry), ctx.modelRegistry);
634
+ return { content: [{ type: "text", text: JSON.stringify(catalog) }], details: undefined };
635
+ },
636
+ });
637
+ pi.registerTool({
638
+ name: "subagent", label: "Subagent Lite",
639
+ description: "Delegate one task to an isolated Pi. Returns answer and resumable session path; timeout stops the child. Parallel calls are independent.",
640
+ parameters: SubagentParams,
641
+ async execute(_id, params, signal, onUpdate, ctx) {
642
+ if (!params.task.trim()) throw new Error("Subagent task must not be blank.");
643
+ const { policy, error } = loadModelPolicy();
644
+ if (error) throw new Error(error);
645
+ const validation = validateModelPolicy(policy, ctx.modelRegistry);
646
+ if (validation.length) throw new Error(validation.join("\n"));
647
+ let resumePath: string | undefined, config: RunConfig;
648
+ if (params.resume) {
649
+ if (params.model !== undefined || params.thinking !== undefined || params.tools !== undefined || params.cwd !== undefined) throw new Error("Resume accepts only task, resume, label and timeoutMs.");
650
+ resumePath = resolveResumePath(params.resume);
651
+ const saved = readChildMetadata(resumePath);
652
+ if (policy.enabled && (!saved.model || !policy.allowed.has(saved.model))) throw new Error("Saved child model is missing or no longer permitted.");
653
+ const model = saved.model ? modelMap(ctx.modelRegistry).get(saved.model) : undefined;
654
+ const entry = saved.model ? policy.metadata.get(saved.model) : undefined;
655
+ if (policy.enabled && allowedThinkingLevels(entry) && !saved.thinking) throw new Error("Saved child thinking level is required by current policy.");
656
+ if (policy.enabled && model && saved.thinking && !effectiveThinkingLevels(entry, model).includes(saved.thinking as ThinkingLevel)) throw new Error("Saved child thinking level is no longer permitted.");
657
+ config = saved;
658
+ } else {
659
+ const selected = resolveFreshModel(params.model, params.thinking, policy, ctx.modelRegistry);
660
+ if (selected.error) throw new Error(selected.error);
661
+ config = { cwd: path.resolve(params.cwd ?? ctx.cwd), model: selected.model, thinking: selected.thinking ?? params.thinking,
662
+ tools: params.tools ? [...new Set(params.tools)] : pi.getActiveTools().filter(name => !INTERNAL_TOOL_NAMES.has(name)) };
663
+ }
664
+ const controller = new AbortController();
665
+ const feather = loadConfig();
666
+ const child = runChild(config, params.task, params.label, params.timeoutMs ?? feather.defaultTimeoutMs, resumePath,
667
+ signal ? AbortSignal.any([signal, controller.signal]) : controller.signal, onUpdate,
668
+ { resultKeep: feather.resultKeep });
669
+ running.set(controller, child);
670
+ try {
671
+ const result = await child;
672
+ return { content: [{ type: "text", text: modelFacingResult(result) }], details: receiptOf(result), usage: result.usage };
673
+ } finally { running.delete(controller); }
674
+ },
675
+ renderCall(args, theme) {
676
+ return new Text(`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.label ?? (args.resume ? "resume" : "child"))}\n${theme.fg("dim", bounded(args.task, 160))}`, 0, 0);
677
+ },
678
+ renderResult(result, { expanded }, theme) {
679
+ const receipt = result.details as Receipt | undefined;
680
+ const text = result.content.filter(part => part.type === "text").map(part => part.text).join("\n");
681
+ if (!receipt || receipt.version !== 1) return new Text(bounded(text, expanded ? 8192 : 512), 0, 0);
682
+ const color = receipt.status === "done" ? "success" : receipt.status === "running" || receipt.status === "timeout" ? "warning" : "error";
683
+ let header = `${theme.fg(color, receipt.status)} ${receipt.label ?? "child"} · ${receipt.toolCount} tools`;
684
+ if (receipt.currentTool) header += ` · ${receipt.currentTool}`;
685
+ header += ` · ${receipt.usage.turns} turns · ↑${receipt.usage.input} ↓${receipt.usage.output}`;
686
+ if (!expanded) return new Text(`${header}\n${theme.fg("dim", bounded(text.split("\n").slice(1, 4).join("\n"), 512))}`, 0, 0);
687
+ const container = new Container();
688
+ container.addChild(new Text(header, 0, 0));
689
+ container.addChild(new Markdown(text, 0, 0, getMarkdownTheme()));
690
+ return container;
691
+ },
692
+ });
693
+ }