@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,702 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import { readFileSync, 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_CODEX_DIR = path.join(os.homedir(), ".codex");
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
+ * Codex CLI adapter — reads session data from ~/.codex/sessions/
16
+ * and cross-references with running PIDs.
17
+ */
18
+ export class CodexAdapter {
19
+ id = "codex";
20
+ codexDir;
21
+ sessionsDir;
22
+ sessionsMetaDir;
23
+ getPids;
24
+ isProcessAlive;
25
+ constructor(opts) {
26
+ this.codexDir = opts?.codexDir || DEFAULT_CODEX_DIR;
27
+ this.sessionsDir = path.join(this.codexDir, "sessions");
28
+ this.sessionsMetaDir =
29
+ opts?.sessionsMetaDir || path.join(this.codexDir, "agentctl", "sessions");
30
+ this.getPids = opts?.getPids || getCodexPids;
31
+ this.isProcessAlive = opts?.isProcessAlive || defaultIsProcessAlive;
32
+ }
33
+ async list(opts) {
34
+ const runningPids = await this.getPids();
35
+ const sessionInfos = await this.discoverSessions();
36
+ const sessions = [];
37
+ for (const info of sessionInfos) {
38
+ const session = this.buildSession(info, runningPids);
39
+ if (opts?.status && session.status !== opts.status)
40
+ continue;
41
+ if (!opts?.all && session.status === "stopped") {
42
+ const age = Date.now() - session.startedAt.getTime();
43
+ if (age > STOPPED_SESSION_MAX_AGE_MS)
44
+ continue;
45
+ }
46
+ if (!opts?.all &&
47
+ !opts?.status &&
48
+ session.status !== "running" &&
49
+ session.status !== "idle") {
50
+ continue;
51
+ }
52
+ sessions.push(session);
53
+ }
54
+ sessions.sort((a, b) => {
55
+ if (a.status === "running" && b.status !== "running")
56
+ return -1;
57
+ if (b.status === "running" && a.status !== "running")
58
+ return 1;
59
+ return b.startedAt.getTime() - a.startedAt.getTime();
60
+ });
61
+ return sessions;
62
+ }
63
+ async peek(sessionId, opts) {
64
+ const lines = opts?.lines ?? 20;
65
+ const info = await this.findSession(sessionId);
66
+ if (!info)
67
+ throw new Error(`Session not found: ${sessionId}`);
68
+ const content = await fs.readFile(info.filePath, "utf-8");
69
+ const jsonlLines = content.trim().split("\n");
70
+ const messages = [];
71
+ for (const line of jsonlLines) {
72
+ try {
73
+ const parsed = JSON.parse(line);
74
+ // Extract agent messages from event_msg type (primary source)
75
+ if (parsed.type === "event_msg" &&
76
+ parsed.payload?.type === "agent_message" &&
77
+ parsed.payload.message) {
78
+ messages.push(parsed.payload.message);
79
+ }
80
+ }
81
+ catch {
82
+ // skip malformed lines
83
+ }
84
+ }
85
+ const recent = messages.slice(-lines);
86
+ return recent.join("\n---\n");
87
+ }
88
+ async status(sessionId) {
89
+ const runningPids = await this.getPids();
90
+ const info = await this.findSession(sessionId);
91
+ if (!info)
92
+ throw new Error(`Session not found: ${sessionId}`);
93
+ return this.buildSession(info, runningPids);
94
+ }
95
+ async launch(opts) {
96
+ const args = [
97
+ "exec",
98
+ "--dangerously-bypass-approvals-and-sandbox",
99
+ "--json",
100
+ ];
101
+ if (opts.model) {
102
+ args.push("--model", opts.model);
103
+ }
104
+ const cwd = opts.cwd || process.cwd();
105
+ args.push("--cd", cwd);
106
+ args.push(opts.prompt);
107
+ const env = buildSpawnEnv(undefined, opts.env);
108
+ await fs.mkdir(this.sessionsMetaDir, { recursive: true });
109
+ const logPath = path.join(this.sessionsMetaDir, `launch-${Date.now()}.log`);
110
+ const logFd = await fs.open(logPath, "w");
111
+ const codexPath = await resolveBinaryPath("codex");
112
+ const child = spawn(codexPath, args, {
113
+ cwd,
114
+ env,
115
+ stdio: ["ignore", logFd.fd, "ignore"],
116
+ detached: true,
117
+ });
118
+ child.on("error", (err) => {
119
+ console.error(`[codex] spawn error: ${err.message}`);
120
+ });
121
+ child.unref();
122
+ const pid = child.pid;
123
+ const now = new Date();
124
+ await logFd.close();
125
+ // Poll for thread_id from JSONL output
126
+ let resolvedSessionId;
127
+ if (pid) {
128
+ resolvedSessionId = await this.pollForSessionId(logPath, pid, 10000);
129
+ }
130
+ const sessionId = resolvedSessionId || (pid ? `pending-${pid}` : crypto.randomUUID());
131
+ if (pid) {
132
+ await this.writeSessionMeta({
133
+ sessionId,
134
+ pid,
135
+ wrapperPid: process.pid,
136
+ cwd,
137
+ model: opts.model,
138
+ prompt: opts.prompt.slice(0, 200),
139
+ launchedAt: now.toISOString(),
140
+ });
141
+ }
142
+ return {
143
+ id: sessionId,
144
+ adapter: this.id,
145
+ status: "running",
146
+ startedAt: now,
147
+ cwd,
148
+ model: opts.model,
149
+ prompt: opts.prompt.slice(0, 200),
150
+ pid,
151
+ meta: {
152
+ adapterOpts: opts.adapterOpts,
153
+ spec: opts.spec,
154
+ logPath,
155
+ },
156
+ };
157
+ }
158
+ /**
159
+ * Poll the launch log file for up to `timeoutMs` to extract the session/thread ID.
160
+ * Codex outputs {"type":"thread.started","thread_id":"..."} early in its JSONL stream.
161
+ */
162
+ async pollForSessionId(logPath, pid, timeoutMs) {
163
+ const deadline = Date.now() + timeoutMs;
164
+ while (Date.now() < deadline) {
165
+ try {
166
+ const content = await fs.readFile(logPath, "utf-8");
167
+ for (const line of content.split("\n")) {
168
+ if (!line.trim())
169
+ continue;
170
+ try {
171
+ const msg = JSON.parse(line);
172
+ // Look for thread.started event
173
+ if (msg.thread_id) {
174
+ return msg.thread_id;
175
+ }
176
+ // Also check session_meta payload
177
+ if (msg.type === "session_meta" && msg.payload?.id) {
178
+ return msg.payload.id;
179
+ }
180
+ }
181
+ catch {
182
+ // Not valid JSON yet
183
+ }
184
+ }
185
+ }
186
+ catch {
187
+ // File may not exist yet
188
+ }
189
+ try {
190
+ process.kill(pid, 0);
191
+ }
192
+ catch {
193
+ break;
194
+ }
195
+ await sleep(200);
196
+ }
197
+ return undefined;
198
+ }
199
+ async stop(sessionId, opts) {
200
+ const pid = await this.findPidForSession(sessionId);
201
+ if (!pid)
202
+ throw new Error(`No running process for session: ${sessionId}`);
203
+ if (opts?.force) {
204
+ process.kill(pid, "SIGINT");
205
+ await sleep(5000);
206
+ try {
207
+ process.kill(pid, "SIGKILL");
208
+ }
209
+ catch {
210
+ // Already dead
211
+ }
212
+ }
213
+ else {
214
+ process.kill(pid, "SIGTERM");
215
+ }
216
+ }
217
+ async resume(sessionId, message) {
218
+ const session = await this.findSession(sessionId);
219
+ const cwd = session?.cwd || process.cwd();
220
+ const args = [
221
+ "exec",
222
+ "resume",
223
+ "--dangerously-bypass-approvals-and-sandbox",
224
+ "--json",
225
+ sessionId,
226
+ message,
227
+ ];
228
+ const codexPath = await resolveBinaryPath("codex");
229
+ const child = spawn(codexPath, args, {
230
+ cwd,
231
+ stdio: ["pipe", "pipe", "pipe"],
232
+ detached: true,
233
+ });
234
+ child.on("error", (err) => {
235
+ console.error(`[codex] resume spawn error: ${err.message}`);
236
+ });
237
+ child.unref();
238
+ }
239
+ async *events() {
240
+ let knownSessions = new Map();
241
+ const initial = await this.list({ all: true });
242
+ for (const s of initial) {
243
+ knownSessions.set(s.id, s);
244
+ }
245
+ const watchDir = this.sessionsDir;
246
+ let watcher;
247
+ try {
248
+ await fs.access(watchDir);
249
+ watcher = watch(watchDir, { recursive: true });
250
+ }
251
+ catch {
252
+ // Sessions dir may not exist yet
253
+ }
254
+ try {
255
+ while (true) {
256
+ await sleep(5000);
257
+ const current = await this.list({ all: true });
258
+ const currentMap = new Map(current.map((s) => [s.id, s]));
259
+ for (const [id, session] of currentMap) {
260
+ const prev = knownSessions.get(id);
261
+ if (!prev) {
262
+ yield {
263
+ type: "session.started",
264
+ adapter: this.id,
265
+ sessionId: id,
266
+ session,
267
+ timestamp: new Date(),
268
+ };
269
+ }
270
+ else if (prev.status === "running" &&
271
+ session.status === "stopped") {
272
+ yield {
273
+ type: "session.stopped",
274
+ adapter: this.id,
275
+ sessionId: id,
276
+ session,
277
+ timestamp: new Date(),
278
+ };
279
+ }
280
+ else if (prev.status === "running" && session.status === "idle") {
281
+ yield {
282
+ type: "session.idle",
283
+ adapter: this.id,
284
+ sessionId: id,
285
+ session,
286
+ timestamp: new Date(),
287
+ };
288
+ }
289
+ }
290
+ knownSessions = currentMap;
291
+ }
292
+ }
293
+ finally {
294
+ watcher?.close();
295
+ }
296
+ }
297
+ // --- Private helpers ---
298
+ /**
299
+ * Discover all Codex sessions by scanning ~/.codex/sessions/ recursively.
300
+ * Sessions are stored as: sessions/YYYY/MM/DD/rollout-<datetime>-<session-id>.jsonl
301
+ */
302
+ async discoverSessions() {
303
+ const sessions = [];
304
+ const jsonlFiles = await this.findJsonlFiles(this.sessionsDir);
305
+ for (const filePath of jsonlFiles) {
306
+ try {
307
+ const info = await this.parseSessionFile(filePath);
308
+ if (info)
309
+ sessions.push(info);
310
+ }
311
+ catch {
312
+ // Skip unparseable files
313
+ }
314
+ }
315
+ // Also check persisted metadata for sessions not yet in ~/.codex/sessions/
316
+ try {
317
+ const metaFiles = await fs.readdir(this.sessionsMetaDir);
318
+ for (const file of metaFiles) {
319
+ if (!file.endsWith(".json") || file.startsWith("launch-"))
320
+ continue;
321
+ try {
322
+ const raw = await fs.readFile(path.join(this.sessionsMetaDir, file), "utf-8");
323
+ const meta = JSON.parse(raw);
324
+ // Skip if we already have this session from the sessions dir
325
+ if (sessions.some((s) => s.id === meta.sessionId))
326
+ continue;
327
+ sessions.push({
328
+ id: meta.sessionId,
329
+ cwd: meta.cwd,
330
+ model: meta.model,
331
+ firstPrompt: meta.prompt,
332
+ created: new Date(meta.launchedAt),
333
+ modified: new Date(meta.launchedAt),
334
+ filePath: "",
335
+ });
336
+ }
337
+ catch {
338
+ // Skip
339
+ }
340
+ }
341
+ }
342
+ catch {
343
+ // Dir doesn't exist
344
+ }
345
+ return sessions;
346
+ }
347
+ /** Recursively find all .jsonl files under a directory */
348
+ async findJsonlFiles(dir) {
349
+ const results = [];
350
+ try {
351
+ const entries = await fs.readdir(dir, { withFileTypes: true });
352
+ for (const entry of entries) {
353
+ const fullPath = path.join(dir, entry.name);
354
+ if (entry.isDirectory()) {
355
+ const nested = await this.findJsonlFiles(fullPath);
356
+ results.push(...nested);
357
+ }
358
+ else if (entry.name.endsWith(".jsonl")) {
359
+ results.push(fullPath);
360
+ }
361
+ }
362
+ }
363
+ catch {
364
+ // Directory doesn't exist
365
+ }
366
+ return results;
367
+ }
368
+ /** Parse a Codex session JSONL file to extract session info */
369
+ async parseSessionFile(filePath) {
370
+ const content = await fs.readFile(filePath, "utf-8");
371
+ const lines = content.trim().split("\n");
372
+ if (lines.length === 0 || !lines[0].trim())
373
+ return null;
374
+ const stat = await fs.stat(filePath);
375
+ let id;
376
+ let cwd;
377
+ let model;
378
+ let cliVersion;
379
+ let firstPrompt;
380
+ let lastMessage;
381
+ let sessionTimestamp;
382
+ let totalIn = 0;
383
+ let totalOut = 0;
384
+ for (const line of lines) {
385
+ if (!line.trim())
386
+ continue;
387
+ try {
388
+ const parsed = JSON.parse(line);
389
+ // Extract session ID from thread.started or session_meta
390
+ if (parsed.thread_id && !id) {
391
+ id = parsed.thread_id;
392
+ }
393
+ if (parsed.type === "session_meta" && parsed.payload) {
394
+ const meta = parsed.payload;
395
+ if (meta.id)
396
+ id = meta.id;
397
+ if (meta.cwd)
398
+ cwd = meta.cwd;
399
+ if (meta.cli_version)
400
+ cliVersion = meta.cli_version;
401
+ }
402
+ // Capture the earliest timestamp from the file
403
+ if (parsed.timestamp && !sessionTimestamp) {
404
+ sessionTimestamp = parsed.timestamp;
405
+ }
406
+ // Extract model from turn_context
407
+ if (parsed.type === "turn_context" && parsed.payload?.model) {
408
+ model = parsed.payload.model;
409
+ }
410
+ // Extract first user prompt
411
+ if (parsed.type === "event_msg" &&
412
+ parsed.payload?.type === "user_message" &&
413
+ parsed.payload.message &&
414
+ !firstPrompt) {
415
+ firstPrompt = parsed.payload.message;
416
+ }
417
+ // Extract agent messages
418
+ if (parsed.type === "event_msg" &&
419
+ parsed.payload?.type === "agent_message" &&
420
+ parsed.payload.message) {
421
+ lastMessage = parsed.payload.message;
422
+ }
423
+ // Extract token usage
424
+ if (parsed.type === "event_msg" &&
425
+ parsed.payload?.type === "token_count" &&
426
+ parsed.payload.info?.total_token_usage) {
427
+ const usage = parsed.payload.info.total_token_usage;
428
+ totalIn = usage.input_tokens ?? 0;
429
+ totalOut = usage.output_tokens ?? 0;
430
+ }
431
+ }
432
+ catch {
433
+ // skip malformed lines
434
+ }
435
+ }
436
+ if (!id) {
437
+ // Try to extract ID from filename:
438
+ // rollout-2026-02-20T17-38-59-019c7dd9-9b86-7dc1-95fe-7b68b8fd260d.jsonl
439
+ const basename = path.basename(filePath, ".jsonl");
440
+ const uuidMatch = basename.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
441
+ if (uuidMatch) {
442
+ id = uuidMatch[1];
443
+ }
444
+ }
445
+ if (!id)
446
+ return null;
447
+ // Use session's own timestamp if available, fall back to file stat
448
+ const created = sessionTimestamp
449
+ ? new Date(sessionTimestamp)
450
+ : stat.birthtime;
451
+ return {
452
+ id,
453
+ cwd,
454
+ model,
455
+ cliVersion,
456
+ firstPrompt: firstPrompt?.slice(0, 200),
457
+ lastMessage,
458
+ tokens: totalIn || totalOut ? { in: totalIn, out: totalOut } : undefined,
459
+ created,
460
+ modified: stat.mtime,
461
+ filePath,
462
+ };
463
+ }
464
+ buildSession(info, runningPids) {
465
+ const isRunning = this.isSessionRunning(info, runningPids);
466
+ const pid = isRunning ? this.findMatchingPid(info, runningPids) : undefined;
467
+ return {
468
+ id: info.id,
469
+ adapter: this.id,
470
+ status: isRunning ? "running" : "stopped",
471
+ startedAt: info.created,
472
+ stoppedAt: isRunning ? undefined : info.modified,
473
+ cwd: info.cwd,
474
+ model: info.model,
475
+ prompt: info.firstPrompt,
476
+ tokens: info.tokens,
477
+ pid,
478
+ meta: {
479
+ cliVersion: info.cliVersion,
480
+ lastMessage: info.lastMessage,
481
+ },
482
+ };
483
+ }
484
+ isSessionRunning(info, runningPids) {
485
+ const sessionCreated = info.created.getTime();
486
+ // 1. Check running PIDs from ps aux
487
+ for (const [, pidInfo] of runningPids) {
488
+ if (pidInfo.args.includes(info.id)) {
489
+ if (this.processStartedAfterSession(pidInfo, sessionCreated))
490
+ return true;
491
+ continue;
492
+ }
493
+ if (info.cwd && pidInfo.cwd === info.cwd) {
494
+ if (this.processStartedAfterSession(pidInfo, sessionCreated))
495
+ return true;
496
+ }
497
+ }
498
+ // 2. Check persisted session metadata
499
+ const meta = this.readSessionMetaSync(info.id);
500
+ if (meta?.pid) {
501
+ if (this.isProcessAlive(meta.pid)) {
502
+ // Cross-check start time for PID recycling
503
+ const pidInfo = runningPids.get(meta.pid);
504
+ if (pidInfo?.startTime && meta.startTime) {
505
+ const currentStartMs = new Date(pidInfo.startTime).getTime();
506
+ const recordedStartMs = new Date(meta.startTime).getTime();
507
+ if (!Number.isNaN(currentStartMs) &&
508
+ !Number.isNaN(recordedStartMs) &&
509
+ Math.abs(currentStartMs - recordedStartMs) > 5000) {
510
+ return false;
511
+ }
512
+ }
513
+ if (meta.startTime) {
514
+ const metaStartMs = new Date(meta.startTime).getTime();
515
+ const sessionMs = new Date(meta.launchedAt).getTime();
516
+ if (!Number.isNaN(metaStartMs) && metaStartMs >= sessionMs - 5000) {
517
+ return true;
518
+ }
519
+ return false;
520
+ }
521
+ return true;
522
+ }
523
+ }
524
+ return false;
525
+ }
526
+ processStartedAfterSession(info, sessionCreatedMs) {
527
+ if (!info.startTime)
528
+ return false;
529
+ const processStartMs = new Date(info.startTime).getTime();
530
+ if (Number.isNaN(processStartMs))
531
+ return false;
532
+ return processStartMs >= sessionCreatedMs - 5000;
533
+ }
534
+ findMatchingPid(info, runningPids) {
535
+ const sessionCreated = info.created.getTime();
536
+ for (const [pid, pidInfo] of runningPids) {
537
+ if (pidInfo.args.includes(info.id)) {
538
+ if (this.processStartedAfterSession(pidInfo, sessionCreated))
539
+ return pid;
540
+ continue;
541
+ }
542
+ if (info.cwd && pidInfo.cwd === info.cwd) {
543
+ if (this.processStartedAfterSession(pidInfo, sessionCreated))
544
+ return pid;
545
+ }
546
+ }
547
+ const meta = this.readSessionMetaSync(info.id);
548
+ if (meta?.pid && this.isProcessAlive(meta.pid)) {
549
+ return meta.pid;
550
+ }
551
+ return undefined;
552
+ }
553
+ async findSession(sessionId) {
554
+ const sessions = await this.discoverSessions();
555
+ // Exact match
556
+ const exact = sessions.find((s) => s.id === sessionId);
557
+ if (exact)
558
+ return exact;
559
+ // Prefix match
560
+ const prefix = sessions.find((s) => s.id.startsWith(sessionId));
561
+ return prefix || null;
562
+ }
563
+ async findPidForSession(sessionId) {
564
+ const session = await this.status(sessionId);
565
+ return session.pid ?? null;
566
+ }
567
+ // --- Session metadata persistence ---
568
+ async writeSessionMeta(meta) {
569
+ await fs.mkdir(this.sessionsMetaDir, { recursive: true });
570
+ let startTime;
571
+ try {
572
+ const { stdout } = await execFileAsync("ps", [
573
+ "-p",
574
+ meta.pid.toString(),
575
+ "-o",
576
+ "lstart=",
577
+ ]);
578
+ startTime = stdout.trim() || undefined;
579
+ }
580
+ catch {
581
+ // Process may have already exited
582
+ }
583
+ const fullMeta = { ...meta, startTime };
584
+ const metaPath = path.join(this.sessionsMetaDir, `${meta.sessionId}.json`);
585
+ await fs.writeFile(metaPath, JSON.stringify(fullMeta, null, 2));
586
+ }
587
+ async readSessionMeta(sessionId) {
588
+ const metaPath = path.join(this.sessionsMetaDir, `${sessionId}.json`);
589
+ try {
590
+ const raw = await fs.readFile(metaPath, "utf-8");
591
+ return JSON.parse(raw);
592
+ }
593
+ catch {
594
+ // Not found
595
+ }
596
+ // Scan all metadata files for matching sessionId
597
+ try {
598
+ const files = await fs.readdir(this.sessionsMetaDir);
599
+ for (const file of files) {
600
+ if (!file.endsWith(".json") || file.startsWith("launch-"))
601
+ continue;
602
+ try {
603
+ const raw = await fs.readFile(path.join(this.sessionsMetaDir, file), "utf-8");
604
+ const meta = JSON.parse(raw);
605
+ if (meta.sessionId === sessionId)
606
+ return meta;
607
+ }
608
+ catch {
609
+ // skip
610
+ }
611
+ }
612
+ }
613
+ catch {
614
+ // Dir doesn't exist
615
+ }
616
+ return null;
617
+ }
618
+ /**
619
+ * Synchronous-style read of session metadata (reads from cache/disk).
620
+ * Used by isSessionRunning which is called in a tight loop.
621
+ * Falls back to null if not found.
622
+ */
623
+ readSessionMetaSync(sessionId) {
624
+ const metaPath = path.join(this.sessionsMetaDir, `${sessionId}.json`);
625
+ try {
626
+ const raw = readFileSync(metaPath, "utf-8");
627
+ return JSON.parse(raw);
628
+ }
629
+ catch {
630
+ return null;
631
+ }
632
+ }
633
+ }
634
+ // --- Utility functions ---
635
+ function defaultIsProcessAlive(pid) {
636
+ try {
637
+ process.kill(pid, 0);
638
+ return true;
639
+ }
640
+ catch {
641
+ return false;
642
+ }
643
+ }
644
+ async function getCodexPids() {
645
+ const pids = new Map();
646
+ try {
647
+ const { stdout } = await execFileAsync("ps", ["aux"]);
648
+ for (const line of stdout.split("\n")) {
649
+ if (!line.includes("codex") || line.includes("grep"))
650
+ continue;
651
+ const fields = line.trim().split(/\s+/);
652
+ if (fields.length < 11)
653
+ continue;
654
+ const pid = parseInt(fields[1], 10);
655
+ const command = fields.slice(10).join(" ");
656
+ // Match codex exec or codex with flags — exclude interactive sessions
657
+ if (!command.startsWith("codex exec") && !command.startsWith("codex --"))
658
+ continue;
659
+ if (pid === process.pid)
660
+ continue;
661
+ let cwd = "";
662
+ try {
663
+ const { stdout: lsofOut } = await execFileAsync("/usr/sbin/lsof", [
664
+ "-p",
665
+ pid.toString(),
666
+ "-Fn",
667
+ ]);
668
+ const lsofLines = lsofOut.split("\n");
669
+ for (let i = 0; i < lsofLines.length; i++) {
670
+ if (lsofLines[i] === "fcwd" && lsofLines[i + 1]?.startsWith("n")) {
671
+ cwd = lsofLines[i + 1].slice(1);
672
+ break;
673
+ }
674
+ }
675
+ }
676
+ catch {
677
+ // lsof might fail
678
+ }
679
+ let startTime;
680
+ try {
681
+ const { stdout: lstart } = await execFileAsync("ps", [
682
+ "-p",
683
+ pid.toString(),
684
+ "-o",
685
+ "lstart=",
686
+ ]);
687
+ startTime = lstart.trim() || undefined;
688
+ }
689
+ catch {
690
+ // ps might fail
691
+ }
692
+ pids.set(pid, { pid, cwd, args: command, startTime });
693
+ }
694
+ }
695
+ catch {
696
+ // ps failed
697
+ }
698
+ return pids;
699
+ }
700
+ function sleep(ms) {
701
+ return new Promise((resolve) => setTimeout(resolve, ms));
702
+ }