pi-subagentura 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/helpers.ts +660 -0
  2. package/package.json +2 -2
  3. package/subagent.ts +60 -11
package/helpers.ts ADDED
@@ -0,0 +1,660 @@
1
+ /**
2
+ * Shared helpers for pi-subagentura
3
+ *
4
+ * Exported so both subagent.ts and test files can import them.
5
+ * Keeps helper logic in one place — single source of truth.
6
+ */
7
+
8
+ import { randomBytes } from "node:crypto";
9
+ import { appendFileSync, mkdirSync, existsSync } from "node:fs";
10
+ import { resolve } from "node:path";
11
+ import { getModel, getProviders } from "@earendil-works/pi-ai";
12
+ import type { Model } from "@earendil-works/pi-ai";
13
+
14
+ // Note: Model<TApi> and AgentToolResult<T> are SDK generics. We use `unknown` as
15
+ // the type argument to avoid strict generic instantiation issues with tsc.
16
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
17
+
18
+ import {
19
+ AuthStorage,
20
+ createAgentSession,
21
+ ModelRegistry,
22
+ SessionManager,
23
+ type AgentSession,
24
+ } from "@earendil-works/pi-coding-agent";
25
+
26
+ // ── Debug Logging ─────────────────────────────────────────────────
27
+
28
+ const DEBUG_LOG_DIR = process.env.SUBAGENT_DEBUG_LOG_DIR
29
+ ? resolve(process.env.SUBAGENT_DEBUG_LOG_DIR)
30
+ : undefined;
31
+
32
+ export function debugLog(level: string, event: string, data: Record<string, unknown> = {}) {
33
+ if (!DEBUG_LOG_DIR) return;
34
+ try {
35
+ if (!existsSync(DEBUG_LOG_DIR)) {
36
+ mkdirSync(DEBUG_LOG_DIR, { recursive: true });
37
+ }
38
+ const entry = JSON.stringify({
39
+ timestamp: new Date().toISOString(),
40
+ level,
41
+ event,
42
+ ...data,
43
+ }) + "\n";
44
+ const fileName = resolve(DEBUG_LOG_DIR, `debug-${new Date().toISOString().slice(0, 10)}.jsonl`);
45
+ appendFileSync(fileName, entry);
46
+ } catch {
47
+ // Silently fail to avoid polluting output
48
+ }
49
+ }
50
+
51
+ export function extractTextFromContent(content: unknown): string {
52
+ if (Array.isArray(content)) {
53
+ return content
54
+ .filter((c): c is { type: "text"; text: string } =>
55
+ typeof c === "object" && c !== null && c.type === "text" && typeof c.text === "string"
56
+ )
57
+ .map((c) => c.text)
58
+ .join("\n");
59
+ }
60
+ if (typeof content === "string") {
61
+ return content;
62
+ }
63
+ debugLog("warn", "unexpected_content_type", {
64
+ contentType: typeof content,
65
+ content: String(String(content).slice(0, 200)),
66
+ });
67
+ return "";
68
+ }
69
+
70
+ // ── Constants ────────────────────────────────────────────────────────
71
+
72
+ /**
73
+ * Milliseconds to wait before showing activeTool in the live status preview.
74
+ * Prevents UI flicker for fast tool executions that start and end within this window.
75
+ *
76
+ * Note: If Pi adds new model providers, update KNOWN_PROVIDERS below.
77
+ */
78
+ export const ACTIVE_TOOL_DEBOUNCE_MS = 150;
79
+
80
+ // Note: If Pi adds new providers, getProviders() from @earendil-works/pi-ai will
81
+ // return them automatically. We no longer maintain a hardcoded list.
82
+
83
+ // ── Types ───────────────────────────────────────────────────────────
84
+
85
+ export interface SubagentResult {
86
+ output: string;
87
+ usage: {
88
+ input: number;
89
+ output: number;
90
+ cacheRead: number;
91
+ cacheWrite: number;
92
+ cost: number;
93
+ turns: number;
94
+ };
95
+ model?: string;
96
+ isError: boolean;
97
+ errorMessage?: string;
98
+ }
99
+
100
+ export interface SubagentLiveStatus {
101
+ turn: number;
102
+ activeTool?: { name: string; args: Record<string, unknown> };
103
+ output: string;
104
+ usage: SubagentResult["usage"];
105
+ }
106
+
107
+ // ── Async Job Types ─────────────────────────────────────────────────
108
+
109
+ export type JobStatus = "running" | "done" | "error" | "cancelled";
110
+
111
+ /** Notification delivery mode for async subagent completion */
112
+ export type NotifyOnComplete = "notify" | "inject";
113
+
114
+ export interface JobState {
115
+ id: string;
116
+ status: JobStatus;
117
+ liveStatus: SubagentLiveStatus;
118
+ result?: SubagentResult;
119
+ session: AgentSession;
120
+ startedAt: number;
121
+ promise: Promise<SubagentResult>;
122
+ modelLabel?: string;
123
+ /** Notification mode requested by spawner's notifyOnComplete param */
124
+ notifyOnComplete?: NotifyOnComplete;
125
+ /** At-most-once delivery guard */
126
+ notificationDelivered?: boolean;
127
+ /** Set true by get_subagent_result to suppress redundant notification */
128
+ resultRetrieved?: boolean;
129
+ /** Optional TTL in ms for completed job retention */
130
+ maxAge?: number;
131
+ }
132
+
133
+ // ── Job Registry ────────────────────────────────────────────────────
134
+
135
+ /**
136
+ * Persisted job registry using global to survive module reloads (jiti).
137
+ *
138
+ * Lifecycle:
139
+ * - Jobs added on async subagent spawn
140
+ * - Completed/error jobs persist indefinitely (no TTL)
141
+ * - Cancelled jobs removed immediately
142
+ * - All jobs lost on Pi restart (in-memory only)
143
+ */
144
+
145
+ // Use 'global' for Node.js global, fall back to globalThis
146
+ const g = typeof global !== "undefined" ? global : globalThis;
147
+
148
+ // Create or reuse the registry on the global object
149
+ if (!g.__piSubagenturaRegistry) {
150
+ g.__piSubagenturaRegistry = new Map<string, JobState>();
151
+ }
152
+
153
+ export const jobRegistry = g.__piSubagenturaRegistry as Map<string, JobState>;
154
+
155
+ // Declare global piref for notification delivery (set by extension factory, read by delivery code)
156
+ declare global {
157
+ var __piSubagenturaPiRef: unknown; // ExtensionAPI ref — set in subagent.ts factory
158
+ }
159
+
160
+ // Initialize the global pi ref
161
+ if (!g.__piSubagenturaPiRef) {
162
+ g.__piSubagenturaPiRef = undefined;
163
+ }
164
+
165
+ /** Jobs persist indefinitely — no automatic expiration */
166
+ export const JOB_CLEANUP_TTL_MS = 0;
167
+
168
+ /** Maximum number of jobs to retain in the registry */
169
+ export const MAX_REGISTRY_SIZE = 100;
170
+
171
+ /** Remove the oldest completed or error job from the registry */
172
+ export function pruneOldestJob(): boolean {
173
+ for (const [jobId, job] of jobRegistry) {
174
+ if (job.status === "done" || job.status === "error") {
175
+ jobRegistry.delete(jobId);
176
+ return true;
177
+ }
178
+ }
179
+ return false;
180
+ }
181
+
182
+ /** Remove all completed and error jobs from the registry. Returns count removed. */
183
+ export function pruneCompletedJobs(): number {
184
+ let removed = 0;
185
+ for (const [jobId, job] of jobRegistry) {
186
+ if (job.status === "done" || job.status === "error") {
187
+ jobRegistry.delete(jobId);
188
+ removed++;
189
+ }
190
+ }
191
+ return removed;
192
+ }
193
+
194
+ export function scheduleJobCleanup(
195
+ jobId: string,
196
+ immediate = false,
197
+ maxAge?: number,
198
+ ): void {
199
+ if (!immediate) {
200
+ if (maxAge && maxAge > 0) {
201
+ setTimeout(() => {
202
+ jobRegistry.delete(jobId);
203
+ }, maxAge);
204
+ }
205
+ return; // persist indefinitely unless maxAge specified
206
+ }
207
+ setTimeout(() => {
208
+ jobRegistry.delete(jobId);
209
+ }, 0);
210
+ }
211
+
212
+ /** Generate a unique job ID (16 hex chars from crypto.randomBytes) */
213
+ export function generateJobId(): string {
214
+ // Uses randomBytes for Node 18 compatibility (randomUUID needs Node 19+)
215
+ return randomBytes(8).toString("hex");
216
+ }
217
+
218
+ // ── Helpers ─────────────────────────────────────────────────────────
219
+
220
+ /**
221
+ * Resolve a model from a string identifier and an optional default.
222
+ *
223
+ * The caller (LLM agent) is responsible for providing the correct model id.
224
+ * This function does NOT guess — it only does exact lookups:
225
+ * 1. undefined → defaultModel
226
+ * 2. Use parent modelRegistry (has extension-added models like minimax)
227
+ * 3. "provider/id" format → exact getModel lookup (global static registry)
228
+ * 4. Bare id → exact getModel scan across all providers (global static registry)
229
+ * 5. Falls back to defaultModel when nothing matches
230
+ */
231
+ export function resolveModel(
232
+ modelId: string | undefined,
233
+ // @ts-expect-error — Model<TApi> requires type arg; unknown is a safe placeholder
234
+ defaultModel: Model | undefined,
235
+ parentModelRegistry?: ModelRegistry,
236
+ ) {
237
+ if (!modelId) return defaultModel;
238
+
239
+ // Only exact matching — no fuzzy/substring guessing.
240
+ // The AI should call list_available_models and pick from the list.
241
+ if (parentModelRegistry) {
242
+ if (modelId.includes("/")) {
243
+ const [provider, id] = modelId.split("/", 2);
244
+ const exact = parentModelRegistry.find(provider, id);
245
+ if (exact) return exact as any;
246
+ } else {
247
+ // Bare id — search all models in parent registry
248
+ for (const m of parentModelRegistry.getAll()) {
249
+ if (m.id === modelId) return m as any;
250
+ }
251
+ }
252
+ }
253
+
254
+ // Fall back to global static registry (built-in models only)
255
+ if (modelId.includes("/")) {
256
+ const [provider, id] = modelId.split("/", 2);
257
+ // @ts-expect-error — getModel requires KnownProvider union; we trust the caller
258
+ return getModel(provider, id) ?? defaultModel;
259
+ }
260
+
261
+ // Bare id — exact match across all providers
262
+ for (const provider of getProviders()) {
263
+ // @ts-expect-error — KnownProvider cast needed; string is assignable to it at runtime
264
+ const found = getModel(provider, modelId);
265
+ if (found) return found;
266
+ }
267
+
268
+ return defaultModel;
269
+ }
270
+ export function formatTokens(count: number): string {
271
+ if (count < 1000) return count.toString();
272
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
273
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
274
+ return `${(count / 1000000).toFixed(1)}M`;
275
+ }
276
+
277
+ export function formatUsage(
278
+ u: SubagentResult["usage"],
279
+ model?: string,
280
+ ): string {
281
+ const parts: string[] = [];
282
+ if (u.turns) parts.push(`${u.turns} turn${u.turns > 1 ? "s" : ""}`);
283
+ if (u.input) parts.push(`↑${formatTokens(u.input)}`);
284
+ if (u.output) parts.push(`↓${formatTokens(u.output)}`);
285
+ if (u.cacheRead) parts.push(`R${formatTokens(u.cacheRead)}`);
286
+ if (u.cacheWrite) parts.push(`W${formatTokens(u.cacheWrite)}`);
287
+ if (u.cost) parts.push(`$${u.cost.toFixed(4)}`);
288
+ if (model) parts.push(model);
289
+ return parts.join(" ");
290
+ }
291
+
292
+ export function buildLiveUpdate(
293
+ status: SubagentLiveStatus,
294
+ model?: string,
295
+ // @ts-expect-error — AgentToolResult<T> requires type arg; unknown is a safe placeholder
296
+ ): AgentToolResult {
297
+ return {
298
+ content: [{ type: "text", text: status.output }],
299
+ details: {
300
+ status: "running",
301
+ subagentStatus: status,
302
+ model,
303
+ },
304
+ };
305
+ }
306
+
307
+ // ── startSubagentJob ────────────────────────────────────────────────
308
+
309
+ export interface StartSubagentJobParams {
310
+ task: string;
311
+ persona: string | undefined;
312
+ modelOverride: string | undefined;
313
+ cwd: string;
314
+ contextText: string | null;
315
+ signal: AbortSignal | undefined;
316
+ // @ts-expect-error — AgentToolResult<T> requires type arg
317
+ onUpdate: ((partial: AgentToolResult) => void) | undefined;
318
+ // @ts-expect-error — Model<TApi> requires type arg
319
+ defaultModel: Model | undefined;
320
+ maxAge?: number;
321
+ /** Parent session's model registry for resolving extension-added models (e.g. minimax) */
322
+ parentModelRegistry?: ModelRegistry;
323
+ }
324
+
325
+ export interface StartSubagentJobResult {
326
+ jobId: string;
327
+ jobPromise: Promise<SubagentResult>;
328
+ session: AgentSession;
329
+ liveStatus: SubagentLiveStatus;
330
+ modelLabel?: string;
331
+ /** Warning when modelOverride was specified but not found — lists available models */
332
+ modelWarning?: string;
333
+ }
334
+
335
+ /**
336
+ * Create a subagent session and start its prompt execution.
337
+ *
338
+ * Returns immediately with { jobId, jobPromise, session, liveStatus }.
339
+ * The jobPromise resolves to a SubagentResult when the subagent completes.
340
+ * The liveStatus object is mutated in real-time by the event subscriber.
341
+ *
342
+ * This is the shared core used by both sync (runSubagent) and async paths.
343
+ */
344
+ export async function startSubagentJob(
345
+ params: StartSubagentJobParams,
346
+ ): Promise<StartSubagentJobResult> {
347
+ const {
348
+ task,
349
+ persona,
350
+ modelOverride,
351
+ cwd,
352
+ contextText,
353
+ signal,
354
+ onUpdate,
355
+ defaultModel,
356
+ parentModelRegistry,
357
+ } = params;
358
+
359
+ // Enforce registry size cap before adding a new job
360
+ while (jobRegistry.size >= MAX_REGISTRY_SIZE) {
361
+ if (!pruneOldestJob()) break; // no old jobs to evict, allow slight overcap
362
+ }
363
+
364
+ const jobId = generateJobId();
365
+ const authStorage = AuthStorage.create();
366
+ const modelRegistry = ModelRegistry.create(authStorage);
367
+
368
+ // Resolve model: exact match only, fallback to default
369
+ // Uses parent's modelRegistry to find extension-added models (e.g. minimax)
370
+ const targetModel = resolveModel(modelOverride, defaultModel, parentModelRegistry);
371
+ const modelLabel = targetModel
372
+ ? `${targetModel.provider}/${targetModel.id}`
373
+ : undefined;
374
+
375
+ // Build model warning when override was specified (helps AI discover valid models)
376
+ let modelWarning: string | undefined;
377
+ if (modelOverride && parentModelRegistry) {
378
+ const available = parentModelRegistry.getAvailable();
379
+ const modelList = available
380
+ .map((m) => ` ${m.provider}/${m.id}${m.name ? ` (${m.name})` : ""}`)
381
+ .join("\n");
382
+ modelWarning =
383
+ `Requested model "${modelOverride}" resolved to ${modelLabel ?? "none"}. ` +
384
+ `Available models:\n${modelList || " (none)"}\n` +
385
+ `Use list_available_models to discover more.`;
386
+ }
387
+
388
+ let handleAbort: (() => void) | undefined;
389
+ let unsubscribe: (() => void) | undefined;
390
+
391
+ const liveStatus: SubagentLiveStatus = {
392
+ turn: 0,
393
+ output: "",
394
+ usage: {
395
+ input: 0,
396
+ output: 0,
397
+ cacheRead: 0,
398
+ cacheWrite: 0,
399
+ cost: 0,
400
+ turns: 0,
401
+ },
402
+ };
403
+
404
+ // Debounce activeTool updates to prevent flickering on fast tool calls.
405
+ // When onUpdate is undefined (async path), skip the debounce entirely —
406
+ // no rendering to flicker, and the timer overhead is wasted.
407
+ let activeToolTimer: ReturnType<typeof setTimeout> | undefined;
408
+ let pendingActiveTool: SubagentLiveStatus["activeTool"] = undefined;
409
+
410
+ function setActiveToolDebounced(tool: SubagentLiveStatus["activeTool"]) {
411
+ pendingActiveTool = tool;
412
+ if (activeToolTimer) {
413
+ clearTimeout(activeToolTimer);
414
+ activeToolTimer = undefined;
415
+ }
416
+ if (tool) {
417
+ if (!onUpdate) {
418
+ // Async path: no rendering, apply immediately
419
+ liveStatus.activeTool = tool;
420
+ return;
421
+ }
422
+ activeToolTimer = setTimeout(() => {
423
+ activeToolTimer = undefined;
424
+ liveStatus.activeTool = pendingActiveTool;
425
+ onUpdate?.(buildLiveUpdate(liveStatus, modelLabel));
426
+ }, ACTIVE_TOOL_DEBOUNCE_MS);
427
+ } else {
428
+ if (liveStatus.activeTool) {
429
+ liveStatus.activeTool = undefined;
430
+ onUpdate?.(buildLiveUpdate(liveStatus, modelLabel));
431
+ }
432
+ }
433
+ }
434
+
435
+ // Create session
436
+ debugLog("info", "session_creating", {
437
+ jobId,
438
+ model: modelLabel ?? "default",
439
+ cwd,
440
+ });
441
+ const session = (
442
+ await createAgentSession({
443
+ sessionManager: SessionManager.inMemory(),
444
+ authStorage,
445
+ modelRegistry,
446
+ model: targetModel,
447
+ cwd,
448
+ })
449
+ ).session;
450
+ debugLog("info", "session_created", {
451
+ jobId,
452
+ sessionModel: session.model ? `${session.model.provider}/${session.model.id}` : null,
453
+ });
454
+
455
+ // Wire abort signal
456
+ if (signal) {
457
+ handleAbort = () => {
458
+ debugLog("warn", "job_abort", { jobId });
459
+ session.abort().catch(() => {});
460
+ };
461
+ if (signal.aborted) {
462
+ handleAbort();
463
+ } else {
464
+ signal.addEventListener("abort", handleAbort);
465
+ }
466
+ }
467
+
468
+ // Wire session events
469
+ unsubscribe = session.subscribe((event) => {
470
+ switch (event.type) {
471
+ case "turn_start": {
472
+ liveStatus.turn++;
473
+ liveStatus.usage.turns = liveStatus.turn;
474
+ liveStatus.output = "";
475
+ debugLog("info", "turn_start", { jobId, turn: liveStatus.turn });
476
+ onUpdate?.(buildLiveUpdate(liveStatus, modelLabel));
477
+ break;
478
+ }
479
+ case "tool_execution_start": {
480
+ debugLog("info", "tool_start", {
481
+ jobId,
482
+ toolName: event.toolName,
483
+ args: event.args as Record<string, unknown>,
484
+ });
485
+ setActiveToolDebounced({
486
+ name: event.toolName,
487
+ args: event.args as Record<string, unknown>,
488
+ });
489
+ break;
490
+ }
491
+ case "tool_execution_end": {
492
+ debugLog("info", "tool_end", { jobId, toolName: liveStatus.activeTool?.name });
493
+ setActiveToolDebounced(undefined);
494
+ break;
495
+ }
496
+ case "turn_end": {
497
+ debugLog("info", "turn_end", {
498
+ jobId,
499
+ turn: liveStatus.turn,
500
+ outputLength: liveStatus.output.length,
501
+ activeTool: liveStatus.activeTool?.name ?? null,
502
+ });
503
+ if (activeToolTimer) {
504
+ clearTimeout(activeToolTimer);
505
+ activeToolTimer = undefined;
506
+ }
507
+ liveStatus.activeTool = undefined;
508
+ onUpdate?.(buildLiveUpdate(liveStatus, modelLabel));
509
+ break;
510
+ }
511
+ case "message_update": {
512
+ const evt = event.assistantMessageEvent;
513
+ debugLog("info", "message_update", {
514
+ jobId,
515
+ updateType: evt.type,
516
+ ...(evt.type === "text_delta" && {
517
+ delta: evt.delta.slice(0, 200),
518
+ outputLength: liveStatus.output.length,
519
+ }),
520
+ ...(evt.type === "thinking_delta" && { delta: evt.delta.slice(0, 200) }),
521
+ ...(evt.type === "toolcall_delta" && { partial: String(evt.partial).slice(0, 200) }),
522
+ ...(evt.type === "toolcall_end" && { toolCallId: evt.toolCall?.id }),
523
+ });
524
+ if (evt.type === "text_delta") {
525
+ liveStatus.output += evt.delta;
526
+ onUpdate?.(buildLiveUpdate(liveStatus, modelLabel));
527
+ }
528
+ break;
529
+ }
530
+ }
531
+ });
532
+
533
+ // Build prompt text
534
+ const personaPrefix = persona ? `${persona}\n\n` : "";
535
+ const finalPrompt = contextText
536
+ ? `${personaPrefix}You are a SEPARATE background sub-agent. Your ONLY job is the task below.\nThe conversation history above is CONTEXT ONLY — do NOT comment on it, do NOT role-play as the main assistant, do NOT describe the spawning process. Execute ONLY the task and return ONLY the result.\n\n## Conversation History (context only — do not respond to this)\n${contextText}\n\n## Your Task (respond ONLY to this)\n${task}`
537
+ : `${personaPrefix}Task: ${task}`;
538
+
539
+ debugLog("info", "prompt_built", {
540
+ jobId,
541
+ hasContext: !!contextText,
542
+ contextLength: contextText?.length ?? 0,
543
+ taskLength: task.length,
544
+ persona: persona ?? null,
545
+ promptPreview: finalPrompt.slice(0, 500),
546
+ });
547
+
548
+ // Launch the prompt in a promise chain (NOT awaited — returns immediately).
549
+ // The jobPromise represents the full lifecycle: prompt → extraction → cleanup.
550
+ const jobPromise = (async (): Promise<SubagentResult> => {
551
+ let result: SubagentResult;
552
+ try {
553
+ debugLog("info", "prompt_start", { jobId });
554
+ await session.prompt(finalPrompt);
555
+ debugLog("info", "prompt_complete", { jobId });
556
+
557
+ // Extract final assistant output
558
+ const messages = session.agent.state.messages;
559
+ debugLog("info", "messages_extracted", {
560
+ jobId,
561
+ messageCount: messages.length,
562
+ messageRoles: messages.map((m) => m.role),
563
+ lastMessageContentType: typeof (messages[messages.length - 1] as any)?.content,
564
+ lastMessageContentIsArray: Array.isArray((messages[messages.length - 1] as any)?.content),
565
+ });
566
+
567
+ let finalOutput = liveStatus.output;
568
+ for (let i = messages.length - 1; i >= 0; i--) {
569
+ const msg = messages[i];
570
+ debugLog("info", "message_check", {
571
+ jobId,
572
+ index: i,
573
+ role: msg.role,
574
+ contentType: typeof (msg as any).content,
575
+ contentIsArray: Array.isArray((msg as any).content),
576
+ });
577
+ if (msg.role === "assistant") {
578
+ const textParts = extractTextFromContent(msg.content);
579
+ if (textParts) {
580
+ finalOutput = textParts;
581
+ break;
582
+ }
583
+ }
584
+ }
585
+
586
+ const usage = {
587
+ input: 0,
588
+ output: 0,
589
+ cacheRead: 0,
590
+ cacheWrite: 0,
591
+ cost: 0,
592
+ turns: 0,
593
+ };
594
+ for (const msg of messages) {
595
+ if (msg.role === "assistant" && msg.usage) {
596
+ usage.turns++;
597
+ usage.input += msg.usage.input;
598
+ usage.output += msg.usage.output;
599
+ usage.cacheRead += msg.usage.cacheRead;
600
+ usage.cacheWrite += msg.usage.cacheWrite;
601
+ usage.cost += msg.usage.cost.total;
602
+ }
603
+ }
604
+
605
+ result = {
606
+ output: finalOutput || "(no output)",
607
+ usage,
608
+ model: session.model
609
+ ? `${session.model.provider}/${session.model.id}`
610
+ : undefined,
611
+ isError: !!session.agent.state.errorMessage,
612
+ errorMessage: session.agent.state.errorMessage,
613
+ };
614
+ } catch (err) {
615
+ const msg = err instanceof Error ? err.message : String(err);
616
+ const stack = err instanceof Error ? err.stack : undefined;
617
+ debugLog("error", "subagent_error", {
618
+ jobId,
619
+ error: msg,
620
+ stack: stack ?? null,
621
+ errorName: err instanceof Error ? err.name : typeof err,
622
+ });
623
+ result = {
624
+ output: `Sub-agent crashed: ${msg}`,
625
+ usage: {
626
+ input: 0,
627
+ output: 0,
628
+ cacheRead: 0,
629
+ cacheWrite: 0,
630
+ cost: 0,
631
+ turns: 0,
632
+ },
633
+ model: undefined,
634
+ isError: true,
635
+ errorMessage: msg,
636
+ };
637
+ } finally {
638
+ debugLog("info", "job_complete", {
639
+ jobId,
640
+ outputLength: result.output.length,
641
+ output: result.output.slice(0, 200),
642
+ isError: result.isError,
643
+ errorMessage: result.errorMessage ?? null,
644
+ usage: result.usage,
645
+ });
646
+ if (activeToolTimer) {
647
+ clearTimeout(activeToolTimer);
648
+ activeToolTimer = undefined;
649
+ }
650
+ if (signal && handleAbort)
651
+ signal.removeEventListener("abort", handleAbort);
652
+ if (unsubscribe) unsubscribe();
653
+ session?.dispose();
654
+ debugLog("info", "session_disposed", { jobId });
655
+ }
656
+ return result;
657
+ })();
658
+
659
+ return { jobId, jobPromise, session, liveStatus, modelLabel, modelWarning };
660
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagentura",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "Public Pi package that adds in-process sub-agents via the SDK",
5
5
  "main": "subagent.ts",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "interactive-tmux.ts",
28
28
  "artifact.ts",
29
29
  "subagent-artifact-cli.ts",
30
- "README.md",
30
+ "helpers.ts",
31
31
  "LICENSE"
32
32
  ],
