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