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