pi-subagentura 2.0.0 → 2.0.2

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.
package/artifact.ts CHANGED
@@ -18,6 +18,7 @@
18
18
 
19
19
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
20
20
  import { join } from "node:path";
21
+ import ndjson from "ndjson";
21
22
 
22
23
  // ── Types ───────────────────────────────────────────────────────────
23
24
 
@@ -87,6 +88,11 @@ export function writeOutput(art: SubagentArtifact, content: string): void {
87
88
  * ts >= since are returned. Malformed lines are silently skipped (the
88
89
  * sub-agent CLI is the only writer, but a partial write could in theory
89
90
  * leave a truncated line).
91
+ *
92
+ * Uses the `ndjson` library with `strict: false` so a single bad line does not abort the whole
93
+ * file — ndjson drops the bad row and continues with the rest. Any trailing partial line (file
94
+ * did not end with a newline) is buffered by the parser and dropped on `end()`; it is treated as a
95
+ * in-progress write that the next reader will pick up once completed.
90
96
  */
91
97
  export function readEvents(art: SubagentArtifact, since?: number): SubagentEvent[] {
92
98
  if (!existsSync(art.statusFile)) return [];
@@ -96,16 +102,16 @@ export function readEvents(art: SubagentArtifact, since?: number): SubagentEvent
96
102
  } catch {
97
103
  return [];
98
104
  }
105
+ const parser = ndjson.parse({ strict: false });
99
106
  const events: SubagentEvent[] = [];
100
- for (const line of content.split("\n")) {
101
- if (!line.trim()) continue;
102
- try {
103
- const ev = JSON.parse(line) as SubagentEvent;
104
- if (since === undefined || ev.ts >= since) events.push(ev);
105
- } catch {
106
- // Skip malformed lines (partial write, manual edit, etc.)
107
- }
108
- }
107
+ parser.on("data", (obj: unknown) => {
108
+ const ev = obj as SubagentEvent;
109
+ if (since === undefined || ev.ts >= since) events.push(ev);
110
+ });
111
+ // Non-strict mode never emits 'error' for bad JSON; attach a no-op so an unhandled error event
112
+ // can never crash the parent process.
113
+ parser.on("error", () => {});
114
+ parser.end(Buffer.from(content, "utf8"));
109
115
  return events;
110
116
  }
111
117
 
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,7 +1,9 @@
1
1
  {
2
2
  "name": "pi-subagentura",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "Public Pi package that adds in-process sub-agents via the SDK",
5
+ "author": "lmn451",
6
+ "license": "MIT",
5
7
  "main": "subagent.ts",
6
8
  "type": "module",
7
9
  "keywords": [
@@ -13,7 +15,6 @@
13
15
  "swarm",
14
16
  "crew"
15
17
  ],
16
- "license": "MIT",
17
18
  "repository": {
18
19
  "type": "git",
19
20
  "url": "https://github.com/lmn451/pi-subagentura"
@@ -27,7 +28,7 @@
27
28
  "interactive-tmux.ts",
28
29
  "artifact.ts",
29
30
  "subagent-artifact-cli.ts",
30
- "README.md",
31
+ "helpers.ts",
31
32
  "LICENSE"
32
33
  ],
33
34
  "engines": {
@@ -58,5 +59,9 @@
58
59
  "prettier": "^3.8.3",
59
60
  "typescript": "^6.0.3",
60
61
  "vitest": "^3.0.0"
62
+ },
63
+ "dependencies": {
64
+ "is-path-inside": "^4.0.0",
65
+ "ndjson": "^2.0.0"
61
66
  }
62
67
  }
package/subagent.ts CHANGED
@@ -59,12 +59,12 @@ import {
59
59
  } from "./interactive-tmux";
60
60
  import { appendEvent, artifactPath, lastEvent, readEvents, readOutput, type SubagentArtifact, type SubagentEvent } from "./artifact";
61
61
 
62
- import { openSync, readdirSync, readSync, statSync } from "node:fs";
62
+ import { closeSync, openSync, readdirSync, readSync, realpathSync, statSync } from "node:fs";
63
63
  import { homedir } from "node:os";
64
64
  import { basename, dirname, join } from "node:path";
65
65
  import { Text, truncateToWidth } from "@earendil-works/pi-tui";
