@orgloop/agentctl 1.0.1 → 1.2.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,855 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import { watch } from "node:fs";
4
+ import * as fs from "node:fs/promises";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+ import { promisify } from "node:util";
8
+ const execFileAsync = promisify(execFile);
9
+ const DEFAULT_PI_DIR = path.join(os.homedir(), ".pi");
10
+ // Default: only show stopped sessions from the last 7 days
11
+ const STOPPED_SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
12
+ /**
13
+ * Pi adapter — reads session data from ~/.pi/agent/sessions/
14
+ * and cross-references with running PIDs. NEVER maintains its own registry.
15
+ *
16
+ * Pi stores sessions as JSONL files in ~/.pi/agent/sessions/<cwd-slug>/<timestamp>_<id>.jsonl
17
+ * Each file starts with a type:'session' header line containing metadata.
18
+ */
19
+ export class PiAdapter {
20
+ id = "pi";
21
+ piDir;
22
+ sessionsDir;
23
+ sessionsMetaDir;
24
+ getPids;
25
+ isProcessAlive;
26
+ constructor(opts) {
27
+ this.piDir = opts?.piDir || DEFAULT_PI_DIR;
28
+ this.sessionsDir = path.join(this.piDir, "agent", "sessions");
29
+ this.sessionsMetaDir =
30
+ opts?.sessionsMetaDir || path.join(this.piDir, "agentctl", "sessions");
31
+ this.getPids = opts?.getPids || getPiPids;
32
+ this.isProcessAlive = opts?.isProcessAlive || defaultIsProcessAlive;
33
+ }
34
+ async list(opts) {
35
+ const runningPids = await this.getPids();
36
+ const discovered = await this.discoverSessions();
37
+ const sessions = [];
38
+ for (const disc of discovered) {
39
+ const session = await this.buildSession(disc, runningPids);
40
+ // Filter by status
41
+ if (opts?.status && session.status !== opts.status)
42
+ continue;
43
+ // If not --all, skip old stopped sessions
44
+ if (!opts?.all && session.status === "stopped") {
45
+ const age = Date.now() - session.startedAt.getTime();
46
+ if (age > STOPPED_SESSION_MAX_AGE_MS)
47
+ continue;
48
+ }
49
+ // Default: only show running sessions unless --all
50
+ if (!opts?.all &&
51
+ !opts?.status &&
52
+ session.status !== "running" &&
53
+ session.status !== "idle") {
54
+ continue;
55
+ }
56
+ sessions.push(session);
57
+ }
58
+ // Sort: running first, then by most recent
59
+ sessions.sort((a, b) => {
60
+ if (a.status === "running" && b.status !== "running")
61
+ return -1;
62
+ if (b.status === "running" && a.status !== "running")
63
+ return 1;
64
+ return b.startedAt.getTime() - a.startedAt.getTime();
65
+ });
66
+ return sessions;
67
+ }
68
+ async peek(sessionId, opts) {
69
+ const lines = opts?.lines ?? 20;
70
+ const disc = await this.findSession(sessionId);
71
+ // Fallback for pending-* sessions: read from the launch log file
72
+ if (!disc) {
73
+ const meta = await this.readSessionMeta(sessionId);
74
+ if (meta?.sessionId) {
75
+ // Try to find the session by the metadata's session ID
76
+ const resolved = await this.findSession(meta.sessionId);
77
+ if (resolved) {
78
+ return this.peekFromJsonl(resolved.filePath, lines);
79
+ }
80
+ }
81
+ const logPath = await this.getLogPathForSession(sessionId);
82
+ if (logPath) {
83
+ try {
84
+ const content = await fs.readFile(logPath, "utf-8");
85
+ const logLines = content.trim().split("\n");
86
+ return logLines.slice(-lines).join("\n") || "(no output)";
87
+ }
88
+ catch {
89
+ // log file unreadable
90
+ }
91
+ }
92
+ throw new Error(`Session not found: ${sessionId}`);
93
+ }
94
+ return this.peekFromJsonl(disc.filePath, lines);
95
+ }
96
+ /** Extract assistant messages from a JSONL session file */
97
+ async peekFromJsonl(filePath, lines) {
98
+ const content = await fs.readFile(filePath, "utf-8");
99
+ const jsonlLines = content.trim().split("\n");
100
+ const assistantMessages = [];
101
+ for (const line of jsonlLines) {
102
+ try {
103
+ const entry = JSON.parse(line);
104
+ if (entry.type === "message") {
105
+ const msg = entry;
106
+ const payload = msg.message;
107
+ if (payload?.role === "assistant" && payload.content) {
108
+ const text = extractContent(payload.content);
109
+ if (text)
110
+ assistantMessages.push(text);
111
+ }
112
+ }
113
+ }
114
+ catch {
115
+ // skip malformed lines
116
+ }
117
+ }
118
+ // Take last N messages
119
+ const recent = assistantMessages.slice(-lines);
120
+ return recent.join("\n---\n");
121
+ }
122
+ /** Get the log file path for a pending session from metadata */
123
+ async getLogPathForSession(sessionId) {
124
+ const meta = await this.readSessionMeta(sessionId);
125
+ if (!meta)
126
+ return null;
127
+ // The log path is stored in the launch metadata directory
128
+ const logPath = path.join(this.sessionsMetaDir, `launch-${new Date(meta.launchedAt).getTime()}.log`);
129
+ try {
130
+ await fs.access(logPath);
131
+ return logPath;
132
+ }
133
+ catch {
134
+ // Also scan for log files near the launch time
135
+ try {
136
+ const files = await fs.readdir(this.sessionsMetaDir);
137
+ const launchMs = new Date(meta.launchedAt).getTime();
138
+ for (const file of files) {
139
+ if (!file.startsWith("launch-") || !file.endsWith(".log"))
140
+ continue;
141
+ const tsStr = file.replace("launch-", "").replace(".log", "");
142
+ const ts = Number(tsStr);
143
+ if (!Number.isNaN(ts) && Math.abs(ts - launchMs) < 2000) {
144
+ return path.join(this.sessionsMetaDir, file);
145
+ }
146
+ }
147
+ }
148
+ catch {
149
+ // dir doesn't exist
150
+ }
151
+ }
152
+ return null;
153
+ }
154
+ async status(sessionId) {
155
+ const runningPids = await this.getPids();
156
+ const disc = await this.findSession(sessionId);
157
+ if (!disc)
158
+ throw new Error(`Session not found: ${sessionId}`);
159
+ return this.buildSession(disc, runningPids);
160
+ }
161
+ async launch(opts) {
162
+ const args = ["-p", opts.prompt, "--mode", "json"];
163
+ if (opts.model) {
164
+ args.unshift("--model", opts.model);
165
+ }
166
+ const env = { ...process.env, ...opts.env };
167
+ const cwd = opts.cwd || process.cwd();
168
+ // Write stdout to a log file so we can extract the session ID
169
+ await fs.mkdir(this.sessionsMetaDir, { recursive: true });
170
+ const logPath = path.join(this.sessionsMetaDir, `launch-${Date.now()}.log`);
171
+ const logFd = await fs.open(logPath, "w");
172
+ const child = spawn("pi", args, {
173
+ cwd,
174
+ env,
175
+ stdio: ["ignore", logFd.fd, logFd.fd],
176
+ detached: true,
177
+ });
178
+ // Fully detach: child runs in its own process group.
179
+ child.unref();
180
+ const pid = child.pid;
181
+ const now = new Date();
182
+ // Close our handle — child keeps its own fd open
183
+ await logFd.close();
184
+ // Try to extract the session ID from the JSON-mode stdout output.
185
+ // Pi's first JSON line has type: "session" with an id field.
186
+ let resolvedSessionId;
187
+ if (pid) {
188
+ resolvedSessionId = await this.pollForSessionId(logPath, pid, 5000);
189
+ // Fallback: scan session files directory for a newly created file
190
+ if (!resolvedSessionId) {
191
+ resolvedSessionId = await this.pollSessionDir(cwd, now, pid, 3000);
192
+ }
193
+ }
194
+ const sessionId = resolvedSessionId || (pid ? `pending-${pid}` : crypto.randomUUID());
195
+ // Persist session metadata so status checks work after wrapper exits
196
+ if (pid) {
197
+ await this.writeSessionMeta({
198
+ sessionId,
199
+ pid,
200
+ wrapperPid: process.pid,
201
+ cwd,
202
+ model: opts.model,
203
+ prompt: opts.prompt.slice(0, 200),
204
+ launchedAt: now.toISOString(),
205
+ });
206
+ }
207
+ const session = {
208
+ id: sessionId,
209
+ adapter: this.id,
210
+ status: "running",
211
+ startedAt: now,
212
+ cwd,
213
+ model: opts.model,
214
+ prompt: opts.prompt.slice(0, 200),
215
+ pid,
216
+ meta: {
217
+ adapterOpts: opts.adapterOpts,
218
+ spec: opts.spec,
219
+ logPath,
220
+ },
221
+ };
222
+ return session;
223
+ }
224
+ /**
225
+ * Poll the launch log file for up to `timeoutMs` to extract the real session ID.
226
+ * Pi's JSONL output includes a session header with type: "session" and id field.
227
+ */
228
+ async pollForSessionId(logPath, pid, timeoutMs) {
229
+ const deadline = Date.now() + timeoutMs;
230
+ while (Date.now() < deadline) {
231
+ try {
232
+ const content = await fs.readFile(logPath, "utf-8");
233
+ for (const line of content.split("\n")) {
234
+ if (!line.trim())
235
+ continue;
236
+ try {
237
+ const msg = JSON.parse(line);
238
+ // Pi session header: type "session" with id
239
+ if (msg.type === "session" &&
240
+ msg.id &&
241
+ typeof msg.id === "string") {
242
+ return msg.id;
243
+ }
244
+ }
245
+ catch {
246
+ // Not valid JSON yet
247
+ }
248
+ }
249
+ }
250
+ catch {
251
+ // File may not exist yet
252
+ }
253
+ // Check if process is still alive
254
+ try {
255
+ process.kill(pid, 0);
256
+ }
257
+ catch {
258
+ break; // Process died
259
+ }
260
+ await sleep(200);
261
+ }
262
+ return undefined;
263
+ }
264
+ /**
265
+ * Fallback: poll the Pi sessions directory for a new JSONL file
266
+ * created after the launch time, matching the cwd.
267
+ */
268
+ async pollSessionDir(cwd, launchTime, pid, timeoutMs) {
269
+ // Resolve symlinks (macOS: /tmp → /private/tmp) so slug matches Pi's resolved path
270
+ let resolvedCwd = cwd;
271
+ try {
272
+ resolvedCwd = await fs.realpath(cwd);
273
+ }
274
+ catch {
275
+ // Path might not exist yet — use original
276
+ }
277
+ // Pi uses a cwd-slug directory: cwd with / replaced by - and surrounded by --
278
+ const cwdSlug = `--${resolvedCwd.replace(/\//g, "-")}--`;
279
+ const slugDir = path.join(this.sessionsDir, cwdSlug);
280
+ const deadline = Date.now() + timeoutMs;
281
+ while (Date.now() < deadline) {
282
+ try {
283
+ const files = await fs.readdir(slugDir);
284
+ for (const file of files) {
285
+ if (!file.endsWith(".jsonl"))
286
+ continue;
287
+ const filePath = path.join(slugDir, file);
288
+ const stat = await fs.stat(filePath);
289
+ // Only consider files created after (or very near) launch time
290
+ if (stat.birthtimeMs >= launchTime.getTime() - 2000) {
291
+ const header = await this.parseSessionHeader(filePath);
292
+ if (header?.id)
293
+ return header.id;
294
+ }
295
+ }
296
+ }
297
+ catch {
298
+ // Dir may not exist yet
299
+ }
300
+ try {
301
+ process.kill(pid, 0);
302
+ }
303
+ catch {
304
+ break;
305
+ }
306
+ await sleep(200);
307
+ }
308
+ return undefined;
309
+ }
310
+ async stop(sessionId, opts) {
311
+ const pid = await this.findPidForSession(sessionId);
312
+ if (!pid)
313
+ throw new Error(`No running process for session: ${sessionId}`);
314
+ if (opts?.force) {
315
+ // SIGINT first, then SIGKILL after 5s
316
+ process.kill(pid, "SIGINT");
317
+ await sleep(5000);
318
+ try {
319
+ process.kill(pid, "SIGKILL");
320
+ }
321
+ catch {
322
+ // Already dead — good
323
+ }
324
+ }
325
+ else {
326
+ process.kill(pid, "SIGTERM");
327
+ }
328
+ }
329
+ async resume(sessionId, message) {
330
+ // Pi doesn't have a native --continue flag.
331
+ // Launch a new pi session in the same cwd with the continuation message.
332
+ const disc = await this.findSession(sessionId);
333
+ const cwd = disc?.header.cwd || process.cwd();
334
+ const child = spawn("pi", ["-p", message], {
335
+ cwd,
336
+ stdio: ["pipe", "pipe", "pipe"],
337
+ detached: true,
338
+ });
339
+ child.unref();
340
+ }
341
+ async *events() {
342
+ // Track known sessions to detect transitions
343
+ let knownSessions = new Map();
344
+ // Initial snapshot
345
+ const initial = await this.list({ all: true });
346
+ for (const s of initial) {
347
+ knownSessions.set(s.id, s);
348
+ }
349
+ // Poll + fs.watch hybrid
350
+ const watcher = watch(this.sessionsDir, { recursive: true });
351
+ try {
352
+ while (true) {
353
+ await sleep(5000);
354
+ const current = await this.list({ all: true });
355
+ const currentMap = new Map(current.map((s) => [s.id, s]));
356
+ // Detect new sessions
357
+ for (const [id, session] of currentMap) {
358
+ const prev = knownSessions.get(id);
359
+ if (!prev) {
360
+ yield {
361
+ type: "session.started",
362
+ adapter: this.id,
363
+ sessionId: id,
364
+ session,
365
+ timestamp: new Date(),
366
+ };
367
+ }
368
+ else if (prev.status === "running" &&
369
+ session.status === "stopped") {
370
+ yield {
371
+ type: "session.stopped",
372
+ adapter: this.id,
373
+ sessionId: id,
374
+ session,
375
+ timestamp: new Date(),
376
+ };
377
+ }
378
+ else if (prev.status === "running" && session.status === "idle") {
379
+ yield {
380
+ type: "session.idle",
381
+ adapter: this.id,
382
+ sessionId: id,
383
+ session,
384
+ timestamp: new Date(),
385
+ };
386
+ }
387
+ }
388
+ knownSessions = currentMap;
389
+ }
390
+ }
391
+ finally {
392
+ watcher.close();
393
+ }
394
+ }
395
+ // --- Private helpers ---
396
+ /**
397
+ * Scan ~/.pi/agent/sessions/ recursively for .jsonl files and parse headers.
398
+ * Pi stores sessions at <sessionsDir>/<cwd-slug>/<timestamp>_<id>.jsonl
399
+ */
400
+ async discoverSessions() {
401
+ const results = [];
402
+ let cwdSlugs;
403
+ try {
404
+ cwdSlugs = await fs.readdir(this.sessionsDir);
405
+ }
406
+ catch {
407
+ return [];
408
+ }
409
+ for (const slug of cwdSlugs) {
410
+ const slugDir = path.join(this.sessionsDir, slug);
411
+ const stat = await fs.stat(slugDir).catch(() => null);
412
+ if (!stat?.isDirectory())
413
+ continue;
414
+ let files;
415
+ try {
416
+ files = await fs.readdir(slugDir);
417
+ }
418
+ catch {
419
+ continue;
420
+ }
421
+ for (const file of files) {
422
+ if (!file.endsWith(".jsonl"))
423
+ continue;
424
+ const filePath = path.join(slugDir, file);
425
+ let fileStat;
426
+ try {
427
+ fileStat = await fs.stat(filePath);
428
+ }
429
+ catch {
430
+ continue;
431
+ }
432
+ // Parse session header from first lines
433
+ const header = await this.parseSessionHeader(filePath);
434
+ if (!header)
435
+ continue;
436
+ // Extract session ID from header or filename
437
+ // Filename format: <timestamp>_<id>.jsonl
438
+ const sessionId = header.id || this.extractSessionIdFromFilename(file);
439
+ if (!sessionId)
440
+ continue;
441
+ results.push({
442
+ sessionId,
443
+ filePath,
444
+ header: { ...header, id: sessionId },
445
+ created: fileStat.birthtime,
446
+ modified: fileStat.mtime,
447
+ cwdSlug: slug,
448
+ });
449
+ }
450
+ }
451
+ return results;
452
+ }
453
+ /** Extract session ID from filename format: <timestamp>_<id>.jsonl */
454
+ extractSessionIdFromFilename(filename) {
455
+ const base = filename.replace(".jsonl", "");
456
+ const underscoreIdx = base.indexOf("_");
457
+ if (underscoreIdx >= 0) {
458
+ return base.slice(underscoreIdx + 1);
459
+ }
460
+ return base; // fallback: use entire filename as ID
461
+ }
462
+ /** Parse the session header (type:'session') from the first few lines of a JSONL file */
463
+ async parseSessionHeader(filePath) {
464
+ try {
465
+ const content = await fs.readFile(filePath, "utf-8");
466
+ for (const line of content.split("\n").slice(0, 10)) {
467
+ if (!line.trim())
468
+ continue;
469
+ try {
470
+ const entry = JSON.parse(line);
471
+ if (entry.type === "session") {
472
+ return entry;
473
+ }
474
+ }
475
+ catch {
476
+ // skip malformed line
477
+ }
478
+ }
479
+ }
480
+ catch {
481
+ // file unreadable
482
+ }
483
+ return null;
484
+ }
485
+ async buildSession(disc, runningPids) {
486
+ const isRunning = await this.isSessionRunning(disc, runningPids);
487
+ const { model, tokens, cost } = await this.parseSessionTail(disc.filePath, disc.header);
488
+ return {
489
+ id: disc.sessionId,
490
+ adapter: this.id,
491
+ status: isRunning ? "running" : "stopped",
492
+ startedAt: disc.created,
493
+ stoppedAt: isRunning ? undefined : disc.modified,
494
+ cwd: disc.header.cwd,
495
+ model,
496
+ prompt: await this.getFirstPrompt(disc.filePath),
497
+ tokens,
498
+ cost,
499
+ pid: isRunning
500
+ ? await this.findMatchingPid(disc, runningPids)
501
+ : undefined,
502
+ meta: {
503
+ provider: disc.header.provider,
504
+ thinkingLevel: disc.header.thinkingLevel,
505
+ version: disc.header.version,
506
+ cwdSlug: disc.cwdSlug,
507
+ },
508
+ };
509
+ }
510
+ async isSessionRunning(disc, runningPids) {
511
+ const sessionCwd = disc.header.cwd;
512
+ if (!sessionCwd)
513
+ return false;
514
+ const sessionCreated = disc.created.getTime();
515
+ // 1. Check running PIDs discovered via `ps aux`
516
+ for (const [, info] of runningPids) {
517
+ // Check if the session ID appears in the command args — most reliable match
518
+ if (info.args.includes(disc.sessionId)) {
519
+ if (this.processStartedAfterSession(info, sessionCreated))
520
+ return true;
521
+ // PID recycling: process started before this session existed
522
+ continue;
523
+ }
524
+ // Match by cwd — less specific (multiple sessions share a project)
525
+ if (info.cwd === sessionCwd) {
526
+ if (this.processStartedAfterSession(info, sessionCreated))
527
+ return true;
528
+ }
529
+ }
530
+ // 2. Check persisted session metadata (for detached processes that
531
+ // may not appear in `ps aux` filtering, e.g. after wrapper exit)
532
+ const meta = await this.readSessionMeta(disc.sessionId);
533
+ if (meta?.pid) {
534
+ // Verify the persisted PID is still alive
535
+ if (this.isProcessAlive(meta.pid)) {
536
+ // Cross-check: if this PID appears in runningPids with a DIFFERENT
537
+ // start time than what we recorded, the PID was recycled.
538
+ const pidInfo = runningPids.get(meta.pid);
539
+ if (pidInfo?.startTime && meta.startTime) {
540
+ const currentStartMs = new Date(pidInfo.startTime).getTime();
541
+ const recordedStartMs = new Date(meta.startTime).getTime();
542
+ if (!Number.isNaN(currentStartMs) &&
543
+ !Number.isNaN(recordedStartMs) &&
544
+ Math.abs(currentStartMs - recordedStartMs) > 5000) {
545
+ // Process at this PID has a different start time — recycled
546
+ await this.deleteSessionMeta(disc.sessionId);
547
+ return false;
548
+ }
549
+ }
550
+ // Verify stored start time is consistent with launch time
551
+ if (meta.startTime) {
552
+ const metaStartMs = new Date(meta.startTime).getTime();
553
+ const sessionMs = new Date(meta.launchedAt).getTime();
554
+ if (!Number.isNaN(metaStartMs) && metaStartMs >= sessionMs - 5000) {
555
+ return true;
556
+ }
557
+ // Start time doesn't match — PID was recycled, clean up stale metadata
558
+ await this.deleteSessionMeta(disc.sessionId);
559
+ return false;
560
+ }
561
+ // No start time in metadata — can't verify, assume alive
562
+ return true;
563
+ }
564
+ // PID is dead — clean up stale metadata
565
+ await this.deleteSessionMeta(disc.sessionId);
566
+ }
567
+ // 3. Fallback: check if JSONL was modified very recently (last 60s)
568
+ try {
569
+ const stat = await fs.stat(disc.filePath);
570
+ const age = Date.now() - stat.mtimeMs;
571
+ if (age < 60_000) {
572
+ for (const [, info] of runningPids) {
573
+ if (info.cwd === sessionCwd &&
574
+ this.processStartedAfterSession(info, sessionCreated)) {
575
+ return true;
576
+ }
577
+ }
578
+ }
579
+ }
580
+ catch {
581
+ // file doesn't exist
582
+ }
583
+ return false;
584
+ }
585
+ /**
586
+ * Check whether a process plausibly belongs to a session by verifying
587
+ * the process started at or after the session's creation time.
588
+ * When start time is unavailable, defaults to false (assume no match).
589
+ */
590
+ processStartedAfterSession(info, sessionCreatedMs) {
591
+ if (!info.startTime)
592
+ return false;
593
+ const processStartMs = new Date(info.startTime).getTime();
594
+ if (Number.isNaN(processStartMs))
595
+ return false;
596
+ // Allow 5s tolerance for clock skew
597
+ return processStartMs >= sessionCreatedMs - 5000;
598
+ }
599
+ async findMatchingPid(disc, runningPids) {
600
+ const sessionCwd = disc.header.cwd;
601
+ const sessionCreated = disc.created.getTime();
602
+ for (const [pid, info] of runningPids) {
603
+ if (info.args.includes(disc.sessionId)) {
604
+ if (this.processStartedAfterSession(info, sessionCreated))
605
+ return pid;
606
+ continue;
607
+ }
608
+ if (info.cwd === sessionCwd) {
609
+ if (this.processStartedAfterSession(info, sessionCreated))
610
+ return pid;
611
+ }
612
+ }
613
+ // Check persisted metadata for detached processes
614
+ const meta = await this.readSessionMeta(disc.sessionId);
615
+ if (meta?.pid && this.isProcessAlive(meta.pid)) {
616
+ return meta.pid;
617
+ }
618
+ return undefined;
619
+ }
620
+ /** Parse session tail for model, tokens, and cost aggregation */
621
+ async parseSessionTail(filePath, header) {
622
+ try {
623
+ const content = await fs.readFile(filePath, "utf-8");
624
+ const lines = content.trim().split("\n");
625
+ let model = header.modelId;
626
+ let totalIn = 0;
627
+ let totalOut = 0;
628
+ let totalCost = 0;
629
+ for (const line of lines) {
630
+ try {
631
+ const entry = JSON.parse(line);
632
+ if (entry.type === "message") {
633
+ const msg = entry;
634
+ const payload = msg.message;
635
+ if (payload?.role === "assistant" && payload.usage) {
636
+ totalIn += payload.usage.input || 0;
637
+ totalOut += payload.usage.output || 0;
638
+ // Pi cost can be a number or an object with a total field
639
+ const rawCost = payload.usage.cost;
640
+ if (typeof rawCost === "number") {
641
+ totalCost += rawCost;
642
+ }
643
+ else if (rawCost && typeof rawCost === "object") {
644
+ totalCost += rawCost.total || 0;
645
+ }
646
+ }
647
+ }
648
+ else if (entry.type === "model_change") {
649
+ model = entry.modelId;
650
+ }
651
+ }
652
+ catch {
653
+ // skip
654
+ }
655
+ }
656
+ return {
657
+ model,
658
+ tokens: totalIn || totalOut ? { in: totalIn, out: totalOut } : undefined,
659
+ cost: totalCost || undefined,
660
+ };
661
+ }
662
+ catch {
663
+ return { model: header.modelId };
664
+ }
665
+ }
666
+ /** Get the first user prompt from a session JSONL file */
667
+ async getFirstPrompt(filePath) {
668
+ try {
669
+ const content = await fs.readFile(filePath, "utf-8");
670
+ for (const line of content.split("\n").slice(0, 20)) {
671
+ try {
672
+ const entry = JSON.parse(line);
673
+ if (entry.type === "message") {
674
+ const msg = entry;
675
+ const payload = msg.message;
676
+ if (payload?.role === "user" && payload.content) {
677
+ const text = extractContent(payload.content);
678
+ return text?.slice(0, 200);
679
+ }
680
+ }
681
+ }
682
+ catch {
683
+ // skip
684
+ }
685
+ }
686
+ }
687
+ catch {
688
+ // skip
689
+ }
690
+ return undefined;
691
+ }
692
+ /** Find a session by exact or prefix ID match */
693
+ async findSession(sessionId) {
694
+ const all = await this.discoverSessions();
695
+ return (all.find((d) => d.sessionId === sessionId || d.sessionId.startsWith(sessionId)) || null);
696
+ }
697
+ async findPidForSession(sessionId) {
698
+ const session = await this.status(sessionId);
699
+ return session.pid ?? null;
700
+ }
701
+ // --- Session metadata persistence ---
702
+ /** Write session metadata to disk so status checks survive wrapper exit */
703
+ async writeSessionMeta(meta) {
704
+ await fs.mkdir(this.sessionsMetaDir, { recursive: true });
705
+ // Try to capture the process start time immediately
706
+ let startTime;
707
+ try {
708
+ const { stdout } = await execFileAsync("ps", [
709
+ "-p",
710
+ meta.pid.toString(),
711
+ "-o",
712
+ "lstart=",
713
+ ]);
714
+ startTime = stdout.trim() || undefined;
715
+ }
716
+ catch {
717
+ // Process may have already exited or ps failed
718
+ }
719
+ const fullMeta = { ...meta, startTime };
720
+ const metaPath = path.join(this.sessionsMetaDir, `${meta.sessionId}.json`);
721
+ await fs.writeFile(metaPath, JSON.stringify(fullMeta, null, 2));
722
+ }
723
+ /** Read persisted session metadata */
724
+ async readSessionMeta(sessionId) {
725
+ // Check exact sessionId first
726
+ const metaPath = path.join(this.sessionsMetaDir, `${sessionId}.json`);
727
+ try {
728
+ const raw = await fs.readFile(metaPath, "utf-8");
729
+ return JSON.parse(raw);
730
+ }
731
+ catch {
732
+ // File doesn't exist or is unreadable
733
+ }
734
+ // Scan all metadata files for one whose sessionId matches
735
+ try {
736
+ const files = await fs.readdir(this.sessionsMetaDir);
737
+ for (const file of files) {
738
+ if (!file.endsWith(".json"))
739
+ continue;
740
+ try {
741
+ const raw = await fs.readFile(path.join(this.sessionsMetaDir, file), "utf-8");
742
+ const m = JSON.parse(raw);
743
+ if (m.sessionId === sessionId)
744
+ return m;
745
+ }
746
+ catch {
747
+ // skip
748
+ }
749
+ }
750
+ }
751
+ catch {
752
+ // Dir doesn't exist
753
+ }
754
+ return null;
755
+ }
756
+ /** Delete stale session metadata */
757
+ async deleteSessionMeta(sessionId) {
758
+ for (const id of [sessionId, `pending-${sessionId}`]) {
759
+ const metaPath = path.join(this.sessionsMetaDir, `${id}.json`);
760
+ try {
761
+ await fs.unlink(metaPath);
762
+ }
763
+ catch {
764
+ // File doesn't exist
765
+ }
766
+ }
767
+ }
768
+ }
769
+ // --- Utility functions ---
770
+ /** Check if a process is alive via kill(pid, 0) signal check */
771
+ function defaultIsProcessAlive(pid) {
772
+ try {
773
+ process.kill(pid, 0);
774
+ return true;
775
+ }
776
+ catch {
777
+ return false;
778
+ }
779
+ }
780
+ /** Discover running pi processes via `ps aux` */
781
+ async function getPiPids() {
782
+ const pids = new Map();
783
+ try {
784
+ const { stdout } = await execFileAsync("ps", ["aux"]);
785
+ for (const line of stdout.split("\n")) {
786
+ if (line.includes("grep"))
787
+ continue;
788
+ // Extract PID (second field) and command (everything after 10th field)
789
+ const fields = line.trim().split(/\s+/);
790
+ if (fields.length < 11)
791
+ continue;
792
+ const pid = parseInt(fields[1], 10);
793
+ const command = fields.slice(10).join(" ");
794
+ // Match 'pi' command invocations with flags (e.g. "pi -p", "pi --json")
795
+ // Avoid matching other commands that happen to contain "pi"
796
+ if (!command.startsWith("pi -") && !command.startsWith("pi --"))
797
+ continue;
798
+ if (pid === process.pid)
799
+ continue;
800
+ // Try to extract working directory from lsof
801
+ let cwd = "";
802
+ try {
803
+ const { stdout: lsofOut } = await execFileAsync("/usr/sbin/lsof", [
804
+ "-p",
805
+ pid.toString(),
806
+ "-Fn",
807
+ ]);
808
+ const lsofLines = lsofOut.split("\n");
809
+ for (let i = 0; i < lsofLines.length; i++) {
810
+ if (lsofLines[i] === "fcwd" && lsofLines[i + 1]?.startsWith("n")) {
811
+ cwd = lsofLines[i + 1].slice(1);
812
+ break;
813
+ }
814
+ }
815
+ }
816
+ catch {
817
+ // lsof might fail — that's fine
818
+ }
819
+ // Get process start time for PID recycling detection
820
+ let startTime;
821
+ try {
822
+ const { stdout: lstart } = await execFileAsync("ps", [
823
+ "-p",
824
+ pid.toString(),
825
+ "-o",
826
+ "lstart=",
827
+ ]);
828
+ startTime = lstart.trim() || undefined;
829
+ }
830
+ catch {
831
+ // ps might fail — that's fine
832
+ }
833
+ pids.set(pid, { pid, cwd, args: command, startTime });
834
+ }
835
+ }
836
+ catch {
837
+ // ps failed — return empty
838
+ }
839
+ return pids;
840
+ }
841
+ /** Extract text content from Pi message content field */
842
+ function extractContent(content) {
843
+ if (typeof content === "string")
844
+ return content;
845
+ if (Array.isArray(content)) {
846
+ return content
847
+ .filter((b) => b.type === "text" && b.text)
848
+ .map((b) => b.text)
849
+ .join("\n");
850
+ }
851
+ return "";
852
+ }
853
+ function sleep(ms) {
854
+ return new Promise((resolve) => setTimeout(resolve, ms));
855
+ }