taskplane 0.22.18 → 0.23.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,686 @@
1
+ /**
2
+ * Agent Host — Direct-child Pi agent hosting for Runtime V2
3
+ *
4
+ * Spawns `pi --mode rpc` as a direct child process (no TMUX, no shell),
5
+ * parses RPC JSONL events, normalizes them into RuntimeAgentEvents,
6
+ * manages mailbox delivery, and produces exit summaries.
7
+ *
8
+ * This replaces the TMUX-backed hosting path (spawnAgentTmux in
9
+ * task-runner.ts + rpc-wrapper.mjs as a TMUX session command) with
10
+ * a programmatic parent-child model where the caller has full process
11
+ * ownership.
12
+ *
13
+ * Key differences from the legacy path:
14
+ * 1. No TMUX — `spawn()` with `shell: false`
15
+ * 2. No sidecar tailing — events flow directly to the caller via callbacks
16
+ * 3. No PID-file orphan guessing — caller owns the process handle
17
+ * 4. Registry integration — manifests updated on status transitions
18
+ * 5. Pi CLI resolved to JS entrypoint, not .CMD shim
19
+ *
20
+ * @module taskplane/agent-host
21
+ * @since TP-104
22
+ */
23
+
24
+ import { spawn, type ChildProcess } from "child_process";
25
+ import {
26
+ readFileSync, writeFileSync, appendFileSync, mkdirSync,
27
+ existsSync, readdirSync, renameSync,
28
+ } from "fs";
29
+ import { join, dirname, basename, resolve } from "path";
30
+ import { StringDecoder } from "string_decoder";
31
+
32
+ import type {
33
+ RuntimeAgentId,
34
+ RuntimeAgentRole,
35
+ RuntimeAgentEvent,
36
+ RuntimeAgentEventType,
37
+ RuntimeAgentManifest,
38
+ PacketPaths,
39
+ } from "./types.ts";
40
+
41
+ import {
42
+ createManifest,
43
+ writeManifest,
44
+ updateManifestStatus,
45
+ buildRegistrySnapshot,
46
+ writeRegistrySnapshot,
47
+ } from "./process-registry.ts";
48
+ import { appendMailboxAuditEvent } from "./mailbox.ts";
49
+
50
+ // ── Pi CLI Resolution ────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Resolve the Pi CLI JS entrypoint for direct spawning.
54
+ *
55
+ * On Windows, `pi` resolves to a .CMD shim which cannot be spawned
56
+ * with `shell: false`. This function resolves the underlying JS file.
57
+ *
58
+ * Resolution order:
59
+ * 1. APPDATA/npm/node_modules/@mariozechner/pi-coding-agent/dist/cli.js
60
+ * 2. HOME/.npm-global/lib/node_modules/...
61
+ * 3. /usr/local/lib/node_modules/...
62
+ *
63
+ * @returns Absolute path to the Pi CLI JS entrypoint
64
+ * @throws Error if Pi CLI cannot be found
65
+ *
66
+ * @since TP-104
67
+ */
68
+ export function resolvePiCliPath(): string {
69
+ const relPath = join("node_modules", "@mariozechner", "pi-coding-agent", "dist", "cli.js");
70
+ const candidates: string[] = [];
71
+
72
+ if (process.env.APPDATA) {
73
+ candidates.push(join(process.env.APPDATA, "npm", relPath));
74
+ }
75
+ const home = process.env.HOME || process.env.USERPROFILE || "";
76
+ if (home) {
77
+ candidates.push(join(home, "AppData", "Roaming", "npm", relPath));
78
+ candidates.push(join(home, ".npm-global", "lib", relPath));
79
+ }
80
+ candidates.push(join("/usr", "local", "lib", relPath));
81
+ candidates.push(join("/opt", "homebrew", "lib", relPath));
82
+
83
+ // Dynamic: npm root -g
84
+ try {
85
+ const { spawnSync } = require("child_process");
86
+ const result = spawnSync("npm", ["root", "-g"], { encoding: "utf-8", timeout: 5000, shell: true });
87
+ if (result.stdout?.trim()) {
88
+ candidates.push(join(result.stdout.trim(), "@mariozechner", "pi-coding-agent", "dist", "cli.js"));
89
+ }
90
+ } catch { /* ignore */ }
91
+
92
+ for (const candidate of candidates) {
93
+ if (existsSync(candidate)) return candidate;
94
+ }
95
+
96
+ throw new Error(
97
+ "Cannot find Pi CLI entrypoint (cli.js). Ensure pi is installed globally. " +
98
+ `Searched: ${candidates.slice(0, 4).join(", ")}`,
99
+ );
100
+ }
101
+
102
+ // ── Conversation Payload Helpers (TP-111) ───────────────────────────────
103
+
104
+ /** Maximum characters for conversation event text payloads. */
105
+ const MAX_CONV_PAYLOAD_CHARS = 2000;
106
+
107
+ /** Truncate a string to maxLen chars, appending ellipsis if truncated. */
108
+ function truncatePayload(text: string, maxLen: number): string {
109
+ if (text.length <= maxLen) return text;
110
+ return text.slice(0, maxLen) + "…";
111
+ }
112
+
113
+ /**
114
+ * Extract text content from a Pi RPC message_end event's message object.
115
+ * Pi may return content as a string or as an array of content blocks.
116
+ */
117
+ function extractAssistantText(message: Record<string, unknown>): string {
118
+ // Direct string content
119
+ if (typeof message.content === "string") return message.content;
120
+ // Array of content blocks (Anthropic format)
121
+ // Guard: skip null/non-object entries to prevent TypeError on malformed streams
122
+ if (Array.isArray(message.content)) {
123
+ const textBlocks = message.content
124
+ .filter((b: unknown): b is { type: string; text: string } =>
125
+ typeof b === "object" && b !== null &&
126
+ (b as any).type === "text" && typeof (b as any).text === "string")
127
+ .map((b) => b.text);
128
+ if (textBlocks.length > 0) return textBlocks.join("\n");
129
+ }
130
+ // Fallback: try text field
131
+ if (typeof message.text === "string") return message.text;
132
+ return "";
133
+ }
134
+
135
+ // ── Types ────────────────────────────────────────────────────────────
136
+
137
+ /**
138
+ * Options for spawning an agent via the direct host.
139
+ *
140
+ * @since TP-104
141
+ */
142
+ export interface AgentHostOptions {
143
+ /** Stable agent identity */
144
+ agentId: RuntimeAgentId;
145
+ /** Agent role */
146
+ role: RuntimeAgentRole;
147
+ /** Batch ID this agent belongs to */
148
+ batchId: string;
149
+ /** Lane number (null for merge agents) */
150
+ laneNumber: number | null;
151
+ /** Task ID being executed (null before first assignment) */
152
+ taskId: string | null;
153
+ /** Repo ID the agent is operating in */
154
+ repoId: string;
155
+ /** Working directory for the Pi process */
156
+ cwd: string;
157
+ /** User prompt content */
158
+ prompt: string;
159
+ /** Optional system prompt content */
160
+ systemPrompt?: string;
161
+ /** Model identifier (e.g., "anthropic/claude-sonnet-4-20250514") */
162
+ model?: string;
163
+ /** Comma-separated tool list */
164
+ tools?: string;
165
+ /** Thinking mode override */
166
+ thinking?: string;
167
+ /** Extension paths to load */
168
+ extensions?: string[];
169
+ /** Mailbox directory for steering (null = no mailbox) */
170
+ mailboxDir?: string | null;
171
+ /** Steering-pending JSONL path (TP-090, worker-only) */
172
+ steeringPendingPath?: string | null;
173
+ /** Path to persist normalized events JSONL */
174
+ eventsPath?: string | null;
175
+ /** Path to write exit summary JSON */
176
+ exitSummaryPath?: string | null;
177
+ /** Timeout in milliseconds (0 = no timeout) */
178
+ timeoutMs?: number;
179
+ /** Delay in ms before closing stdin after agent_end (default: 100) */
180
+ closeDelayMs?: number;
181
+ /** State root for process registry (null = no registry integration) */
182
+ stateRoot?: string | null;
183
+ /** Packet paths for registry manifest (null for merge agents) */
184
+ packet?: PacketPaths | null;
185
+ /** Extra environment variables for the child process */
186
+ env?: Record<string, string>;
187
+ }
188
+
189
+ /**
190
+ * Accumulated telemetry from a completed agent session.
191
+ *
192
+ * @since TP-104
193
+ */
194
+ export interface AgentHostResult {
195
+ /** Process exit code (null if killed by signal) */
196
+ exitCode: number | null;
197
+ /** Signal that killed the process (null if exited normally) */
198
+ signal: string | null;
199
+ /** Wall-clock duration in milliseconds */
200
+ durationMs: number;
201
+ /** Whether the process was killed by the caller */
202
+ killed: boolean;
203
+ /** Total input tokens */
204
+ inputTokens: number;
205
+ /** Total output tokens */
206
+ outputTokens: number;
207
+ /** Cache read tokens */
208
+ cacheReadTokens: number;
209
+ /** Cache write tokens */
210
+ cacheWriteTokens: number;
211
+ /** Cumulative cost in USD */
212
+ costUsd: number;
213
+ /** Number of tool calls */
214
+ toolCalls: number;
215
+ /** Last tool call description */
216
+ lastTool: string;
217
+ /** Number of auto-retries */
218
+ retries: number;
219
+ /** Number of auto-compactions */
220
+ compactions: number;
221
+ /** Authoritative context usage from Pi */
222
+ contextUsage: { tokens: number; contextWindow: number; percent: number } | null;
223
+ /** Final error message (null if clean exit) */
224
+ error: string | null;
225
+ /** Whether agent_end was received */
226
+ agentEnded: boolean;
227
+ /** Captured stderr tail (last 2KB) */
228
+ stderrTail: string;
229
+ }
230
+
231
+ /**
232
+ * Callback for normalized agent events.
233
+ *
234
+ * @since TP-104
235
+ */
236
+ export type AgentEventCallback = (event: RuntimeAgentEvent) => void;
237
+
238
+ /**
239
+ * Callback for telemetry updates (called on each message_end).
240
+ *
241
+ * @since TP-104
242
+ */
243
+ export type AgentTelemetryCallback = (result: Partial<AgentHostResult>) => void;
244
+
245
+ // ── JSONL Helpers ────────────────────────────────────────────────────
246
+
247
+ const MAILBOX_MESSAGE_TYPES = new Set(["steer", "query", "abort", "info", "reply", "escalate"]);
248
+
249
+ function isValidMailboxMessage(obj: any): boolean {
250
+ if (!obj || typeof obj !== "object") return false;
251
+ return (
252
+ typeof obj.id === "string" &&
253
+ typeof obj.batchId === "string" &&
254
+ typeof obj.from === "string" &&
255
+ typeof obj.to === "string" &&
256
+ typeof obj.timestamp === "number" && Number.isFinite(obj.timestamp) &&
257
+ typeof obj.type === "string" && MAILBOX_MESSAGE_TYPES.has(obj.type) &&
258
+ typeof obj.content === "string"
259
+ );
260
+ }
261
+
262
+ // ── Core Host Function ───────────────────────────────────────────────
263
+
264
+ /**
265
+ * Spawn and manage a Pi agent as a direct child process.
266
+ *
267
+ * Returns a promise that resolves with the full session result when
268
+ * the agent exits, plus a kill function for early termination.
269
+ *
270
+ * @param opts - Agent host options
271
+ * @param onEvent - Optional callback for normalized events
272
+ * @param onTelemetry - Optional callback for telemetry updates
273
+ * @returns Object with promise (resolves on exit) and kill function
274
+ *
275
+ * @since TP-104
276
+ */
277
+ export function spawnAgent(
278
+ opts: AgentHostOptions,
279
+ onEvent?: AgentEventCallback,
280
+ onTelemetry?: AgentTelemetryCallback,
281
+ ): { promise: Promise<AgentHostResult>; kill: () => void } {
282
+
283
+ const cliPath = resolvePiCliPath();
284
+ const closeDelayMs = opts.closeDelayMs ?? 100;
285
+ const timeoutMs = opts.timeoutMs ?? 0;
286
+
287
+ // Build Pi CLI arguments
288
+ const piArgs: string[] = [cliPath, "--mode", "rpc", "--no-session"];
289
+ if (opts.model) piArgs.push("--model", opts.model);
290
+ if (opts.tools) piArgs.push("--tools", opts.tools);
291
+ if (opts.systemPrompt) piArgs.push("--system-prompt", opts.systemPrompt);
292
+ // Always pass --no-extensions to prevent auto-discovery from cwd.
293
+ // Explicit -e entries are still honored by pi even with --no-extensions.
294
+ // This matches the fix from TP-095 that eliminated duplicate extension loading.
295
+ piArgs.push("--no-extensions");
296
+ if (opts.extensions && opts.extensions.length > 0) {
297
+ for (const ext of opts.extensions) {
298
+ piArgs.push("-e", ext);
299
+ }
300
+ }
301
+ piArgs.push("--no-skills");
302
+ if (opts.thinking) piArgs.push("--thinking", opts.thinking);
303
+
304
+ // Spawn directly — no shell, no TMUX
305
+ const proc = spawn(process.execPath, piArgs, {
306
+ shell: false,
307
+ cwd: opts.cwd,
308
+ stdio: ["pipe", "pipe", "pipe"],
309
+ env: { ...process.env, ...(opts.env ?? {}) },
310
+ });
311
+
312
+ // State accumulator
313
+ const startedAt = Date.now();
314
+ let killed = false;
315
+ let timedOut = false;
316
+ let agentEnded = false;
317
+ let stdinClosed = false;
318
+ let statsRequested = false;
319
+ let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0;
320
+ let costUsd = 0, toolCalls = 0, retries = 0, compactions = 0;
321
+ let lastTool = "", error: string | null = null;
322
+ let contextUsage: AgentHostResult["contextUsage"] = null;
323
+ let stderrBuffer = "";
324
+ const STDERR_MAX = 2048;
325
+
326
+ // Timeout
327
+ let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
328
+ if (timeoutMs > 0) {
329
+ timeoutHandle = setTimeout(() => {
330
+ timedOut = true;
331
+ killed = true;
332
+ try { proc.kill("SIGTERM"); } catch { /* ignore */ }
333
+ }, timeoutMs);
334
+ }
335
+
336
+ const REGISTRY_REFRESH_INTERVAL_MS = 1_000;
337
+ let lastRegistryRefreshAt = 0;
338
+ const refreshRegistrySnapshot = (force: boolean = false) => {
339
+ if (!opts.stateRoot) return;
340
+ const now = Date.now();
341
+ if (!force && (now - lastRegistryRefreshAt) < REGISTRY_REFRESH_INTERVAL_MS) return;
342
+ try {
343
+ const snapshot = buildRegistrySnapshot(opts.stateRoot, opts.batchId);
344
+ writeRegistrySnapshot(opts.stateRoot, snapshot);
345
+ lastRegistryRefreshAt = now;
346
+ } catch { /* best effort */ }
347
+ };
348
+
349
+ // Registry integration: write manifest before process is considered visible
350
+ if (opts.stateRoot) {
351
+ const manifest = createManifest({
352
+ batchId: opts.batchId,
353
+ agentId: opts.agentId,
354
+ role: opts.role,
355
+ laneNumber: opts.laneNumber,
356
+ taskId: opts.taskId,
357
+ repoId: opts.repoId,
358
+ pid: proc.pid ?? 0,
359
+ parentPid: process.pid,
360
+ cwd: opts.cwd,
361
+ packet: opts.packet ?? null,
362
+ });
363
+ manifest.status = "running";
364
+ writeManifest(opts.stateRoot, manifest);
365
+ refreshRegistrySnapshot(true);
366
+ }
367
+
368
+ // Helper: close stdin safely with delay
369
+ function closeStdin() {
370
+ if (stdinClosed) return;
371
+ stdinClosed = true;
372
+ if (closeDelayMs > 0) {
373
+ setTimeout(() => {
374
+ try { proc.stdin?.end(); } catch { /* ignore */ }
375
+ }, closeDelayMs);
376
+ } else {
377
+ try { proc.stdin?.end(); } catch { /* ignore */ }
378
+ }
379
+ }
380
+
381
+ // Helper: emit normalized event
382
+ function emitEvent(type: RuntimeAgentEventType, payload: Record<string, unknown> = {}) {
383
+ const event: RuntimeAgentEvent = {
384
+ batchId: opts.batchId,
385
+ agentId: opts.agentId,
386
+ role: opts.role,
387
+ laneNumber: opts.laneNumber,
388
+ taskId: opts.taskId,
389
+ repoId: opts.repoId,
390
+ ts: Date.now(),
391
+ type,
392
+ payload,
393
+ };
394
+ if (onEvent) onEvent(event);
395
+ // Persist to events JSONL if path is provided
396
+ if (opts.eventsPath) {
397
+ try {
398
+ mkdirSync(dirname(opts.eventsPath), { recursive: true });
399
+ appendFileSync(opts.eventsPath, JSON.stringify(event) + "\n", "utf-8");
400
+ } catch { /* best effort */ }
401
+ }
402
+ }
403
+
404
+ // Helper: check mailbox and inject (own inbox + _broadcast)
405
+ function checkMailbox() {
406
+ if (!opts.mailboxDir || !proc.stdin || proc.stdin.destroyed) return;
407
+
408
+ const expectedSessionName = basename(opts.mailboxDir);
409
+ const expectedBatchId = basename(dirname(opts.mailboxDir));
410
+
411
+ // Collect messages from own inbox AND broadcast inbox
412
+ const inboxDirs: Array<{ dir: string; isBroadcast: boolean }> = [
413
+ { dir: join(opts.mailboxDir, "inbox"), isBroadcast: false },
414
+ ];
415
+ // TP-106: Also check _broadcast/inbox for broadcast messages
416
+ const broadcastInbox = join(dirname(opts.mailboxDir), "_broadcast", "inbox");
417
+ if (existsSync(broadcastInbox)) {
418
+ inboxDirs.push({ dir: broadcastInbox, isBroadcast: true });
419
+ }
420
+
421
+ for (const { dir: inboxDir, isBroadcast } of inboxDirs) {
422
+ if (!existsSync(inboxDir)) continue;
423
+
424
+ let entries: string[];
425
+ try { entries = readdirSync(inboxDir); } catch { continue; }
426
+
427
+ const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp")).sort();
428
+ if (msgFiles.length === 0) continue;
429
+
430
+ const ackDir = join(opts.mailboxDir, "ack");
431
+
432
+ for (const filename of msgFiles) {
433
+ try {
434
+ const raw = readFileSync(join(inboxDir, filename), "utf-8");
435
+ const msg = JSON.parse(raw);
436
+ if (!isValidMailboxMessage(msg)) continue;
437
+ if (msg.batchId !== expectedBatchId) continue;
438
+ // Validate 'to' field: own inbox requires exact match, broadcast accepts "_broadcast"
439
+ if (!isBroadcast && msg.to !== expectedSessionName) continue;
440
+ if (isBroadcast && msg.to !== "_broadcast") continue;
441
+
442
+ mkdirSync(ackDir, { recursive: true });
443
+ const ackPath = join(ackDir, filename);
444
+ // Broadcast fan-out: if this agent already acked this broadcast message,
445
+ // skip to avoid duplicate delivery while preserving message for peers.
446
+ if (isBroadcast && existsSync(ackPath)) continue;
447
+
448
+ proc.stdin.write(JSON.stringify({ type: "steer", message: msg.content }) + "\n");
449
+
450
+ if (isBroadcast) {
451
+ // Do NOT remove the shared broadcast inbox file. Persist a per-agent
452
+ // ack marker so all agents can consume the same broadcast exactly once.
453
+ try { writeFileSync(ackPath, raw, "utf-8"); } catch { /* best effort */ }
454
+ } else {
455
+ try { renameSync(join(inboxDir, filename), ackPath); } catch { /* race ok */ }
456
+ }
457
+
458
+ emitEvent("message_delivered", { messageId: msg.id, content: msg.content, broadcast: isBroadcast });
459
+ if (opts.stateRoot) {
460
+ appendMailboxAuditEvent(opts.stateRoot, expectedBatchId, {
461
+ type: "message_delivered",
462
+ from: msg.from,
463
+ to: isBroadcast ? expectedSessionName : msg.to,
464
+ messageId: msg.id,
465
+ messageType: msg.type,
466
+ contentPreview: msg.content.slice(0, 200),
467
+ broadcast: isBroadcast,
468
+ });
469
+ }
470
+
471
+ // TP-090: steering-pending flag
472
+ if (opts.steeringPendingPath) {
473
+ try {
474
+ appendFileSync(opts.steeringPendingPath,
475
+ JSON.stringify({ ts: msg.timestamp, content: msg.content, id: msg.id }) + "\n", "utf-8");
476
+ } catch { /* best effort */ }
477
+ }
478
+ } catch { /* skip malformed */ }
479
+ }
480
+ }
481
+ }
482
+
483
+ const promise = new Promise<AgentHostResult>((resolvePromise) => {
484
+ let stdoutBuf = "";
485
+ const decoder = new StringDecoder("utf8");
486
+ let finished = false;
487
+
488
+ function finish(exitCode: number | null, signal: string | null) {
489
+ if (finished) return;
490
+ finished = true;
491
+ if (timeoutHandle) clearTimeout(timeoutHandle);
492
+
493
+ const result: AgentHostResult = {
494
+ exitCode,
495
+ signal,
496
+ durationMs: Date.now() - startedAt,
497
+ killed,
498
+ inputTokens,
499
+ outputTokens,
500
+ cacheReadTokens,
501
+ cacheWriteTokens,
502
+ costUsd,
503
+ toolCalls,
504
+ lastTool,
505
+ retries,
506
+ compactions,
507
+ contextUsage,
508
+ error,
509
+ agentEnded,
510
+ stderrTail: stderrBuffer.trim().slice(-STDERR_MAX),
511
+ };
512
+
513
+ // Write exit summary if path provided
514
+ if (opts.exitSummaryPath) {
515
+ try {
516
+ mkdirSync(dirname(opts.exitSummaryPath), { recursive: true });
517
+ const summary = {
518
+ exitCode: result.exitCode,
519
+ exitSignal: result.signal,
520
+ tokens: (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens) > 0
521
+ ? { input: inputTokens, output: outputTokens, cacheRead: cacheReadTokens, cacheWrite: cacheWriteTokens }
522
+ : null,
523
+ cost: costUsd > 0 ? costUsd : null,
524
+ toolCalls,
525
+ retries,
526
+ compactions,
527
+ durationSec: Math.round(result.durationMs / 1000),
528
+ lastToolCall: lastTool || null,
529
+ error: error || null,
530
+ contextUsage: contextUsage || null,
531
+ };
532
+ writeFileSync(opts.exitSummaryPath, JSON.stringify(summary, null, 2) + "\n", "utf-8");
533
+ } catch { /* best effort */ }
534
+ }
535
+
536
+ const exitEventType: RuntimeAgentEventType =
537
+ timedOut ? "agent_timeout" :
538
+ killed ? "agent_killed" :
539
+ (exitCode === 0 && agentEnded) ? "agent_exited" :
540
+ "agent_crashed";
541
+ emitEvent(exitEventType, { exitCode, signal, durationMs: result.durationMs, timedOut });
542
+
543
+ // Registry integration: update manifest to terminal status
544
+ if (opts.stateRoot) {
545
+ const terminalStatus =
546
+ timedOut ? "timed_out" as const :
547
+ killed ? "killed" as const :
548
+ (exitCode === 0 && agentEnded) ? "exited" as const :
549
+ "crashed" as const;
550
+ updateManifestStatus(opts.stateRoot, opts.batchId, opts.agentId, terminalStatus);
551
+ refreshRegistrySnapshot(true);
552
+ }
553
+
554
+ resolvePromise(result);
555
+ }
556
+
557
+ proc.stdout.on("data", (chunk: Buffer | string) => {
558
+ stdoutBuf += typeof chunk === "string" ? chunk : decoder.write(chunk);
559
+ let idx: number;
560
+ while ((idx = stdoutBuf.indexOf("\n")) >= 0) {
561
+ let line = stdoutBuf.slice(0, idx);
562
+ stdoutBuf = stdoutBuf.slice(idx + 1);
563
+ if (line.endsWith("\r")) line = line.slice(0, -1);
564
+ if (!line.trim()) continue;
565
+
566
+ let event: any;
567
+ try { event = JSON.parse(line); } catch { continue; }
568
+ if (!event || !event.type) continue;
569
+
570
+ // Accumulate telemetry
571
+ switch (event.type) {
572
+ case "message_end": {
573
+ const usage = event.message?.usage;
574
+ if (usage) {
575
+ inputTokens += usage.input || 0;
576
+ outputTokens += usage.output || 0;
577
+ cacheReadTokens += usage.cacheRead || 0;
578
+ cacheWriteTokens += usage.cacheWrite || 0;
579
+ if (usage.cost) {
580
+ costUsd += typeof usage.cost === "object" ? (usage.cost.total || 0) : (typeof usage.cost === "number" ? usage.cost : 0);
581
+ }
582
+ }
583
+ // TP-111: Emit assistant_message with bounded content
584
+ if (event.message?.role === "assistant") {
585
+ const content = extractAssistantText(event.message);
586
+ if (content) {
587
+ emitEvent("assistant_message", { text: truncatePayload(content, MAX_CONV_PAYLOAD_CHARS) });
588
+ }
589
+ }
590
+ // Request session stats after first assistant message
591
+ if (!statsRequested && event.message?.role === "assistant") {
592
+ statsRequested = true;
593
+ try { proc.stdin?.write(JSON.stringify({ type: "get_session_stats" }) + "\n"); } catch { /* ignore */ }
594
+ }
595
+ // Check mailbox
596
+ checkMailbox();
597
+ // Keep registry snapshot freshness while agent is active.
598
+ refreshRegistrySnapshot(false);
599
+ // Emit telemetry update
600
+ if (onTelemetry) {
601
+ onTelemetry({ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, costUsd, toolCalls, lastTool, contextUsage });
602
+ }
603
+ break;
604
+ }
605
+ case "tool_execution_start": {
606
+ toolCalls++;
607
+ const toolName = event.toolName || "tool";
608
+ const argPreview = typeof event.args === "string" ? event.args.slice(0, 80) :
609
+ (event.args && typeof Object.values(event.args)[0] === "string" ? String(Object.values(event.args)[0]).slice(0, 80) : "");
610
+ lastTool = argPreview ? `${toolName}: ${argPreview}` : toolName;
611
+ // TP-111: Bounded payload only — no raw args in durable event log
612
+ const toolPath = event.args?.path ? String(event.args.path).slice(0, 200) : "";
613
+ emitEvent("tool_call", { tool: toolName, path: toolPath, argsPreview: argPreview });
614
+ break;
615
+ }
616
+ case "tool_execution_end": {
617
+ // TP-111: Include bounded result summary for dashboard display
618
+ const toolResultSummary = typeof event.result === "string" ? event.result.slice(0, 200)
619
+ : event.output ? String(event.output).slice(0, 200) : "";
620
+ emitEvent("tool_result", { tool: event.toolName, summary: toolResultSummary });
621
+ break;
622
+ }
623
+ case "auto_retry_start": {
624
+ retries++;
625
+ emitEvent("retry_started", { attempt: event.attempt, error: event.errorMessage || event.error });
626
+ break;
627
+ }
628
+ case "auto_compaction_start": {
629
+ compactions++;
630
+ emitEvent("compaction_started", {});
631
+ break;
632
+ }
633
+ case "response": {
634
+ if (event.success === false && event.error) {
635
+ error = event.error;
636
+ }
637
+ if (event.success === true && event.data?.contextUsage) {
638
+ contextUsage = event.data.contextUsage;
639
+ emitEvent("context_usage", { ...event.data.contextUsage });
640
+ }
641
+ break;
642
+ }
643
+ case "agent_end": {
644
+ agentEnded = true;
645
+ closeStdin();
646
+ break;
647
+ }
648
+ }
649
+ }
650
+ });
651
+
652
+ proc.stderr?.setEncoding("utf-8");
653
+ proc.stderr?.on("data", (chunk: string) => {
654
+ stderrBuffer += chunk;
655
+ if (stderrBuffer.length > STDERR_MAX * 2) {
656
+ stderrBuffer = stderrBuffer.slice(-STDERR_MAX);
657
+ }
658
+ });
659
+
660
+ proc.on("error", (err: Error) => {
661
+ error = `spawn error: ${err.message}`;
662
+ finish(null, null);
663
+ });
664
+
665
+ proc.on("close", (code: number | null, signal: string | null) => {
666
+ finish(code, signal);
667
+ });
668
+
669
+ // Send steering mode and prompt
670
+ if (opts.mailboxDir) {
671
+ proc.stdin.write(JSON.stringify({ type: "set_steering_mode", mode: "all" }) + "\n");
672
+ }
673
+ proc.stdin.write(JSON.stringify({ type: "prompt", message: opts.prompt }) + "\n");
674
+
675
+ emitEvent("agent_started", { model: opts.model, cwd: opts.cwd });
676
+ // TP-111: Emit prompt_sent with bounded preview
677
+ emitEvent("prompt_sent", { text: truncatePayload(opts.prompt, MAX_CONV_PAYLOAD_CHARS) });
678
+ });
679
+
680
+ const kill = () => {
681
+ killed = true;
682
+ try { proc.kill("SIGTERM"); } catch { /* ignore */ }
683
+ };
684
+
685
+ return { promise, kill };
686
+ }