66
66
  import { Type } from "typebox";
67
-
67
+ import ndjson from "ndjson";
68
68
  // ── Footer Status Key ─────────────────────────────────────────────────────────────────────
69
69
  const FOOTER_KEY = "subagentura-running";
70
70
  const WIDGET_KEY = "subagentura-activity";
@@ -544,9 +544,69 @@ export function pollArtifactChanges(pi: ExtensionAPI): void {
544
544
  }
545
545
  }
546
546
 
547
+ /**
548
+ * Per-state ndjson parser instance used to tail-read the child's session JSONL.
549
+ *
550
+ * The parser buffers partial trailing lines internally (via split2 underneath), so we can
551
+ * safely write raw bytes from the file on every poll and let the parser emit complete JSON
552
+ * objects as 'data' events. This replaces a hand-rolled partial-line + cursor scheme that had
553
+ * three latent bugs:
554
+ * - A 1 MiB per-tick read cap combined with cursor-pinning on a missing newline caused a
555
+ * permanent re-read loop on any single JSONL line larger than 1 MiB (e.g. a multi-MB tool
556
+ * call result that the child pi runtime writes as a single line).
557
+ * - File truncation left the cursor pointing past EOF, silently dropping any post-truncation
558
+ * content.
559
+ * - A `require("node:fs").closeSync(fd)` call in the finally block leaked file descriptors on
560
+ * Node < 22.12 in some bundling paths.
561
+ *
562
+ * Keyed by sub-agent id; one parser per state lives for the lifetime of the process. The parser
563
+ * is destroyed and recreated on file truncation so the buffered partial state is cleared.
564
+ */
565
+ const sessionParsers = new Map<string, ReturnType<typeof ndjson.parse>>();
566
+
567
+ /** Defensive upper bound on the per-tick Buffer.alloc. With ndjson, a partial line is buffered
568
+ * internally across polls, so the cap is no longer required for correctness — it is kept purely
569
+ * to bound worst-case memory if the file explodes in a single tick. 1 MiB is plenty. */
570
+ const MAX_SESSION_READ_BYTES = 1 * 1024 * 1024;
571
+
572
+ /** Get-or-create the per-state session parser and wire its 'data' event to the entry handler. */
573
+ function getOrCreateSessionParser(state: InteractiveSubagentState): ReturnType<typeof ndjson.parse> {
574
+ const existing = sessionParsers.get(state.id);
575
+ if (existing) return existing;
576
+ // strict: false → malformed lines are silently dropped instead of triggering an 'error' event
577
+ // that would force us to recreate the parser mid-stream. Same best-effort delivery semantics as
578
+ // the old hand-rolled try/catch around JSON.parse.
579
+ const parser = ndjson.parse({ strict: false });
580
+ parser.on("data", (entry: unknown) => {
581
+ const art = artifactPath(dirname(state.artifactDir), basename(state.artifactDir));
582
+ processSessionLogEntry(state, art, entry as any);
583
+ });
584
+ // In non-strict mode the parser does not emit 'error' for bad JSON, but we still attach a no-op
585
+ // handler so an unhandled error event can never crash the process.
586
+ parser.on("error", () => {
587
+ // Drop the broken parser so the next tick creates a fresh one. The cursor is reset in the
588
+ // truncation handler, so this only fires for pathological non-truncation errors.
589
+ sessionParsers.delete(state.id);
590
+ });
591
+ sessionParsers.set(state.id, parser);
592
+ return parser;
593
+ }
594
+
595
+ /** Destroy a state's parser (used on truncation and on state removal). */
596
+ function destroySessionParser(state: InteractiveSubagentState): void {
597
+ const parser = sessionParsers.get(state.id);
598
+ if (!parser) return;
599
+ try {
600
+ parser.end();
601
+ } catch {
602
+ // ignore — we're tearing down
603
+ }
604
+ sessionParsers.delete(state.id);
605
+ }
606
+
547
607
  /** Tail-read the child's session JSONL and append `tool_activity` events to events.ndjson.
548
608
  * Updates `state.lastDeliveredSessionByte` so subsequent ticks re-read only new lines. */