33
33
  "engines": {
package/subagent.ts CHANGED
@@ -546,6 +546,10 @@ export function pollArtifactChanges(pi: ExtensionAPI): void {
546
546
 
547
547
  /** Tail-read the child's session JSONL and append `tool_activity` events to events.ndjson.
548
548
  * Updates `state.lastDeliveredSessionByte` so subsequent ticks re-read only new lines. */
549
+ /** Hard cap on the per-tick read window. Session JSONL files can grow quickly
550
+ * in a long-running sub-agent, so we never allocate more than this in a single
551
+ * tailRead call. 1 MiB is plenty for many thousands of typical entries. */
552
+ const MAX_SESSION_READ_BYTES = 1 * 1024 * 1024;
549
553
  function tailReadSessionLog(state: InteractiveSubagentState, art: SubagentArtifact): void {
550
554
  const sessionFile = state.sessionFile;
551
555
  if (!sessionFile) return;
@@ -560,6 +564,11 @@ function tailReadSessionLog(state: InteractiveSubagentState, art: SubagentArtifa
560
564
  const cursor = state.lastDeliveredSessionByte ?? 0;
561
565
  if (size <= cursor) return;
562
566
 
567
+ // Cap the per-tick read so a runaway file can't trigger an unbounded Buffer.alloc.
568
+ const requested = size - cursor;
569
+ const toRead = Math.min(requested, MAX_SESSION_READ_BYTES);
570
+ if (toRead <= 0) return;
571
+
563
572
  let fd: number;
564
573
  try {
565
574
  fd = openSync(sessionFile, "r");
@@ -567,17 +576,25 @@ function tailReadSessionLog(state: InteractiveSubagentState, art: SubagentArtifa
567
576
  return;
568
577
  }
569
578
  try {
570
- const len = size - cursor;
571
- const buf = Buffer.alloc(len);
579
+ const buf = Buffer.alloc(toRead);
572
580
  let bytesRead = 0;
573
- while (bytesRead < len) {
574
- const n = readSync(fd, buf, bytesRead, len - bytesRead, cursor + bytesRead);
581
+ while (bytesRead < toRead) {
582
+ const n = readSync(fd, buf, bytesRead, toRead - bytesRead, cursor + bytesRead);
575
583
  if (n <= 0) break;
576
584
  bytesRead += n;
577
585
  }
578
586
  const chunk = buf.subarray(0, bytesRead).toString("utf8");
579
587
  processSessionLogChunk(state, art, chunk);
580
- state.lastDeliveredSessionByte = cursor + bytesRead;
588
+ // Only advance the cursor to the end of the LAST complete line in the chunk.
589
+ // If the chunk ends mid-line (partial trailing JSONL), the partial must be
590
+ // re-read on the next tick after the child finishes writing it. Advancing the
591
+ // cursor past the partial would silently drop bytes and corrupt the event log.
592
+ const endOfComplete = chunk.lastIndexOf("\n");
593
+ if (endOfComplete >= 0) {
594
+ state.lastDeliveredSessionByte = cursor + endOfComplete + 1;
595
+ }
596
+ // If no newline in chunk and we hit the cap, leave the cursor where it was:
597
+ // the child is still mid-line; we'll re-read from the same offset next tick.
581
598
  } finally {
582
599
  try { require("node:fs").closeSync(fd); } catch {}
583
600
  }
@@ -734,7 +751,15 @@ function labelFor(event: SubagentEvent): string {
734
751
  * default artifacts root (PI_CODING_AGENT_SESSION_DIR or ~/.pi/agent/sessions/subagentura).
735
752
  * For v1 this is a best-effort lookup; a future iteration can track all artifact roots.
736
753
  */
737
- function findArtifactById(id: string): SubagentArtifact | null {
754
+ export function findArtifactById(id: string): SubagentArtifact | null {
755
+ // Sub-agent ids are randomBytes(4).toString("hex") at spawn time, i.e. 8 hex
756
+ // chars. Validate the id before joining it into a path so that an
757
+ // LLM-supplied id like "../../../etc" can't escape the artifact root
758
+ // (path.join normalises "..", so a malicious id would otherwise resolve
759
+ // to a sibling directory and get exfiltrated to the parent LLM via
760
+ // read_subagent_artifact).
761
+ if (!/^[a-f0-9]{8}$/.test(id)) return null;
762
+
738
763
  const root = process.env.PI_CODING_AGENT_SESSION_DIR ?? join(homedir(), ".pi", "agent", "sessions");
739
764
  let topLevel: string[];
740
765
  try {
@@ -754,6 +779,7 @@ function findArtifactById(id: string): SubagentArtifact | null {
754
779
  }
755
780
  return null;
756
781
  }
782
+
757
783
  /** Sanitize a string by redacting common sensitive patterns (API keys, tokens, JWTs). */
758
784
  function sanitizeOutput(text: string): string {
759
785
  return text.replace(
@@ -830,10 +856,11 @@ export default function (pi: ExtensionAPI) {
830
856
  // every running interactive sub-agent and fires pointer notifications for new events.
831
857
  // The poller survives parent restarts (artifacts on disk + per-state lastDeliveredEventTs).
832
858
  if (!g2.__piSubagenturaInteractivePollerHandle) {
833
- g2.__piSubagenturaInteractivePollerHandle = setInterval(
834
- () => pollArtifactChanges(pi),
835
- 5000,
836
- );
859
+ const handle = setInterval(() => pollArtifactChanges(pi), 5000);
860
+ // Don't pin the event loop on a long-lived parent. unref() lets the process exit
861
+ // cleanly when nothing else is keeping it alive (no other ref'd handles).
862
+ handle.unref?.();
863
+ g2.__piSubagenturaInteractivePollerHandle = handle;
837
864
  }
838
865
  // ── Tool 1: inherits conversation history ────────────────────────
839
866
  pi.registerTool({
@@ -1987,10 +2014,32 @@ export default function (pi: ExtensionAPI) {
1987
2014
  },
1988
2015
  });
1989
2016
 
1990
- // ── Session shutdown: abort all jobs and clear registry ──────────
2017
+ // ── Session shutdown: abort all jobs, kill tmux panes, stop the poller ─
1991
2018
  (pi as any).on?.("session_shutdown", () => {
1992
2019
  const g2 = typeof global !== "undefined" ? global : globalThis;
1993
2020
 
2021
+ // Stop the global poller so it doesn't fire after we're gone. Without
2022
+ // clearInterval the handle would keep the event loop alive across restarts.
2023
+ if (g2.__piSubagenturaInteractivePollerHandle) {
2024
+ try {
2025
+ clearInterval(g2.__piSubagenturaInteractivePollerHandle);
2026
+ } catch { /* defensive */ }
2027
+ g2.__piSubagenturaInteractivePollerHandle = undefined;
2028
+ }
2029
+
2030
+ // Kill any tmux panes backing live interactive sub-agents. We can't leave them
2031
+ // running — the parent process is shutting down. cancelInteractiveSubagent
2032
+ // does the right thing (writes .cancelled, kills pane, lets trap record the event).
2033
+ try {
2034
+ for (const state of interactiveSubagentRegistry.values()) {
2035
+ if (state.status === "running") {
2036
+ try {
2037
+ cancelInteractiveSubagent(state.id);
2038
+ } catch { /* best effort */ }
2039
+ }
2040
+ }
2041
+ } catch { /* best effort */ }
2042
+
1994
2043
  // Abort all running subagent sessions before clearing
1995
2044
  for (const job of jobRegistry.values()) {
1996
2045
  if (job.status === "running") {