@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,753 @@
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_SESSION_DIR = path.join(os.homedir(), ".pi", "agent", "sessions");
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 Rust adapter — reads session data directly from ~/.pi/agent/sessions/
16
+ * and cross-references with running PIDs. NEVER maintains its own registry.
17
+ *
18
+ * Pi Rust (pi-rust / pi_agent_rust) stores sessions as JSONL files organized
19
+ * by project directory. It also maintains a SQLite index for fast lookups,
20
+ * but we read JSONL directly for simplicity and testability.
21
+ */
22
+ export class PiRustAdapter {
23
+ id = "pi-rust";
24
+ sessionDir;
25
+ sessionsMetaDir;
26
+ getPids;
27
+ isProcessAlive;
28
+ constructor(opts) {
29
+ this.sessionDir = opts?.sessionDir || DEFAULT_SESSION_DIR;
30
+ this.sessionsMetaDir =
31
+ opts?.sessionsMetaDir ||
32
+ path.join(os.homedir(), ".pi", "agentctl", "sessions");
33
+ this.getPids = opts?.getPids || getPiRustPids;
34
+ this.isProcessAlive = opts?.isProcessAlive || defaultIsProcessAlive;
35
+ }
36
+ async list(opts) {
37
+ const runningPids = await this.getPids();
38
+ const sessions = [];
39
+ let projectDirs;
40
+ try {
41
+ const entries = await fs.readdir(this.sessionDir);
42
+ // Project dirs start with "--" (encoded paths)
43
+ projectDirs = entries.filter((e) => e.startsWith("--"));
44
+ }
45
+ catch {
46
+ return [];
47
+ }
48
+ for (const projDir of projectDirs) {
49
+ const projPath = path.join(this.sessionDir, projDir);
50
+ const stat = await fs.stat(projPath).catch(() => null);
51
+ if (!stat?.isDirectory())
52
+ continue;
53
+ const projectCwd = decodeProjDir(projDir);
54
+ const sessionFiles = await this.getSessionFiles(projPath);
55
+ for (const file of sessionFiles) {
56
+ const filePath = path.join(projPath, file);
57
+ const header = await this.readSessionHeader(filePath);
58
+ if (!header)
59
+ continue;
60
+ const session = await this.buildSession(header, filePath, projectCwd, runningPids);
61
+ // Filter by status
62
+ if (opts?.status && session.status !== opts.status)
63
+ continue;
64
+ // If not --all, skip old stopped sessions
65
+ if (!opts?.all && session.status === "stopped") {
66
+ const age = Date.now() - session.startedAt.getTime();
67
+ if (age > STOPPED_SESSION_MAX_AGE_MS)
68
+ continue;
69
+ }
70
+ // Default: only show running sessions unless --all
71
+ if (!opts?.all &&
72
+ !opts?.status &&
73
+ session.status !== "running" &&
74
+ session.status !== "idle") {
75
+ continue;
76
+ }
77
+ sessions.push(session);
78
+ }
79
+ }
80
+ // Sort: running first, then by most recent
81
+ sessions.sort((a, b) => {
82
+ if (a.status === "running" && b.status !== "running")
83
+ return -1;
84
+ if (b.status === "running" && a.status !== "running")
85
+ return 1;
86
+ return b.startedAt.getTime() - a.startedAt.getTime();
87
+ });
88
+ return sessions;
89
+ }
90
+ async peek(sessionId, opts) {
91
+ const lines = opts?.lines ?? 20;
92
+ const filePath = await this.findSessionFile(sessionId);
93
+ if (!filePath)
94
+ throw new Error(`Session not found: ${sessionId}`);
95
+ const content = await fs.readFile(filePath, "utf-8");
96
+ const jsonlLines = content.trim().split("\n");
97
+ const assistantMessages = [];
98
+ for (const line of jsonlLines) {
99
+ try {
100
+ const msg = JSON.parse(line);
101
+ if (msg.type === "message" &&
102
+ msg.message?.role === "assistant" &&
103
+ msg.message?.content) {
104
+ const text = extractTextContent(msg.message.content);
105
+ if (text)
106
+ assistantMessages.push(text);
107
+ }
108
+ }
109
+ catch {
110
+ // skip malformed lines
111
+ }
112
+ }
113
+ // Take last N messages
114
+ const recent = assistantMessages.slice(-lines);
115
+ return recent.join("\n---\n");
116
+ }
117
+ async status(sessionId) {
118
+ const runningPids = await this.getPids();
119
+ const filePath = await this.findSessionFile(sessionId);
120
+ if (!filePath)
121
+ throw new Error(`Session not found: ${sessionId}`);
122
+ const header = await this.readSessionHeader(filePath);
123
+ if (!header)
124
+ throw new Error(`Session not found: ${sessionId}`);
125
+ const projDir = path.basename(path.dirname(filePath));
126
+ const projectCwd = decodeProjDir(projDir);
127
+ return this.buildSession(header, filePath, projectCwd, runningPids);
128
+ }
129
+ async launch(opts) {
130
+ const args = ["--print", "--mode", "json", opts.prompt];
131
+ if (opts.model) {
132
+ args.unshift("--model", opts.model);
133
+ }
134
+ const env = buildSpawnEnv(undefined, opts.env);
135
+ const cwd = opts.cwd || process.cwd();
136
+ // Write stdout to a log file so we can extract the session ID
137
+ await fs.mkdir(this.sessionsMetaDir, { recursive: true });
138
+ const logPath = path.join(this.sessionsMetaDir, `launch-${Date.now()}.log`);
139
+ const logFd = await fs.open(logPath, "w");
140
+ const piRustPath = await resolveBinaryPath("pi-rust");
141
+ const child = spawn(piRustPath, args, {
142
+ cwd,
143
+ env,
144
+ stdio: ["ignore", logFd.fd, "ignore"],
145
+ detached: true,
146
+ });
147
+ child.on("error", (err) => {
148
+ console.error(`[pi-rust] spawn error: ${err.message}`);
149
+ });
150
+ child.unref();
151
+ const pid = child.pid;
152
+ const now = new Date();
153
+ await logFd.close();
154
+ // Try to extract the real session ID from the JSONL output
155
+ let resolvedSessionId;
156
+ if (pid) {
157
+ resolvedSessionId = await this.pollForSessionId(logPath, pid, 5000);
158
+ }
159
+ const sessionId = resolvedSessionId || (pid ? `pending-${pid}` : crypto.randomUUID());
160
+ // Persist session metadata so status checks work after wrapper exits
161
+ if (pid) {
162
+ await this.writeSessionMeta({
163
+ sessionId,
164
+ pid,
165
+ wrapperPid: process.pid,
166
+ cwd,
167
+ model: opts.model,
168
+ prompt: opts.prompt.slice(0, 200),
169
+ launchedAt: now.toISOString(),
170
+ });
171
+ }
172
+ const session = {
173
+ id: sessionId,
174
+ adapter: this.id,
175
+ status: "running",
176
+ startedAt: now,
177
+ cwd,
178
+ model: opts.model,
179
+ prompt: opts.prompt.slice(0, 200),
180
+ pid,
181
+ meta: {
182
+ adapterOpts: opts.adapterOpts,
183
+ spec: opts.spec,
184
+ logPath,
185
+ },
186
+ };
187
+ return session;
188
+ }
189
+ /**
190
+ * Poll the launch log file for up to `timeoutMs` to extract the real session ID.
191
+ * Pi Rust's JSONL output includes the session ID in the first line (type: "session").
192
+ */
193
+ async pollForSessionId(logPath, pid, timeoutMs) {
194
+ const deadline = Date.now() + timeoutMs;
195
+ while (Date.now() < deadline) {
196
+ try {
197
+ const content = await fs.readFile(logPath, "utf-8");
198
+ for (const line of content.split("\n")) {
199
+ if (!line.trim())
200
+ continue;
201
+ try {
202
+ const msg = JSON.parse(line);
203
+ if (msg.type === "session" &&
204
+ msg.id &&
205
+ typeof msg.id === "string") {
206
+ return msg.id;
207
+ }
208
+ }
209
+ catch {
210
+ // Not valid JSON yet
211
+ }
212
+ }
213
+ }
214
+ catch {
215
+ // File may not exist yet
216
+ }
217
+ // Check if process is still alive
218
+ try {
219
+ process.kill(pid, 0);
220
+ }
221
+ catch {
222
+ break; // Process died
223
+ }
224
+ await sleep(200);
225
+ }
226
+ return undefined;
227
+ }
228
+ async stop(sessionId, opts) {
229
+ const pid = await this.findPidForSession(sessionId);
230
+ if (!pid)
231
+ throw new Error(`No running process for session: ${sessionId}`);
232
+ if (opts?.force) {
233
+ process.kill(pid, "SIGINT");
234
+ await sleep(5000);
235
+ try {
236
+ process.kill(pid, "SIGKILL");
237
+ }
238
+ catch {
239
+ // Already dead
240
+ }
241
+ }
242
+ else {
243
+ process.kill(pid, "SIGTERM");
244
+ }
245
+ }
246
+ async resume(sessionId, message) {
247
+ const filePath = await this.findSessionFile(sessionId);
248
+ const session = filePath
249
+ ? await this.status(sessionId).catch(() => null)
250
+ : null;
251
+ const cwd = session?.cwd || process.cwd();
252
+ // pi-rust --continue resumes the previous session, --session <path> for a specific one
253
+ const args = ["--print", "-p", message];
254
+ if (filePath) {
255
+ args.unshift("--session", filePath);
256
+ }
257
+ else {
258
+ args.unshift("--continue");
259
+ }
260
+ const piRustPath = await resolveBinaryPath("pi-rust");
261
+ const child = spawn(piRustPath, args, {
262
+ cwd,
263
+ stdio: ["pipe", "pipe", "pipe"],
264
+ detached: true,
265
+ });
266
+ child.on("error", (err) => {
267
+ console.error(`[pi-rust] resume spawn error: ${err.message}`);
268
+ });
269
+ child.unref();
270
+ }
271
+ async *events() {
272
+ let knownSessions = new Map();
273
+ const initial = await this.list({ all: true });
274
+ for (const s of initial) {
275
+ knownSessions.set(s.id, s);
276
+ }
277
+ const watcher = watch(this.sessionDir, { recursive: true });
278
+ try {
279
+ while (true) {
280
+ await sleep(5000);
281
+ const current = await this.list({ all: true });
282
+ const currentMap = new Map(current.map((s) => [s.id, s]));
283
+ for (const [id, session] of currentMap) {
284
+ const prev = knownSessions.get(id);
285
+ if (!prev) {
286
+ yield {
287
+ type: "session.started",
288
+ adapter: this.id,
289
+ sessionId: id,
290
+ session,
291
+ timestamp: new Date(),
292
+ };
293
+ }
294
+ else if (prev.status === "running" &&
295
+ session.status === "stopped") {
296
+ yield {
297
+ type: "session.stopped",
298
+ adapter: this.id,
299
+ sessionId: id,
300
+ session,
301
+ timestamp: new Date(),
302
+ };
303
+ }
304
+ else if (prev.status === "running" && session.status === "idle") {
305
+ yield {
306
+ type: "session.idle",
307
+ adapter: this.id,
308
+ sessionId: id,
309
+ session,
310
+ timestamp: new Date(),
311
+ };
312
+ }
313
+ }
314
+ knownSessions = currentMap;
315
+ }
316
+ }
317
+ finally {
318
+ watcher.close();
319
+ }
320
+ }
321
+ // --- Private helpers ---
322
+ /** List .jsonl session files in a project directory */
323
+ async getSessionFiles(projPath) {
324
+ try {
325
+ const entries = await fs.readdir(projPath);
326
+ return entries.filter((e) => e.endsWith(".jsonl"));
327
+ }
328
+ catch {
329
+ return [];
330
+ }
331
+ }
332
+ /** Read and parse the session header (first line) from a JSONL file */
333
+ async readSessionHeader(filePath) {
334
+ try {
335
+ const content = await fs.readFile(filePath, "utf-8");
336
+ const firstLine = content.split("\n")[0];
337
+ if (!firstLine?.trim())
338
+ return null;
339
+ const parsed = JSON.parse(firstLine);
340
+ if (parsed.type !== "session")
341
+ return null;
342
+ return parsed;
343
+ }
344
+ catch {
345
+ return null;
346
+ }
347
+ }
348
+ /** Extract the session ID from a JSONL filename (e.g., "2026-02-22T16-29-54.096Z_feb70071.jsonl" → "feb70071") */
349
+ extractShortId(filename) {
350
+ // Format: {timestamp}_{shortId}.jsonl
351
+ const base = filename.replace(".jsonl", "");
352
+ const parts = base.split("_");
353
+ return parts[parts.length - 1];
354
+ }
355
+ async buildSession(header, filePath, projectCwd, runningPids) {
356
+ const isRunning = await this.isSessionRunning(header, projectCwd, runningPids);
357
+ const { model, tokens, cost } = await this.parseSessionTail(filePath);
358
+ const firstPrompt = await this.readFirstPrompt(filePath);
359
+ let fileStat;
360
+ try {
361
+ fileStat = await fs.stat(filePath);
362
+ }
363
+ catch {
364
+ // ignore
365
+ }
366
+ return {
367
+ id: header.id,
368
+ adapter: this.id,
369
+ status: isRunning ? "running" : "stopped",
370
+ startedAt: new Date(header.timestamp),
371
+ stoppedAt: isRunning
372
+ ? undefined
373
+ : fileStat
374
+ ? new Date(Number(fileStat.mtimeMs))
375
+ : undefined,
376
+ cwd: header.cwd || projectCwd,
377
+ model: model || header.modelId,
378
+ prompt: firstPrompt?.slice(0, 200),
379
+ tokens,
380
+ cost: cost ?? undefined,
381
+ pid: isRunning
382
+ ? await this.findMatchingPid(header, projectCwd, runningPids)
383
+ : undefined,
384
+ meta: {
385
+ provider: header.provider,
386
+ thinkingLevel: header.thinkingLevel,
387
+ projectDir: projectCwd,
388
+ sessionFile: filePath,
389
+ },
390
+ };
391
+ }
392
+ async isSessionRunning(header, projectCwd, runningPids) {
393
+ const sessionCreated = new Date(header.timestamp).getTime();
394
+ // 1. Check running PIDs discovered via `ps aux`
395
+ for (const [, info] of runningPids) {
396
+ if (info.args.includes(header.id)) {
397
+ if (this.processStartedAfterSession(info, sessionCreated))
398
+ return true;
399
+ continue;
400
+ }
401
+ if (info.cwd === projectCwd) {
402
+ if (this.processStartedAfterSession(info, sessionCreated))
403
+ return true;
404
+ }
405
+ }
406
+ // 2. Check persisted session metadata
407
+ const meta = await this.readSessionMeta(header.id);
408
+ if (meta?.pid) {
409
+ if (this.isProcessAlive(meta.pid)) {
410
+ const pidInfo = runningPids.get(meta.pid);
411
+ if (pidInfo?.startTime && meta.startTime) {
412
+ const currentStartMs = new Date(pidInfo.startTime).getTime();
413
+ const recordedStartMs = new Date(meta.startTime).getTime();
414
+ if (!Number.isNaN(currentStartMs) &&
415
+ !Number.isNaN(recordedStartMs) &&
416
+ Math.abs(currentStartMs - recordedStartMs) > 5000) {
417
+ await this.deleteSessionMeta(header.id);
418
+ return false;
419
+ }
420
+ }
421
+ if (meta.startTime) {
422
+ const metaStartMs = new Date(meta.startTime).getTime();
423
+ const sessionMs = new Date(meta.launchedAt).getTime();
424
+ if (!Number.isNaN(metaStartMs) && metaStartMs >= sessionMs - 5000) {
425
+ return true;
426
+ }
427
+ await this.deleteSessionMeta(header.id);
428
+ return false;
429
+ }
430
+ return true;
431
+ }
432
+ await this.deleteSessionMeta(header.id);
433
+ }
434
+ return false;
435
+ }
436
+ processStartedAfterSession(info, sessionCreatedMs) {
437
+ if (!info.startTime)
438
+ return false;
439
+ const processStartMs = new Date(info.startTime).getTime();
440
+ if (Number.isNaN(processStartMs))
441
+ return false;
442
+ return processStartMs >= sessionCreatedMs - 5000;
443
+ }
444
+ async findMatchingPid(header, projectCwd, runningPids) {
445
+ const sessionCreated = new Date(header.timestamp).getTime();
446
+ for (const [pid, info] of runningPids) {
447
+ if (info.args.includes(header.id)) {
448
+ if (this.processStartedAfterSession(info, sessionCreated))
449
+ return pid;
450
+ continue;
451
+ }
452
+ if (info.cwd === projectCwd) {
453
+ if (this.processStartedAfterSession(info, sessionCreated))
454
+ return pid;
455
+ }
456
+ }
457
+ const meta = await this.readSessionMeta(header.id);
458
+ if (meta?.pid && this.isProcessAlive(meta.pid)) {
459
+ return meta.pid;
460
+ }
461
+ return undefined;
462
+ }
463
+ async parseSessionTail(filePath) {
464
+ try {
465
+ const content = await fs.readFile(filePath, "utf-8");
466
+ const lines = content.trim().split("\n");
467
+ let model;
468
+ let totalIn = 0;
469
+ let totalOut = 0;
470
+ let totalCost = 0;
471
+ const tail = lines.slice(-100);
472
+ for (const line of tail) {
473
+ try {
474
+ const msg = JSON.parse(line);
475
+ if (msg.type === "message" && msg.message?.role === "assistant") {
476
+ if (msg.message.model)
477
+ model = msg.message.model;
478
+ if (msg.message.usage) {
479
+ totalIn += msg.message.usage.input || 0;
480
+ totalOut += msg.message.usage.output || 0;
481
+ if (msg.message.usage.cost?.total) {
482
+ totalCost += msg.message.usage.cost.total;
483
+ }
484
+ }
485
+ }
486
+ }
487
+ catch {
488
+ // skip
489
+ }
490
+ }
491
+ if (!model) {
492
+ const head = lines.slice(0, 20);
493
+ for (const line of head) {
494
+ try {
495
+ const msg = JSON.parse(line);
496
+ if (msg.type === "message" &&
497
+ msg.message?.role === "assistant" &&
498
+ msg.message?.model) {
499
+ model = msg.message.model;
500
+ break;
501
+ }
502
+ // Also check session header for modelId
503
+ if (msg.type === "session" &&
504
+ msg.modelId) {
505
+ model = msg.modelId;
506
+ }
507
+ }
508
+ catch {
509
+ // skip
510
+ }
511
+ }
512
+ }
513
+ return {
514
+ model,
515
+ tokens: totalIn || totalOut ? { in: totalIn, out: totalOut } : undefined,
516
+ cost: totalCost || undefined,
517
+ };
518
+ }
519
+ catch {
520
+ return {};
521
+ }
522
+ }
523
+ /** Read the first user prompt from a JSONL session file */
524
+ async readFirstPrompt(filePath) {
525
+ try {
526
+ const content = await fs.readFile(filePath, "utf-8");
527
+ for (const line of content.split("\n").slice(0, 20)) {
528
+ try {
529
+ const msg = JSON.parse(line);
530
+ if (msg.type === "message" &&
531
+ msg.message?.role === "user" &&
532
+ msg.message?.content) {
533
+ return extractTextContent(msg.message.content);
534
+ }
535
+ }
536
+ catch {
537
+ // skip
538
+ }
539
+ }
540
+ }
541
+ catch {
542
+ // skip
543
+ }
544
+ return undefined;
545
+ }
546
+ /** Find a session JSONL file by session ID (full or prefix match) */
547
+ async findSessionFile(sessionId) {
548
+ let projectDirs;
549
+ try {
550
+ const entries = await fs.readdir(this.sessionDir);
551
+ projectDirs = entries.filter((e) => e.startsWith("--"));
552
+ }
553
+ catch {
554
+ return null;
555
+ }
556
+ for (const projDir of projectDirs) {
557
+ const projPath = path.join(this.sessionDir, projDir);
558
+ const stat = await fs.stat(projPath).catch(() => null);
559
+ if (!stat?.isDirectory())
560
+ continue;
561
+ const files = await this.getSessionFiles(projPath);
562
+ for (const file of files) {
563
+ const filePath = path.join(projPath, file);
564
+ const header = await this.readSessionHeader(filePath);
565
+ if (!header)
566
+ continue;
567
+ // Full match or prefix match
568
+ if (header.id === sessionId || header.id.startsWith(sessionId)) {
569
+ return filePath;
570
+ }
571
+ // Also check if the short ID in the filename matches
572
+ const shortId = this.extractShortId(file);
573
+ if (shortId === sessionId || sessionId.startsWith(shortId)) {
574
+ return filePath;
575
+ }
576
+ }
577
+ }
578
+ return null;
579
+ }
580
+ async findPidForSession(sessionId) {
581
+ const session = await this.status(sessionId);
582
+ return session.pid ?? null;
583
+ }
584
+ // --- Session metadata persistence ---
585
+ async writeSessionMeta(meta) {
586
+ await fs.mkdir(this.sessionsMetaDir, { recursive: true });
587
+ let startTime;
588
+ try {
589
+ const { stdout } = await execFileAsync("ps", [
590
+ "-p",
591
+ meta.pid.toString(),
592
+ "-o",
593
+ "lstart=",
594
+ ]);
595
+ startTime = stdout.trim() || undefined;
596
+ }
597
+ catch {
598
+ // Process may have already exited or ps failed
599
+ }
600
+ const fullMeta = { ...meta, startTime };
601
+ const metaPath = path.join(this.sessionsMetaDir, `${meta.sessionId}.json`);
602
+ await fs.writeFile(metaPath, JSON.stringify(fullMeta, null, 2));
603
+ }
604
+ async readSessionMeta(sessionId) {
605
+ const metaPath = path.join(this.sessionsMetaDir, `${sessionId}.json`);
606
+ try {
607
+ const raw = await fs.readFile(metaPath, "utf-8");
608
+ return JSON.parse(raw);
609
+ }
610
+ catch {
611
+ // File doesn't exist or is unreadable
612
+ }
613
+ try {
614
+ const files = await fs.readdir(this.sessionsMetaDir);
615
+ for (const file of files) {
616
+ if (!file.endsWith(".json"))
617
+ continue;
618
+ try {
619
+ const raw = await fs.readFile(path.join(this.sessionsMetaDir, file), "utf-8");
620
+ const meta = JSON.parse(raw);
621
+ if (meta.sessionId === sessionId)
622
+ return meta;
623
+ }
624
+ catch {
625
+ // skip
626
+ }
627
+ }
628
+ }
629
+ catch {
630
+ // Dir doesn't exist
631
+ }
632
+ return null;
633
+ }
634
+ async deleteSessionMeta(sessionId) {
635
+ for (const id of [sessionId, `pending-${sessionId}`]) {
636
+ const metaPath = path.join(this.sessionsMetaDir, `${id}.json`);
637
+ try {
638
+ await fs.unlink(metaPath);
639
+ }
640
+ catch {
641
+ // File doesn't exist
642
+ }
643
+ }
644
+ }
645
+ }
646
+ // --- Utility functions ---
647
+ /**
648
+ * Decode a Pi Rust project directory name back to the original path.
649
+ * Pi Rust encodes paths: "/" → "-", wrapped in "--".
650
+ * E.g., "--private-tmp-test-pi-rust--" → "/private/tmp/test-pi-rust"
651
+ *
652
+ * Note: This is a lossy encoding — hyphens in the original path are
653
+ * indistinguishable from path separators. We do our best to reconstruct.
654
+ */
655
+ export function decodeProjDir(dirName) {
656
+ // Strip leading/trailing "--"
657
+ let inner = dirName;
658
+ if (inner.startsWith("--"))
659
+ inner = inner.slice(2);
660
+ if (inner.endsWith("--"))
661
+ inner = inner.slice(0, -2);
662
+ // Replace "-" with "/"
663
+ return `/${inner.replace(/-/g, "/")}`;
664
+ }
665
+ /**
666
+ * Encode a path as a Pi Rust project directory name.
667
+ * E.g., "/private/tmp/test-pi-rust" → "--private-tmp-test-pi-rust--"
668
+ */
669
+ export function encodeProjDir(cwdPath) {
670
+ // Strip leading "/" and replace remaining "/" and "-" with "-"
671
+ const stripped = cwdPath.startsWith("/") ? cwdPath.slice(1) : cwdPath;
672
+ const encoded = stripped.replace(/\//g, "-");
673
+ return `--${encoded}--`;
674
+ }
675
+ function defaultIsProcessAlive(pid) {
676
+ try {
677
+ process.kill(pid, 0);
678
+ return true;
679
+ }
680
+ catch {
681
+ return false;
682
+ }
683
+ }
684
+ async function getPiRustPids() {
685
+ const pids = new Map();
686
+ try {
687
+ const { stdout } = await execFileAsync("ps", ["aux"]);
688
+ for (const line of stdout.split("\n")) {
689
+ if (!line.includes("pi-rust") || line.includes("grep"))
690
+ continue;
691
+ const fields = line.trim().split(/\s+/);
692
+ if (fields.length < 11)
693
+ continue;
694
+ const pid = parseInt(fields[1], 10);
695
+ const command = fields.slice(10).join(" ");
696
+ // Match pi-rust processes (the binary, not wrappers)
697
+ if (!command.includes("pi-rust"))
698
+ continue;
699
+ if (pid === process.pid)
700
+ continue;
701
+ let cwd = "";
702
+ try {
703
+ const { stdout: lsofOut } = await execFileAsync("/usr/sbin/lsof", [
704
+ "-p",
705
+ pid.toString(),
706
+ "-Fn",
707
+ ]);
708
+ const lsofLines = lsofOut.split("\n");
709
+ for (let i = 0; i < lsofLines.length; i++) {
710
+ if (lsofLines[i] === "fcwd" && lsofLines[i + 1]?.startsWith("n")) {
711
+ cwd = lsofLines[i + 1].slice(1);
712
+ break;
713
+ }
714
+ }
715
+ }
716
+ catch {
717
+ // lsof might fail
718
+ }
719
+ let startTime;
720
+ try {
721
+ const { stdout: lstart } = await execFileAsync("ps", [
722
+ "-p",
723
+ pid.toString(),
724
+ "-o",
725
+ "lstart=",
726
+ ]);
727
+ startTime = lstart.trim() || undefined;
728
+ }
729
+ catch {
730
+ // ps might fail
731
+ }
732
+ pids.set(pid, { pid, cwd, args: command, startTime });
733
+ }
734
+ }
735
+ catch {
736
+ // ps failed — return empty
737
+ }
738
+ return pids;
739
+ }
740
+ function extractTextContent(content) {
741
+ if (typeof content === "string")
742
+ return content;
743
+ if (Array.isArray(content)) {
744
+ return content
745
+ .filter((b) => b.type === "text" && b.text)
746
+ .map((b) => b.text)
747
+ .join("\n");
748
+ }
749
+ return "";
750
+ }
751
+ function sleep(ms) {
752
+ return new Promise((resolve) => setTimeout(resolve, ms));
753
+ }