549
- function tailReadSessionLog(state: InteractiveSubagentState, art: SubagentArtifact): void {
609
+ function tailReadSessionLog(state: InteractiveSubagentState, _art: SubagentArtifact): void {
550
610
  const sessionFile = state.sessionFile;
551
611
  if (!sessionFile) return;
552
612
 
@@ -557,9 +617,25 @@ function tailReadSessionLog(state: InteractiveSubagentState, art: SubagentArtifa
557
617
  return; // file not yet created by the child
558
618
  }
559
619
 
620
+ const initialCursor = state.lastDeliveredSessionByte ?? 0;
621
+ if (size < initialCursor) {
622
+ // File shrunk under us (truncation, rotation, manual edit). Reset cursor and parser and fall
623
+ // through to the read below so any content already written after the truncation is processed in
624
+ // the same tick (e.g. test does truncateSync → writeFileSync → poll). The parser is recreated so the
625
+ // buffered partial state is cleared. Any duplicate tool_activity events are acceptable — the
626
+ // artifact log is best-effort and the LLM never sees these (TUI-widget only).
627
+ state.lastDeliveredSessionByte = 0;
628
+ destroySessionParser(state);
629
+ }
560
630
  const cursor = state.lastDeliveredSessionByte ?? 0;
561
631
  if (size <= cursor) return;
562
632
 
633
+ // Defensive cap on per-tick allocation. ndjson handles partial lines correctly across writes,
634
+ // so a single multi-MB line split across ticks works fine — no cursor pin.
635
+ const requested = size - cursor;
636
+ const toRead = Math.min(requested, MAX_SESSION_READ_BYTES);
637
+ if (toRead <= 0) return;
638
+
563
639
  let fd: number;
564
640
  try {
565
641
  fd = openSync(sessionFile, "r");
@@ -567,59 +643,53 @@ function tailReadSessionLog(state: InteractiveSubagentState, art: SubagentArtifa
567
643
  return;
568
644
  }
569
645
  try {
570
- const len = size - cursor;
571
- const buf = Buffer.alloc(len);
646
+ const buf = Buffer.alloc(toRead);
572
647
  let bytesRead = 0;
573
- while (bytesRead < len) {
574
- const n = readSync(fd, buf, bytesRead, len - bytesRead, cursor + bytesRead);
648
+ while (bytesRead < toRead) {
649
+ const n = readSync(fd, buf, bytesRead, toRead - bytesRead, cursor + bytesRead);
575
650
  if (n <= 0) break;
576
651
  bytesRead += n;
577
652
  }
578
- const chunk = buf.subarray(0, bytesRead).toString("utf8");
579
- processSessionLogChunk(state, art, chunk);
653
+ if (bytesRead === 0) return;
654
+ const parser = getOrCreateSessionParser(state);
655
+ parser.write(buf.subarray(0, bytesRead));
656
+ // Always advance the cursor by the bytes we fed the parser. The parser buffers any partial
657
+ // trailing line internally and will emit the completed object on a later write. We do NOT
658
+ // rewind to the last newline the way the old code did — doing so would re-feed the same bytes
659
+ // to the parser and double-emit on the next tick.
580
660
  state.lastDeliveredSessionByte = cursor + bytesRead;
581
661
  } finally {
582
- try { require("node:fs").closeSync(fd); } catch {}
583
- }
584
- }
585
-
586
- /** Parse a chunk of session JSONL, append a tool_activity event per tool call. */
587
- function processSessionLogChunk(state: InteractiveSubagentState, art: SubagentArtifact, chunk: string): void {
588
- const lines = chunk.split("\n");
589
- // Last entry may be a partial line (the child hasn't finished writing it yet).
590
- // We still process complete lines; the partial line will be re-read on the next tick.
591
- const completeLines = chunk.endsWith("\n") ? lines : lines.slice(0, -1);
592
- for (const line of completeLines) {
593
- if (!line.trim()) continue;
594
- let entry: any;
595
662
  try {
596
- entry = JSON.parse(line);
663
+ closeSync(fd);
597
664
  } catch {
598
- // Skip malformed/partial safer to drop than crash.
599
- continue;
665
+ /* fd already closed or never opened ignore */
600
666
  }
601
- if (entry.type !== "message") continue;
602
- const msg = entry.message;
603
- if (!msg) continue;
604
-
605
- // Assistant message: extract toolCall blocks.
606
- if (msg.role === "assistant" && Array.isArray(msg.content)) {
607
- for (const block of msg.content) {
608
- if (block.type !== "toolCall") continue;
609
- const summary = summarizeToolCall(block.name, block.arguments);
610
- if (!summary) continue;
611
- const ev: SubagentEvent = {
612
- ts: msg.timestamp ?? Date.now(),
613
- type: "tool_activity",
614
- status: "running",
615
- tool: block.name,
616
- summary,
617
- };
618
- appendEvent(art, ev);
619
- state.lastToolName = block.name;
620
- state.lastToolSummary = summary;
621
- state.lastActivityAt = ev.ts;
622
- }
667
+ }
668
+ }
669
+
670
+ /** Process a single parsed JSONL entry from the session log; append tool_activity events. */
671
+ function processSessionLogEntry(state: InteractiveSubagentState, art: SubagentArtifact, entry: any): void {
672
+ if (entry.type !== "message") return;
673
+ const msg = entry.message;
674
+ if (!msg) return;
675
+
676
+ // Assistant message: extract toolCall blocks.
677
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
678
+ for (const block of msg.content) {
679
+ if (block.type !== "toolCall") continue;
680
+ const summary = summarizeToolCall(block.name, block.arguments);
681
+ if (!summary) continue;
682
+ const ev: SubagentEvent = {
683
+ ts: msg.timestamp ?? Date.now(),
684
+ type: "tool_activity",
685
+ status: "running",
686
+ tool: block.name,
687
+ summary,
688
+ };
689
+ appendEvent(art, ev);
690
+ state.lastToolName = block.name;
691
+ state.lastToolSummary = summary;
692
+ state.lastActivityAt = ev.ts;
623
693
  }
624
694
  }
625
695
  }
@@ -734,8 +804,27 @@ function labelFor(event: SubagentEvent): string {
734
804
  * default artifacts root (PI_CODING_AGENT_SESSION_DIR or ~/.pi/agent/sessions/subagentura).
735
805
  * For v1 this is a best-effort lookup; a future iteration can track all artifact roots.
736
806
  */
737
- function findArtifactById(id: string): SubagentArtifact | null {
807
+ import isPathInside from "is-path-inside";
808
+
809
+ export function findArtifactById(id: string): SubagentArtifact | null {
810
+ // Sub-agent ids are randomBytes(4).toString("hex") at spawn time, i.e. 8 hex
811
+ // chars. Validate the id before joining it into a path so that an
812
+ // LLM-supplied id like "../../../etc" can't escape the artifact root
813
+ // (path.join normalises "..", so a malicious id would otherwise resolve
814
+ // to a sibling directory and get exfiltrated to the parent LLM via
815
+ // read_subagent_artifact).
816
+ if (!/^[a-f0-9]{8}$/.test(id)) return null;
817
+
738
818
  const root = process.env.PI_CODING_AGENT_SESSION_DIR ?? join(homedir(), ".pi", "agent", "sessions");
819
+ // Resolve the root once, with symlinks followed, so the containment check below
820
+ // is anchored on the real on-disk location. realpathSync throws if root doesn't
821
+ // exist; in that case there's nothing for us to find.
822
+ let realRoot: string;
823
+ try {
824
+ realRoot = realpathSync(root);
825
+ } catch {
826
+ return null;
827
+ }
739
828
  let topLevel: string[];
740
829
  try {
741
830
  topLevel = readdirSync(root);
@@ -746,6 +835,19 @@ function findArtifactById(id: string): SubagentArtifact | null {
746
835
  const candidate = join(root, entry, "artifacts", id);
747
836
  try {
748
837
  if (statSync(candidate).isDirectory()) {
838
+ // statSync follows symlinks, so a symlink at
839
+ // <root>/<cwd>/artifacts/<id> pointing outside the artifact root
840
+ // would otherwise be returned as a valid artifact. Resolve the
841
+ // candidate with realpath and verify it is still inside the
842
+ // resolved root. realpathSync is safe here because statSync
843
+ // above already confirmed candidate exists as a directory.
844
+ let realCandidate: string;
845
+ try {
846
+ realCandidate = realpathSync(candidate);
847
+ } catch {
848
+ continue;
849
+ }
850
+ if (!isPathInside(realCandidate, realRoot)) continue;
749
851
  return artifactPath(join(root, entry, "artifacts"), id);
750
852
  }
751
853
  } catch {
@@ -830,10 +932,11 @@ export default function (pi: ExtensionAPI) {
830
932
  // every running interactive sub-agent and fires pointer notifications for new events.
831
933
  // The poller survives parent restarts (artifacts on disk + per-state lastDeliveredEventTs).
832
934
  if (!g2.__piSubagenturaInteractivePollerHandle) {
833
- g2.__piSubagenturaInteractivePollerHandle = setInterval(
834
- () => pollArtifactChanges(pi),
835
- 5000,
836
- );
935
+ const handle = setInterval(() => pollArtifactChanges(pi), 5000);
936
+ // Don't pin the event loop on a long-lived parent. unref() lets the process exit
937
+ // cleanly when nothing else is keeping it alive (no other ref'd handles).
938
+ handle.unref?.();
939
+ g2.__piSubagenturaInteractivePollerHandle = handle;
837
940
  }
838
941
  // ── Tool 1: inherits conversation history ────────────────────────
839
942
  pi.registerTool({
@@ -1776,6 +1879,15 @@ export default function (pi: ExtensionAPI) {
1776
1879
  }),
1777
1880
 
1778
1881
  async execute(_toolCallId, params): Promise<any> {
1882
+ // Validate the id shape FIRST so a malformed id gets a precise error
1883
+ // instead of being collapsed into the generic "not found" message.
1884
+ if (!/^[a-f0-9]{8}$/.test(params.id)) {
1885
+ return {
1886
+ content: [{ type: "text", text: `Invalid sub-agent id ${JSON.stringify(params.id)}; expected 8 lowercase hex chars.` }],
1887
+ details: { id: params.id, status: "invalid_id" },
1888
+ isError: true,
1889
+ };
1890
+ }
1779
1891
  const state = interactiveSubagentRegistry.get(params.id);
1780
1892
  const art = state
1781
1893
  ? artifactPath(dirname(state.artifactDir), basename(state.artifactDir))
@@ -1987,10 +2099,39 @@ export default function (pi: ExtensionAPI) {
1987
2099
  },
1988
2100
  });
1989
2101
 
1990
- // ── Session shutdown: abort all jobs and clear registry ──────────
2102
+ // ── Session shutdown: abort all jobs, kill tmux panes, stop the poller ─
1991
2103
  (pi as any).on?.("session_shutdown", () => {
1992
2104
  const g2 = typeof global !== "undefined" ? global : globalThis;
1993
2105
 
2106
+ // Stop the global poller so it doesn't fire after we're gone. Without
2107
+ // clearInterval the handle would keep the event loop alive across restarts.
2108
+ if (g2.__piSubagenturaInteractivePollerHandle) {
2109
+ try {
2110
+ clearInterval(g2.__piSubagenturaInteractivePollerHandle);
2111
+ } catch { /* defensive */ }
2112
+ g2.__piSubagenturaInteractivePollerHandle = undefined;
2113
+ }
2114
+
2115
+ // Kill any tmux panes backing live interactive sub-agents. We can't leave them
2116
+ // running — the parent process is shutting down. cancelInteractiveSubagent
2117
+ // does the right thing (writes .cancelled, kills pane, lets trap record the event).
2118
+ try {
2119
+ for (const state of interactiveSubagentRegistry.values()) {
2120
+ if (state.status === "running") {
2121
+ try {
2122
+ cancelInteractiveSubagent(state.id);
2123
+ } catch { /* best effort */ }
2124
+ }
2125
+ }
2126
+ } catch { /* best effort */ }
2127
+
2128
+ // Drop in-memory state for cancelled/exited interactive sub-agents. Without
2129
+ // this, the Map grows unbounded across session_start/session_shutdown cycles
2130
+ // and list_subagent_artifacts returns stale entries from previous sessions.
2131
+ try {
2132
+ interactiveSubagentRegistry.clear();
2133
+ } catch { /* best effort */ }
2134
+
1994
2135
  // Abort all running subagent sessions before clearing
1995
2136
  for (const job of jobRegistry.values()) {
1996
2137
  if (job.status === "running") {