@orgloop/agentctl 1.1.0 → 1.2.1

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