@ogulcancelik/pi-spar 0.1.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.
Files changed (6) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -0
  3. package/core.ts +879 -0
  4. package/index.ts +760 -0
  5. package/package.json +41 -0
  6. package/peek.ts +683 -0
package/core.ts ADDED
@@ -0,0 +1,879 @@
1
+ /**
2
+ * Spar Core - Agent-to-agent communication via pi RPC
3
+ *
4
+ * Extracted from pi-spar.ts for use as a native tool.
5
+ */
6
+
7
+ import { spawn } from "child_process";
8
+ import * as readline from "readline";
9
+ import * as path from "path";
10
+ import * as fs from "fs";
11
+ import * as net from "net";
12
+ import * as os from "os";
13
+
14
+ // =============================================================================
15
+ // Configuration
16
+ // =============================================================================
17
+
18
+ // Session storage in pi's config directory (persistent across reboots)
19
+ const SPAR_DIR = path.join(os.homedir(), ".pi", "agent", "spar");
20
+ const SESSION_DIR = path.join(SPAR_DIR, "sessions");
21
+ const CONFIG_PATH = path.join(SPAR_DIR, "config.json");
22
+
23
+ // Default timeout: 30 minutes (sliding - resets on activity)
24
+ export const DEFAULT_TIMEOUT = 1800000;
25
+
26
+ // Default tools for peer agent (read-only)
27
+ const DEFAULT_TOOLS = "read,grep,find,ls";
28
+
29
+ // =============================================================================
30
+ // Spar Config — user-configured models via /spar-models
31
+ // =============================================================================
32
+
33
+ export interface SparModelConfig {
34
+ alias: string; // short name like "gpt5", "opus"
35
+ provider: string; // pi provider like "openai", "anthropic"
36
+ id: string; // model id like "gpt-5.4", "claude-opus-4-6"
37
+ }
38
+
39
+ export interface SparConfig {
40
+ models: SparModelConfig[];
41
+ }
42
+
43
+ export function loadSparConfig(): SparConfig {
44
+ try {
45
+ if (fs.existsSync(CONFIG_PATH)) {
46
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
47
+ }
48
+ } catch {}
49
+ return { models: [] };
50
+ }
51
+
52
+ export function saveSparConfig(config: SparConfig): void {
53
+ fs.mkdirSync(SPAR_DIR, { recursive: true });
54
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
55
+ }
56
+
57
+ /** Build alias → provider:model map from config */
58
+ function getModelAliases(): Record<string, string> {
59
+ const config = loadSparConfig();
60
+ const aliases: Record<string, string> = {};
61
+ for (const m of config.models) {
62
+ aliases[m.alias] = `${m.provider}:${m.id}`;
63
+ }
64
+ return aliases;
65
+ }
66
+
67
+ /** Get configured models for tool description */
68
+ export function getConfiguredModelsDescription(): string {
69
+ const config = loadSparConfig();
70
+ if (config.models.length === 0) {
71
+ return "No models configured. Run /spar-models to set up sparring models.";
72
+ }
73
+ return config.models
74
+ .map(m => `- \`${m.alias}\` - ${m.provider}/${m.id}`)
75
+ .join("\n");
76
+ }
77
+
78
+ // =============================================================================
79
+ // Types
80
+ // =============================================================================
81
+
82
+ export interface SessionInfo {
83
+ id: string;
84
+ model: string;
85
+ provider: string;
86
+ modelId: string;
87
+ thinking?: string;
88
+ tools: string;
89
+ sessionFile: string;
90
+ createdAt: number;
91
+ lastActivity?: number;
92
+ messageCount?: number;
93
+ status?: "active" | "closed" | "failed";
94
+ error?: string;
95
+ failedAt?: number;
96
+ closedAt?: number;
97
+ }
98
+
99
+ export interface SendResult {
100
+ response: string;
101
+ usage?: {
102
+ input: number;
103
+ output: number;
104
+ cost: number;
105
+ };
106
+ }
107
+
108
+ export interface ProgressStatus {
109
+ model: string;
110
+ sessionId: string;
111
+ startTime: number;
112
+ status: "thinking" | "tool" | "streaming" | "done" | "error";
113
+ toolName?: string;
114
+ toolArgs?: string;
115
+ elapsed?: number;
116
+ }
117
+
118
+ export interface SessionSummary {
119
+ name: string;
120
+ model: string;
121
+ modelAlias?: string;
122
+ messageCount: number;
123
+ lastActivity: number;
124
+ status: string;
125
+ }
126
+
127
+ // =============================================================================
128
+ // Directory Management
129
+ // =============================================================================
130
+
131
+ function ensureSessionDir(): void {
132
+ fs.mkdirSync(SESSION_DIR, { recursive: true });
133
+ }
134
+
135
+ function getSessionInfoPath(sessionId: string): string {
136
+ return path.join(SESSION_DIR, `${sessionId}.info.json`);
137
+ }
138
+
139
+ function getSessionFilePath(sessionId: string): string {
140
+ return path.join(SESSION_DIR, `${sessionId}.jsonl`);
141
+ }
142
+
143
+ function getSessionLogPath(sessionId: string): string {
144
+ return path.join(SESSION_DIR, `${sessionId}.log`);
145
+ }
146
+
147
+ function getSocketPath(sessionId: string): string {
148
+ return `/tmp/pi-spar-${sessionId}.sock`;
149
+ }
150
+
151
+ // =============================================================================
152
+ // Session Logger
153
+ // =============================================================================
154
+
155
+ class SessionLogger {
156
+ private logPath: string;
157
+ private stream: fs.WriteStream | null = null;
158
+
159
+ constructor(sessionId: string) {
160
+ this.logPath = getSessionLogPath(sessionId);
161
+ }
162
+
163
+ private timestamp(): string {
164
+ return new Date().toISOString();
165
+ }
166
+
167
+ private write(level: string, category: string, message: string, data?: any) {
168
+ const entry = {
169
+ ts: this.timestamp(),
170
+ level,
171
+ category,
172
+ message,
173
+ ...(data !== undefined ? { data } : {}),
174
+ };
175
+ const line = JSON.stringify(entry) + "\n";
176
+
177
+ if (!this.stream) {
178
+ this.stream = fs.createWriteStream(this.logPath, { flags: "a" });
179
+ }
180
+ this.stream.write(line);
181
+
182
+ if (process.env.PI_SPAR_DEBUG) {
183
+ console.error(`[${level}] ${category}: ${message}`, data ? JSON.stringify(data).slice(0, 200) : "");
184
+ }
185
+ }
186
+
187
+ info(category: string, message: string, data?: any) { this.write("INFO", category, message, data); }
188
+ error(category: string, message: string, data?: any) { this.write("ERROR", category, message, data); }
189
+ warn(category: string, message: string, data?: any) { this.write("WARN", category, message, data); }
190
+ debug(category: string, message: string, data?: any) { this.write("DEBUG", category, message, data); }
191
+
192
+ rpcEvent(event: any) {
193
+ this.write("DEBUG", "rpc-event", event.type, {
194
+ type: event.type,
195
+ ...(event.type === "tool_execution_start" ? { tool: event.toolName, args: event.args } : {}),
196
+ ...(event.type === "tool_execution_end" ? { tool: event.toolName } : {}),
197
+ ...(event.type === "response" ? { success: event.success, error: event.error, id: event.id } : {}),
198
+ ...(event.type === "message_end" ? { errorMessage: event.message?.errorMessage } : {}),
199
+ ...(event.type === "agent_end" ? { messageCount: event.messages?.length } : {}),
200
+ });
201
+ }
202
+
203
+ stderr(chunk: string) { this.write("STDERR", "pi-process", chunk.trim()); }
204
+
205
+ close() {
206
+ if (this.stream) {
207
+ this.stream.end();
208
+ this.stream = null;
209
+ }
210
+ }
211
+ }
212
+
213
+ // =============================================================================
214
+ // Event Broadcaster (for peek extension)
215
+ // =============================================================================
216
+
217
+ class EventBroadcaster {
218
+ private server: net.Server | null = null;
219
+ private connections: net.Socket[] = [];
220
+ private socketPath: string;
221
+
222
+ // Track state for sync on connect
223
+ private currentStatus: "thinking" | "streaming" | "tool" | "done" = "thinking";
224
+ private currentToolName?: string;
225
+ private currentPartialMessage: any = null; // The full partial AssistantMessage
226
+
227
+ constructor(sessionId: string) {
228
+ this.socketPath = getSocketPath(sessionId);
229
+ }
230
+
231
+ start(): void {
232
+ try {
233
+ if (fs.existsSync(this.socketPath)) {
234
+ fs.unlinkSync(this.socketPath);
235
+ }
236
+ } catch {}
237
+
238
+ this.server = net.createServer((conn) => {
239
+ this.connections.push(conn);
240
+
241
+ // Send sync event with current state to new client
242
+ const syncEvent = {
243
+ type: "sync",
244
+ status: this.currentStatus,
245
+ toolName: this.currentToolName,
246
+ partialMessage: this.currentPartialMessage,
247
+ };
248
+ try { conn.write(JSON.stringify(syncEvent) + "\n"); } catch {}
249
+
250
+ conn.on("close", () => {
251
+ const idx = this.connections.indexOf(conn);
252
+ if (idx >= 0) this.connections.splice(idx, 1);
253
+ });
254
+ conn.on("error", () => {
255
+ const idx = this.connections.indexOf(conn);
256
+ if (idx >= 0) this.connections.splice(idx, 1);
257
+ });
258
+ });
259
+
260
+ this.server.listen(this.socketPath);
261
+ }
262
+
263
+ broadcast(event: any): void {
264
+ // Track state for sync
265
+ if (event.type === "message_start" && event.message?.role === "assistant") {
266
+ this.currentPartialMessage = event.message;
267
+ this.currentStatus = "thinking";
268
+ } else if (event.type === "message_update" && event.message?.role === "assistant") {
269
+ // event.message is the full accumulated partial AssistantMessage
270
+ this.currentPartialMessage = event.message;
271
+ const delta = event.assistantMessageEvent;
272
+ if (delta?.type === "thinking_delta") {
273
+ this.currentStatus = "thinking";
274
+ } else if (delta?.type === "text_delta") {
275
+ this.currentStatus = "streaming";
276
+ }
277
+ } else if (event.type === "tool_execution_start") {
278
+ this.currentStatus = "tool";
279
+ this.currentToolName = event.toolName;
280
+ } else if (event.type === "message_end" || event.type === "agent_end") {
281
+ this.currentPartialMessage = null;
282
+ this.currentStatus = "done";
283
+ this.currentToolName = undefined;
284
+ }
285
+
286
+ const line = JSON.stringify(event) + "\n";
287
+ for (const conn of this.connections) {
288
+ try { conn.write(line); } catch {}
289
+ }
290
+ }
291
+
292
+ stop(): void {
293
+ for (const conn of this.connections) {
294
+ try { conn.end(); } catch {}
295
+ }
296
+ this.connections = [];
297
+
298
+ if (this.server) {
299
+ this.server.close();
300
+ this.server = null;
301
+ }
302
+
303
+ try {
304
+ if (fs.existsSync(this.socketPath)) {
305
+ fs.unlinkSync(this.socketPath);
306
+ }
307
+ } catch {}
308
+ }
309
+ }
310
+
311
+
312
+
313
+ // =============================================================================
314
+ // Session Management
315
+ // =============================================================================
316
+
317
+ function validateSessionName(name: string): void {
318
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
319
+ throw new Error(`Invalid session name: "${name}". Only alphanumeric, hyphens, and underscores allowed.`);
320
+ }
321
+ if (name.length > 64) {
322
+ throw new Error(`Session name too long (max 64 chars): "${name}"`);
323
+ }
324
+ }
325
+
326
+ function saveSessionInfo(info: SessionInfo): void {
327
+ ensureSessionDir();
328
+ fs.writeFileSync(getSessionInfoPath(info.id), JSON.stringify(info, null, 2));
329
+ }
330
+
331
+ function loadSessionInfo(sessionId: string): SessionInfo | null {
332
+ validateSessionName(sessionId);
333
+ const infoPath = getSessionInfoPath(sessionId);
334
+ if (!fs.existsSync(infoPath)) {
335
+ return null;
336
+ }
337
+ return JSON.parse(fs.readFileSync(infoPath, "utf-8"));
338
+ }
339
+
340
+ function markSessionFailed(sessionId: string, error: string): void {
341
+ try {
342
+ const info = loadSessionInfo(sessionId);
343
+ if (info) {
344
+ info.status = "failed";
345
+ info.error = error;
346
+ info.failedAt = Date.now();
347
+ saveSessionInfo(info);
348
+ }
349
+ } catch {}
350
+ }
351
+
352
+ function countSessionMessages(sessionFile: string): number {
353
+ if (!fs.existsSync(sessionFile)) return 0;
354
+ try {
355
+ const content = fs.readFileSync(sessionFile, "utf-8");
356
+ const lines = content.trim().split("\n").filter(Boolean);
357
+ // Count user messages (approximate)
358
+ let count = 0;
359
+ for (const line of lines) {
360
+ try {
361
+ const entry = JSON.parse(line);
362
+ if (entry.type === "message" && entry.message?.role === "user") {
363
+ count++;
364
+ }
365
+ } catch {}
366
+ }
367
+ return count;
368
+ } catch {
369
+ return 0;
370
+ }
371
+ }
372
+
373
+ // =============================================================================
374
+ // Public API
375
+ // =============================================================================
376
+
377
+ /**
378
+ * Resolve model alias or provider:model string to components.
379
+ * Accepts: "opus", "gpt5" (configured aliases), or "provider:model" directly.
380
+ */
381
+ export function resolveModel(model: string): { provider: string; modelId: string; fullModel: string } {
382
+ const aliases = getModelAliases();
383
+ const fullModel = aliases[model] || model;
384
+ const parts = fullModel.split(":");
385
+ if (parts.length < 2) {
386
+ const available = Object.keys(aliases);
387
+ const hint = available.length > 0
388
+ ? `Use ${available.map(a => `"${a}"`).join(", ")}, or "provider:model".`
389
+ : `Use "provider:model" format. Run /spar-models to configure aliases.`;
390
+ throw new Error(`Invalid model: "${model}". ${hint}`);
391
+ }
392
+ return {
393
+ provider: parts[0],
394
+ modelId: parts.slice(1).join(":"),
395
+ fullModel,
396
+ };
397
+ }
398
+
399
+ /**
400
+ * Get model alias from full model string (for display)
401
+ */
402
+ export function getModelAlias(fullModel: string): string | undefined {
403
+ const aliases = getModelAliases();
404
+ for (const [alias, model] of Object.entries(aliases)) {
405
+ if (model === fullModel) return alias;
406
+ }
407
+ return undefined;
408
+ }
409
+
410
+ /**
411
+ * List all sessions
412
+ */
413
+ export function listSessions(): SessionSummary[] {
414
+ ensureSessionDir();
415
+ const files = fs.readdirSync(SESSION_DIR).filter(f => f.endsWith(".info.json"));
416
+ const sessions: SessionSummary[] = [];
417
+
418
+ for (const file of files) {
419
+ try {
420
+ const info: SessionInfo = JSON.parse(fs.readFileSync(path.join(SESSION_DIR, file), "utf-8"));
421
+ const messageCount = info.messageCount ?? countSessionMessages(info.sessionFile);
422
+ sessions.push({
423
+ name: info.id,
424
+ model: info.model,
425
+ modelAlias: getModelAlias(info.model),
426
+ messageCount,
427
+ lastActivity: info.lastActivity ?? info.createdAt,
428
+ status: info.status ?? "active",
429
+ });
430
+ } catch {}
431
+ }
432
+
433
+ // Sort by last activity (most recent first)
434
+ sessions.sort((a, b) => b.lastActivity - a.lastActivity);
435
+ return sessions;
436
+ }
437
+
438
+ /**
439
+ * Check if a session exists
440
+ */
441
+ export function sessionExists(name: string): boolean {
442
+ validateSessionName(name);
443
+ return fs.existsSync(getSessionInfoPath(name));
444
+ }
445
+
446
+ /**
447
+ * Get session info
448
+ */
449
+ export function getSession(name: string): SessionInfo | null {
450
+ return loadSessionInfo(name);
451
+ }
452
+
453
+ /**
454
+ * Get session history (past exchanges)
455
+ */
456
+ export interface Exchange {
457
+ user: string;
458
+ assistant: string;
459
+ }
460
+
461
+ export function getSessionHistory(name: string, count: number = 5): Exchange[] {
462
+ validateSessionName(name);
463
+ const sessionFile = getSessionFilePath(name);
464
+
465
+ if (!fs.existsSync(sessionFile)) {
466
+ return [];
467
+ }
468
+
469
+ const exchanges: Exchange[] = [];
470
+ let currentUser: string | null = null;
471
+
472
+ try {
473
+ const content = fs.readFileSync(sessionFile, "utf-8");
474
+ const lines = content.trim().split("\n").filter(Boolean);
475
+
476
+ for (const line of lines) {
477
+ try {
478
+ const entry = JSON.parse(line);
479
+ if (entry.type !== "message") continue;
480
+
481
+ const msg = entry.message;
482
+ if (msg?.role === "user") {
483
+ // Extract text from user message
484
+ currentUser = extractTextFromContent(msg.content);
485
+ } else if (msg?.role === "assistant" && currentUser) {
486
+ // Extract text from assistant message
487
+ const assistantText = extractTextFromContent(msg.content);
488
+ if (assistantText) {
489
+ exchanges.push({ user: currentUser, assistant: assistantText });
490
+ currentUser = null;
491
+ }
492
+ }
493
+ } catch {}
494
+ }
495
+ } catch {}
496
+
497
+ // Return last N exchanges
498
+ return exchanges.slice(-count);
499
+ }
500
+
501
+ function extractTextFromContent(content: any): string {
502
+ if (typeof content === "string") return content;
503
+ if (Array.isArray(content)) {
504
+ return content
505
+ .filter((c: any) => c?.type === "text" && typeof c.text === "string")
506
+ .map((c: any) => c.text)
507
+ .join("\n\n");
508
+ }
509
+ return "";
510
+ }
511
+
512
+ /**
513
+ * Create a new session
514
+ */
515
+ export function createSession(name: string, model: string, thinking?: string): SessionInfo {
516
+ validateSessionName(name);
517
+
518
+ if (sessionExists(name)) {
519
+ throw new Error(`Session "${name}" already exists.`);
520
+ }
521
+
522
+ const { provider, modelId, fullModel } = resolveModel(model);
523
+
524
+ const info: SessionInfo = {
525
+ id: name,
526
+ model: fullModel,
527
+ provider,
528
+ modelId,
529
+ thinking,
530
+ tools: DEFAULT_TOOLS,
531
+ sessionFile: getSessionFilePath(name),
532
+ createdAt: Date.now(),
533
+ messageCount: 0,
534
+ status: "active",
535
+ };
536
+
537
+ saveSessionInfo(info);
538
+ return info;
539
+ }
540
+
541
+ /**
542
+ * Send a message to a session
543
+ */
544
+ export async function sendMessage(
545
+ sessionName: string,
546
+ message: string,
547
+ options: {
548
+ model?: string;
549
+ thinking?: string;
550
+ timeout?: number;
551
+ signal?: AbortSignal;
552
+ onProgress?: (status: ProgressStatus) => void;
553
+ } = {}
554
+ ): Promise<SendResult> {
555
+ validateSessionName(sessionName);
556
+
557
+ let info = loadSessionInfo(sessionName);
558
+
559
+ // Create session if it doesn't exist
560
+ if (!info) {
561
+ if (!options.model) {
562
+ throw new Error(`Session "${sessionName}" doesn't exist. Provide a model to create it.`);
563
+ }
564
+ info = createSession(sessionName, options.model, options.thinking);
565
+ }
566
+
567
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT;
568
+ const result = await sendToAgent(message, info, timeout, options.onProgress, options.signal);
569
+
570
+ // Update session info
571
+ info.lastActivity = Date.now();
572
+ info.messageCount = (info.messageCount ?? 0) + 1;
573
+ saveSessionInfo(info);
574
+
575
+ return result;
576
+ }
577
+
578
+ // =============================================================================
579
+ // Core: Send message to pi via RPC
580
+ // =============================================================================
581
+
582
+ function extractTextFromMessage(message: any): string {
583
+ if (!message) return "";
584
+ const content = message.content;
585
+ if (typeof content === "string") return content;
586
+ if (Array.isArray(content)) {
587
+ return content
588
+ .filter((c: any) => c?.type === "text" && typeof c.text === "string")
589
+ .map((c: any) => c.text)
590
+ .join("\n\n");
591
+ }
592
+ return "";
593
+ }
594
+
595
+ async function sendToAgent(
596
+ message: string,
597
+ info: SessionInfo,
598
+ timeout: number,
599
+ onProgress?: (status: ProgressStatus) => void,
600
+ signal?: AbortSignal,
601
+ ): Promise<SendResult> {
602
+ const piBin = process.env.PI_SPAR_PI_BIN || "pi";
603
+
604
+ const logger = new SessionLogger(info.id);
605
+ logger.info("session", "Starting sendToAgent", {
606
+ model: info.model,
607
+ timeout,
608
+ messageLength: message.length,
609
+ messagePreview: message.slice(0, 200) + (message.length > 200 ? "..." : ""),
610
+ });
611
+
612
+ const broadcaster = new EventBroadcaster(info.id);
613
+ broadcaster.start();
614
+
615
+ const startTime = Date.now();
616
+ const modelName = info.modelId;
617
+ const sessionId = info.id;
618
+
619
+ const updateProgress = (status: ProgressStatus) => {
620
+ status.elapsed = Math.floor((Date.now() - status.startTime) / 1000);
621
+ onProgress?.(status);
622
+ };
623
+
624
+ updateProgress({ model: modelName, sessionId, startTime, status: "thinking" });
625
+
626
+ const args = [
627
+ "--mode", "rpc",
628
+ "--no-extensions",
629
+ "--provider", info.provider,
630
+ "--model", info.modelId,
631
+ "--session", info.sessionFile,
632
+ ];
633
+
634
+ if (info.thinking) {
635
+ args.push("--thinking", info.thinking);
636
+ }
637
+
638
+ if (info.tools) {
639
+ args.push("--tools", info.tools);
640
+ }
641
+
642
+ logger.info("spawn", "Spawning pi process", { bin: piBin, args });
643
+
644
+ const proc = spawn(piBin, args, {
645
+ stdio: ["pipe", "pipe", "pipe"],
646
+ env: { ...process.env },
647
+ });
648
+
649
+ let stderr = "";
650
+ proc.stderr?.on("data", (data) => {
651
+ const chunk = data.toString();
652
+ stderr += chunk;
653
+ logger.stderr(chunk);
654
+ });
655
+
656
+ const rl = readline.createInterface({
657
+ input: proc.stdout!,
658
+ terminal: false,
659
+ });
660
+
661
+ let responseText = "";
662
+ let usage: SendResult["usage"];
663
+ let agentMessages: any[] = [];
664
+ let finished = false;
665
+
666
+ let reqId = 0;
667
+ const pending = new Map<string, { resolve: (data: any) => void; reject: (err: Error) => void }>();
668
+
669
+ function sendCommand(command: Record<string, unknown>): Promise<any> {
670
+ const id = `req-${++reqId}`;
671
+ const payload = JSON.stringify({ id, ...command });
672
+ proc.stdin!.write(payload + "\n");
673
+ return new Promise((resolve, reject) => {
674
+ pending.set(id, { resolve, reject });
675
+ });
676
+ }
677
+
678
+ let resolveDone!: () => void;
679
+ let rejectDone!: (err: Error) => void;
680
+ const donePromise = new Promise<void>((resolve, reject) => {
681
+ resolveDone = resolve;
682
+ rejectDone = reject;
683
+ });
684
+
685
+ // Handle abort signal (user pressed Escape)
686
+ if (signal) {
687
+ if (signal.aborted) {
688
+ proc.kill("SIGTERM");
689
+ broadcaster.stop();
690
+ logger.close();
691
+ throw new Error("Cancelled");
692
+ }
693
+ signal.addEventListener("abort", () => {
694
+ logger.info("abort", "Cancelled by user");
695
+ finished = true;
696
+ proc.kill("SIGTERM");
697
+ rejectDone(new Error("Cancelled"));
698
+ }, { once: true });
699
+ }
700
+
701
+ rl.on("line", (line) => {
702
+ let event: any;
703
+ try {
704
+ event = JSON.parse(line);
705
+ } catch {
706
+ logger.debug("parse", "Non-JSON line from pi", { line: line.slice(0, 200) });
707
+ return;
708
+ }
709
+
710
+ logger.rpcEvent(event);
711
+ broadcaster.broadcast(event);
712
+
713
+ if (event.type === "response") {
714
+ const waiter = event.id ? pending.get(event.id) : undefined;
715
+ if (event.id) pending.delete(event.id);
716
+
717
+ if (waiter) {
718
+ if (!event.success) {
719
+ const err = event.error || "Unknown error";
720
+ logger.error("rpc-response", `Command failed: ${err}`, { id: event.id });
721
+ waiter.reject(new Error(err));
722
+ } else {
723
+ waiter.resolve(event.data);
724
+ }
725
+ } else if (!event.success) {
726
+ const err = event.error || "Unknown error";
727
+ logger.error("rpc-response", `Untracked error response: ${err}`, event);
728
+ rejectDone(new Error(err));
729
+ }
730
+ return;
731
+ }
732
+
733
+ if (event.type === "message_update") {
734
+ const delta = event.assistantMessageEvent;
735
+ if (delta?.type === "text_delta") {
736
+ responseText += delta.delta;
737
+ resetTimeout("text_delta");
738
+ updateProgress({ model: modelName, sessionId, startTime, status: "streaming" });
739
+ }
740
+ if (delta?.type === "thinking_delta") {
741
+ resetTimeout("thinking_delta");
742
+ updateProgress({ model: modelName, sessionId, startTime, status: "thinking" });
743
+ }
744
+ if (delta?.type === "error") {
745
+ const err = delta.reason ?? "Streaming error";
746
+ logger.error("streaming", `Streaming error: ${err}`, delta);
747
+ rejectDone(new Error(err));
748
+ }
749
+ return;
750
+ }
751
+
752
+ if (event.type === "message_end") {
753
+ const msg = event.message;
754
+ if (msg?.errorMessage) {
755
+ logger.error("message-end", `Message error: ${msg.errorMessage}`, msg);
756
+ rejectDone(new Error(msg.errorMessage));
757
+ }
758
+ return;
759
+ }
760
+
761
+ if (event.type === "tool_execution_start") {
762
+ resetTimeout(`tool_start:${event.toolName}`);
763
+ logger.info("tool", `Tool started: ${event.toolName}`, { args: event.args });
764
+ updateProgress({
765
+ model: modelName,
766
+ sessionId,
767
+ startTime,
768
+ status: "tool",
769
+ toolName: event.toolName,
770
+ toolArgs: JSON.stringify(event.args || {}).slice(0, 100)
771
+ });
772
+ return;
773
+ }
774
+ if (event.type === "tool_execution_update" || event.type === "tool_execution_end") {
775
+ resetTimeout(`tool_${event.type}`);
776
+ return;
777
+ }
778
+
779
+ if (event.type === "agent_end") {
780
+ agentMessages = event.messages || [];
781
+
782
+ usage = { input: 0, output: 0, cost: 0 };
783
+ for (const msg of agentMessages) {
784
+ if (msg.role === "assistant" && msg.usage) {
785
+ usage.input += msg.usage.input || 0;
786
+ usage.output += msg.usage.output || 0;
787
+ usage.cost += msg.usage.cost?.total || 0;
788
+ }
789
+ }
790
+
791
+ if (responseText.trim() === "") {
792
+ const lastAssistant = [...agentMessages].reverse().find((m: any) => m?.role === "assistant");
793
+ responseText = extractTextFromMessage(lastAssistant);
794
+ }
795
+
796
+ logger.info("complete", "Agent completed", {
797
+ usage,
798
+ responseLength: responseText.length,
799
+ messageCount: agentMessages.length,
800
+ });
801
+ finished = true;
802
+ resolveDone();
803
+ return;
804
+ }
805
+
806
+ if (event.type === "hook_error") {
807
+ const err = `Hook error: ${event.error || "Unknown"}`;
808
+ logger.error("hook", err, event);
809
+ rejectDone(new Error(err));
810
+ }
811
+ });
812
+
813
+ proc.on("exit", (code, signal) => {
814
+ if (!finished) {
815
+ const err = `pi process exited unexpectedly (code=${code}, signal=${signal})`;
816
+ logger.error("exit", err, { code, signal, stderr });
817
+ rejectDone(new Error(`${err}\nStderr: ${stderr}`));
818
+ }
819
+ });
820
+
821
+ let timeoutHandle: ReturnType<typeof setTimeout>;
822
+ let timeoutReject: (err: Error) => void;
823
+ const timeoutPromise = new Promise<never>((_, reject) => {
824
+ timeoutReject = reject;
825
+ timeoutHandle = setTimeout(() => {
826
+ const err = `Timeout after ${timeout}ms waiting for response`;
827
+ logger.error("timeout", err, { stderr, elapsed: Date.now() - startTime });
828
+ reject(new Error(`${err}\nStderr: ${stderr}`));
829
+ }, timeout);
830
+ });
831
+
832
+ let lastResetAt = startTime;
833
+ let resetCount = 0;
834
+
835
+ function resetTimeout(reason: string) {
836
+ clearTimeout(timeoutHandle);
837
+ resetCount++;
838
+ const now = Date.now();
839
+ lastResetAt = now;
840
+ logger.debug("timeout-reset", `Reset #${resetCount}: ${reason}`, {
841
+ elapsed: now - startTime,
842
+ });
843
+ timeoutHandle = setTimeout(() => {
844
+ const err = `Timeout after ${timeout}ms of inactivity`;
845
+ logger.error("timeout", err, {
846
+ stderr,
847
+ elapsed: Date.now() - startTime,
848
+ resetCount,
849
+ });
850
+ timeoutReject(new Error(`${err}\nStderr: ${stderr}`));
851
+ }, timeout);
852
+ }
853
+
854
+ try {
855
+ await Promise.race([sendCommand({ type: "get_state" }), timeoutPromise]);
856
+ await Promise.race([sendCommand({ type: "prompt", message }), timeoutPromise]);
857
+ await Promise.race([donePromise, timeoutPromise]);
858
+
859
+ logger.info("session", "sendToAgent completed successfully", { elapsed: Date.now() - startTime });
860
+ return {
861
+ response: responseText.trim(),
862
+ usage,
863
+ };
864
+ } catch (err: any) {
865
+ logger.error("session", `sendToAgent failed: ${err.message}`, {
866
+ elapsed: Date.now() - startTime,
867
+ stderr,
868
+ });
869
+ markSessionFailed(info.id, err.message);
870
+ throw err;
871
+ } finally {
872
+ clearTimeout(timeoutHandle!);
873
+ broadcaster.stop();
874
+ logger.close();
875
+ rl.close();
876
+ proc.stdin?.end();
877
+ proc.kill("SIGTERM");
878
+ }
879
+ }