@johnnywu/pi-subagents 1.0.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,498 @@
1
+ import { AuthStorage, ModelRegistry, withFileMutationQueue } from '@earendil-works/pi-coding-agent';
2
+ import { spawn } from 'node:child_process';
3
+ import * as fs from 'node:fs/promises';
4
+ import * as os from 'node:os';
5
+ import * as path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import type { AgentConfig } from './agent-loader.ts';
8
+
9
+ export interface AgentUsage {
10
+ input: number;
11
+ output: number;
12
+ cacheRead: number;
13
+ cacheWrite: number;
14
+ cost: number;
15
+ contextTokens: number;
16
+ contextWindow?: number;
17
+ }
18
+
19
+ export interface AgentToolLog {
20
+ id: string;
21
+ name: string;
22
+ args: Record<string, unknown>;
23
+ status: 'running' | 'done';
24
+ nested?: AgentProgress;
25
+ }
26
+
27
+ export interface AgentProgress {
28
+ agent: string;
29
+ status: 'running' | 'done' | 'error';
30
+ output: string;
31
+ tools: AgentToolLog[];
32
+ usage: AgentUsage;
33
+ startedAt: number;
34
+ elapsedMs: number;
35
+ model?: string;
36
+ }
37
+
38
+ export interface AgentResult extends AgentProgress {
39
+ isError: boolean;
40
+ exitCode: number;
41
+ stderr: string;
42
+ }
43
+
44
+ export interface PiResolution {
45
+ command: string;
46
+ entryPoint: string;
47
+ }
48
+
49
+ export interface ProcessInvocation {
50
+ command: string;
51
+ args: string[];
52
+ cwd: string;
53
+ env: NodeJS.ProcessEnv;
54
+ }
55
+
56
+ export interface ProcessHandlers {
57
+ stdout(chunk: string): void;
58
+ stderr(chunk: string): void;
59
+ }
60
+
61
+ export type ProcessRunner = (
62
+ invocation: ProcessInvocation,
63
+ handlers: ProcessHandlers,
64
+ signal?: AbortSignal,
65
+ ) => Promise<{ exitCode: number }>;
66
+
67
+ export interface ExecutorFs {
68
+ makeTempDir(prefix: string): Promise<string>;
69
+ writeFile(filePath: string, content: string): Promise<void>;
70
+ removeDir(dir: string): Promise<void>;
71
+ }
72
+
73
+ export interface RunSubagentOptions {
74
+ agent: AgentConfig;
75
+ task: string;
76
+ cwd: string;
77
+ signal?: AbortSignal;
78
+ onProgress?: (progress: AgentProgress) => void;
79
+ depth?: number;
80
+ tempRoot?: string;
81
+ outputArchiveDir?: string;
82
+ agentDir?: string;
83
+ resolvePi?: () => Promise<PiResolution> | PiResolution;
84
+ runner?: ProcessRunner;
85
+ fs?: ExecutorFs;
86
+ now?: () => number;
87
+ }
88
+
89
+ const TASK_FILE_THRESHOLD = 8000;
90
+ const OUTPUT_MAX_BYTES = 50 * 1024;
91
+ const OUTPUT_MAX_LINES = 2000;
92
+
93
+ const defaultFs: ExecutorFs = {
94
+ makeTempDir(prefix) {
95
+ return fs.mkdtemp(prefix);
96
+ },
97
+ async writeFile(filePath, content) {
98
+ await withFileMutationQueue(filePath, async () => {
99
+ await fs.writeFile(filePath, content, { encoding: 'utf8', mode: 0o600 });
100
+ });
101
+ },
102
+ async removeDir(dir) {
103
+ await fs.rm(dir, { recursive: true, force: true });
104
+ },
105
+ };
106
+
107
+ const emptyUsage = (): AgentUsage => ({
108
+ input: 0,
109
+ output: 0,
110
+ cacheRead: 0,
111
+ cacheWrite: 0,
112
+ cost: 0,
113
+ contextTokens: 0,
114
+ });
115
+
116
+ export function subagentSessionDir(
117
+ cwd: string,
118
+ agentDir = path.join(os.homedir(), '.pi', 'agent'),
119
+ ): string {
120
+ const safeProject = `--${path
121
+ .resolve(cwd)
122
+ .replace(/^[/\\]/, '')
123
+ .replace(/[/\\:]/g, '-')}--`;
124
+ return path.join(agentDir, 'sessions', safeProject, 'subagents');
125
+ }
126
+
127
+ export function resolvePiEntryPoint(): PiResolution {
128
+ const packageEntryPoint = fileURLToPath(import.meta.resolve('@earendil-works/pi-coding-agent'));
129
+ const packageRoot = path.dirname(path.dirname(packageEntryPoint));
130
+
131
+ return {
132
+ command: process.execPath,
133
+ entryPoint: path.join(packageRoot, 'dist', 'cli.js'),
134
+ };
135
+ }
136
+
137
+ export const defaultRunner: ProcessRunner = (invocation, handlers, signal) =>
138
+ new Promise((resolve, reject) => {
139
+ const child = spawn(invocation.command, invocation.args, {
140
+ cwd: invocation.cwd,
141
+ env: invocation.env,
142
+ stdio: ['ignore', 'pipe', 'pipe'],
143
+ });
144
+
145
+ child.stdout.on('data', (chunk) => handlers.stdout(String(chunk)));
146
+ child.stderr.on('data', (chunk) => handlers.stderr(String(chunk)));
147
+ child.on('error', reject);
148
+ child.on('close', (code) => resolve({ exitCode: code ?? 0 }));
149
+
150
+ if (signal) {
151
+ const abort = () => child.kill('SIGTERM');
152
+ if (signal.aborted) abort();
153
+ else signal.addEventListener('abort', abort, { once: true });
154
+ }
155
+ });
156
+
157
+ function textFromMessage(message: unknown): string | undefined {
158
+ if (!message || typeof message !== 'object') return undefined;
159
+ const content = (message as { content?: unknown }).content;
160
+ if (!Array.isArray(content)) return undefined;
161
+
162
+ for (const part of content) {
163
+ if (part && typeof part === 'object' && (part as { type?: unknown }).type === 'text') {
164
+ const text = (part as { text?: unknown }).text;
165
+ if (typeof text === 'string') return text;
166
+ }
167
+ }
168
+
169
+ return undefined;
170
+ }
171
+
172
+ type ContextWindowLookup = {
173
+ find: (provider: string, modelId: string) => { contextWindow?: number } | undefined;
174
+ };
175
+
176
+ function contextWindowFromMessage(
177
+ message: unknown,
178
+ modelRegistry?: ContextWindowLookup,
179
+ ): number | undefined {
180
+ if (!message || typeof message !== 'object' || !modelRegistry) return undefined;
181
+ const provider = (message as { provider?: unknown }).provider;
182
+ const model = (message as { model?: unknown }).model;
183
+ if (typeof provider !== 'string' || typeof model !== 'string') return undefined;
184
+ return modelRegistry.find(provider, model)?.contextWindow;
185
+ }
186
+
187
+ function usageFromMessage(
188
+ message: unknown,
189
+ modelRegistry?: ContextWindowLookup,
190
+ ): Partial<AgentUsage> | undefined {
191
+ if (!message || typeof message !== 'object') return undefined;
192
+ const usage = (message as { usage?: unknown }).usage;
193
+ if (!usage || typeof usage !== 'object') return undefined;
194
+ const typed = usage as Record<string, unknown>;
195
+ const cost =
196
+ typed.cost && typeof typed.cost === 'object'
197
+ ? (typed.cost as Record<string, unknown>)
198
+ : undefined;
199
+
200
+ return {
201
+ input: typeof typed.input === 'number' ? typed.input : undefined,
202
+ output: typeof typed.output === 'number' ? typed.output : undefined,
203
+ cacheRead: typeof typed.cacheRead === 'number' ? typed.cacheRead : undefined,
204
+ cacheWrite: typeof typed.cacheWrite === 'number' ? typed.cacheWrite : undefined,
205
+ cost: typeof cost?.total === 'number' ? cost.total : undefined,
206
+ contextTokens: typeof typed.totalTokens === 'number' ? typed.totalTokens : undefined,
207
+ contextWindow:
208
+ typeof typed.contextWindow === 'number'
209
+ ? typed.contextWindow
210
+ : contextWindowFromMessage(message, modelRegistry),
211
+ };
212
+ }
213
+
214
+ function modelFromMessage(message: unknown): string | undefined {
215
+ if (!message || typeof message !== 'object') return undefined;
216
+ const model = (message as { model?: unknown }).model;
217
+ return typeof model === 'string' ? model : undefined;
218
+ }
219
+
220
+ function updateUsage(target: AgentUsage, update: Partial<AgentUsage> | undefined) {
221
+ if (!update) return;
222
+ target.input += update.input ?? 0;
223
+ target.output += update.output ?? 0;
224
+ target.cacheRead += update.cacheRead ?? 0;
225
+ target.cacheWrite += update.cacheWrite ?? 0;
226
+ target.cost += update.cost ?? 0;
227
+ target.contextTokens = update.contextTokens ?? target.contextTokens;
228
+ target.contextWindow = update.contextWindow ?? target.contextWindow;
229
+ }
230
+
231
+ function replaceUsage(target: AgentUsage, source: AgentUsage) {
232
+ target.input = source.input;
233
+ target.output = source.output;
234
+ target.cacheRead = source.cacheRead;
235
+ target.cacheWrite = source.cacheWrite;
236
+ target.cost = source.cost;
237
+ target.contextTokens = source.contextTokens;
238
+ target.contextWindow = source.contextWindow;
239
+ }
240
+
241
+ function usageFromMessages(
242
+ messages: unknown,
243
+ modelRegistry?: ContextWindowLookup,
244
+ ): AgentUsage | undefined {
245
+ if (!Array.isArray(messages)) return undefined;
246
+ const aggregate = emptyUsage();
247
+ let sawUsage = false;
248
+
249
+ for (const message of messages) {
250
+ if (!message || typeof message !== 'object') continue;
251
+ if ((message as { role?: unknown }).role !== 'assistant') continue;
252
+ const update = usageFromMessage(message, modelRegistry);
253
+ if (!update) continue;
254
+ sawUsage = true;
255
+ updateUsage(aggregate, update);
256
+ }
257
+
258
+ return sawUsage ? aggregate : undefined;
259
+ }
260
+
261
+ function lastAssistantModel(messages: unknown): string | undefined {
262
+ if (!Array.isArray(messages)) return undefined;
263
+ for (let index = messages.length - 1; index >= 0; index--) {
264
+ const message = messages[index];
265
+ if (!message || typeof message !== 'object') continue;
266
+ if ((message as { role?: unknown }).role !== 'assistant') continue;
267
+ const model = modelFromMessage(message);
268
+ if (model) return model;
269
+ }
270
+ return undefined;
271
+ }
272
+
273
+ function buildTaskArgument(task: string, taskFilePath: string | undefined): string {
274
+ return taskFilePath ? `Task: @${taskFilePath}` : `Task: ${task}`;
275
+ }
276
+
277
+ function byteLength(text: string): number {
278
+ return Buffer.byteLength(text, 'utf8');
279
+ }
280
+
281
+ function keepTailByBytes(text: string, maxBytes: number): string {
282
+ let kept = text;
283
+ while (byteLength(kept) > maxBytes) kept = kept.slice(1);
284
+ return kept;
285
+ }
286
+
287
+ function truncateHeadContent(text: string, maxBytes: number, maxLines: number): string | undefined {
288
+ const lines = text.split('\n');
289
+ if (byteLength(text) <= maxBytes && lines.length <= maxLines) return undefined;
290
+
291
+ const lineLimited = lines.length > maxLines ? lines.slice(-maxLines).join('\n') : text;
292
+ return keepTailByBytes(lineLimited, maxBytes);
293
+ }
294
+
295
+ function safeFilePart(value: string): string {
296
+ return value.replace(/[^a-zA-Z0-9_.-]+/g, '_');
297
+ }
298
+
299
+ function progressFromPartialResult(partialResult: unknown): AgentProgress | undefined {
300
+ if (!partialResult || typeof partialResult !== 'object') return undefined;
301
+ const details = (partialResult as { details?: unknown }).details;
302
+ if (!details || typeof details !== 'object') return undefined;
303
+ const agent = (details as { agent?: unknown }).agent;
304
+ const status = (details as { status?: unknown }).status;
305
+ if (typeof agent !== 'string') return undefined;
306
+ if (status !== 'running' && status !== 'done' && status !== 'error') return undefined;
307
+ return details as AgentProgress;
308
+ }
309
+
310
+ export async function runSubagent(options: RunSubagentOptions): Promise<AgentResult> {
311
+ const fileSystem = options.fs ?? defaultFs;
312
+ const resolvePi = options.resolvePi ?? resolvePiEntryPoint;
313
+ const runner = options.runner ?? defaultRunner;
314
+ const now = options.now ?? Date.now;
315
+ const startedAt = now();
316
+ const tempPrefix = path.join(options.tempRoot ?? os.tmpdir(), 'pi-subagent-');
317
+ const tempDir = await fileSystem.makeTempDir(tempPrefix);
318
+
319
+ const usage = emptyUsage();
320
+ const tools: AgentToolLog[] = [];
321
+ let output = '';
322
+ let stderr = '';
323
+ let model = options.agent.model;
324
+ let stdoutBuffer = '';
325
+
326
+ const progress = (status: AgentProgress['status']): AgentProgress => ({
327
+ agent: options.agent.name,
328
+ status,
329
+ output,
330
+ tools: [...tools],
331
+ usage: { ...usage },
332
+ startedAt,
333
+ elapsedMs: now() - startedAt,
334
+ model,
335
+ });
336
+
337
+ const emit = (status: AgentProgress['status'] = 'running') =>
338
+ options.onProgress?.(progress(status));
339
+
340
+ try {
341
+ const promptFilePath = path.join(tempDir, 'system-prompt.md');
342
+ await fileSystem.writeFile(promptFilePath, options.agent.prompt);
343
+
344
+ let taskFilePath: string | undefined;
345
+ if (options.task.length > TASK_FILE_THRESHOLD) {
346
+ taskFilePath = path.join(tempDir, 'task.md');
347
+ await fileSystem.writeFile(taskFilePath, options.task);
348
+ }
349
+
350
+ const pi = await resolvePi();
351
+ const modelRegistry = ModelRegistry.create(
352
+ AuthStorage.create(options.agentDir ? path.join(options.agentDir, 'auth.json') : undefined),
353
+ options.agentDir ? path.join(options.agentDir, 'models.json') : undefined,
354
+ );
355
+ const args = [
356
+ pi.entryPoint,
357
+ '--mode',
358
+ 'json',
359
+ '-p',
360
+ '--no-skills',
361
+ '--no-prompt-templates',
362
+ '--no-context-files',
363
+ ];
364
+
365
+ if (options.agent.model) args.push('--model', options.agent.model);
366
+ args.push('--thinking', options.agent.thinking);
367
+ if (options.agent.tools.length > 0) args.push('--tools', options.agent.tools.join(','));
368
+ args.push(
369
+ options.agent.systemPromptMode === 'append' ? '--append-system-prompt' : '--system-prompt',
370
+ promptFilePath,
371
+ );
372
+ args.push('--session-dir', subagentSessionDir(options.cwd, options.agentDir));
373
+ args.push(buildTaskArgument(options.task, taskFilePath));
374
+
375
+ const env: NodeJS.ProcessEnv = {
376
+ ...process.env,
377
+ PI_SUBAGENT_DEPTH: String(options.depth ?? 1),
378
+ PI_SUBAGENT_MAX_DEPTH: String(options.agent.maxDepth),
379
+ };
380
+ if (options.agent.tools.includes('subagent') && options.agent.allowedAgents?.length) {
381
+ env.PI_SUBAGENT_ALLOWED = options.agent.allowedAgents.join(',');
382
+ }
383
+
384
+ const processLine = (line: string) => {
385
+ if (!line.trim()) return;
386
+ let event: Record<string, unknown>;
387
+ try {
388
+ event = JSON.parse(line) as Record<string, unknown>;
389
+ } catch {
390
+ return;
391
+ }
392
+
393
+ if (event.type === 'tool_execution_start') {
394
+ tools.push({
395
+ id: String(event.toolCallId ?? tools.length),
396
+ name: String(event.toolName ?? 'tool'),
397
+ args:
398
+ event.args && typeof event.args === 'object'
399
+ ? (event.args as Record<string, unknown>)
400
+ : {},
401
+ status: 'running',
402
+ });
403
+ emit();
404
+ return;
405
+ }
406
+
407
+ if (event.type === 'tool_execution_update') {
408
+ const id = String(event.toolCallId ?? '');
409
+ const tool = tools.find((item) => item.id === id);
410
+ const nested = progressFromPartialResult(event.partialResult);
411
+ if (tool && nested) tool.nested = nested;
412
+ emit();
413
+ return;
414
+ }
415
+
416
+ if (event.type === 'tool_execution_end') {
417
+ const id = String(event.toolCallId ?? '');
418
+ const tool = tools.find((item) => item.id === id);
419
+ if (tool) tool.status = 'done';
420
+ emit();
421
+ return;
422
+ }
423
+
424
+ if (event.type === 'message_end' && event.message) {
425
+ const text = textFromMessage(event.message);
426
+ if (text !== undefined) output = text;
427
+ updateUsage(usage, usageFromMessage(event.message, modelRegistry));
428
+ model = modelFromMessage(event.message) ?? model;
429
+ emit();
430
+ return;
431
+ }
432
+
433
+ if (event.type === 'agent_end') {
434
+ const aggregate = usageFromMessages(event.messages, modelRegistry);
435
+ if (aggregate) replaceUsage(usage, aggregate);
436
+ model = lastAssistantModel(event.messages) ?? model;
437
+ emit();
438
+ }
439
+ };
440
+
441
+ const exit = await runner(
442
+ {
443
+ command: pi.command,
444
+ args,
445
+ cwd: options.cwd,
446
+ env,
447
+ },
448
+ {
449
+ stdout(chunk) {
450
+ stdoutBuffer += chunk;
451
+ const lines = stdoutBuffer.split('\n');
452
+ stdoutBuffer = lines.pop() ?? '';
453
+ for (const line of lines) processLine(line);
454
+ },
455
+ stderr(chunk) {
456
+ stderr += chunk;
457
+ },
458
+ },
459
+ options.signal,
460
+ );
461
+
462
+ if (stdoutBuffer.trim()) processLine(stdoutBuffer);
463
+ const isError = exit.exitCode !== 0;
464
+ if (isError && !output) output = stderr || `Subagent exited with code ${exit.exitCode}`;
465
+
466
+ const truncated = truncateHeadContent(output, OUTPUT_MAX_BYTES, OUTPUT_MAX_LINES);
467
+ if (truncated !== undefined) {
468
+ const originalOutput = output;
469
+ const fullOutputPath = path.join(
470
+ options.outputArchiveDir ?? os.tmpdir(),
471
+ `${safeFilePart(options.agent.name)}-${startedAt}-output.md`,
472
+ );
473
+ await fileSystem.writeFile(fullOutputPath, originalOutput);
474
+ output = `${truncated}\n\n[Output truncated: original ${originalOutput.split('\n').length} lines / ${byteLength(
475
+ originalOutput,
476
+ )} bytes. Full output: ${fullOutputPath}]`;
477
+ }
478
+
479
+ const result: AgentResult = {
480
+ ...progress(isError ? 'error' : 'done'),
481
+ isError,
482
+ exitCode: exit.exitCode,
483
+ stderr,
484
+ };
485
+ emit(isError ? 'error' : 'done');
486
+ return result;
487
+ } catch (error) {
488
+ output = error instanceof Error ? error.message : String(error);
489
+ return {
490
+ ...progress('error'),
491
+ isError: true,
492
+ exitCode: 1,
493
+ stderr,
494
+ };
495
+ } finally {
496
+ await fileSystem.removeDir(tempDir);
497
+ }
498
+ }