@ai-devkit/agent-manager 0.10.0 → 0.12.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 (57) hide show
  1. package/dist/AgentManager.d.ts +14 -1
  2. package/dist/AgentManager.d.ts.map +1 -1
  3. package/dist/AgentManager.js +38 -0
  4. package/dist/AgentManager.js.map +1 -1
  5. package/dist/adapters/AgentAdapter.d.ts +66 -0
  6. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  7. package/dist/adapters/ClaudeCodeAdapter.d.ts +22 -1
  8. package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
  9. package/dist/adapters/ClaudeCodeAdapter.js +108 -4
  10. package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
  11. package/dist/adapters/CodexAdapter.d.ts +14 -1
  12. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  13. package/dist/adapters/CodexAdapter.js +105 -6
  14. package/dist/adapters/CodexAdapter.js.map +1 -1
  15. package/dist/adapters/GeminiCliAdapter.d.ts +9 -1
  16. package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
  17. package/dist/adapters/GeminiCliAdapter.js +77 -6
  18. package/dist/adapters/GeminiCliAdapter.js.map +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/terminal/TerminalFocusManager.d.ts.map +1 -1
  23. package/dist/terminal/TerminalFocusManager.js +5 -7
  24. package/dist/terminal/TerminalFocusManager.js.map +1 -1
  25. package/dist/terminal/TtyWriter.d.ts.map +1 -1
  26. package/dist/terminal/TtyWriter.js +3 -12
  27. package/dist/terminal/TtyWriter.js.map +1 -1
  28. package/dist/utils/ClaudeSessionParser.d.ts +2 -0
  29. package/dist/utils/ClaudeSessionParser.d.ts.map +1 -1
  30. package/dist/utils/ClaudeSessionParser.js +48 -5
  31. package/dist/utils/ClaudeSessionParser.js.map +1 -1
  32. package/dist/utils/applescript.d.ts +6 -0
  33. package/dist/utils/applescript.d.ts.map +1 -0
  34. package/dist/utils/applescript.js +14 -0
  35. package/dist/utils/applescript.js.map +1 -0
  36. package/dist/utils/session.d.ts +34 -5
  37. package/dist/utils/session.d.ts.map +1 -1
  38. package/dist/utils/session.js +90 -44
  39. package/dist/utils/session.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/AgentManager.ts +55 -3
  42. package/src/__tests__/AgentManager.test.ts +134 -2
  43. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +229 -3
  44. package/src/__tests__/adapters/CodexAdapter.test.ts +123 -3
  45. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +133 -0
  46. package/src/__tests__/utils/ClaudeSessionParser.test.ts +195 -0
  47. package/src/__tests__/utils/session.test.ts +79 -43
  48. package/src/adapters/AgentAdapter.ts +76 -0
  49. package/src/adapters/ClaudeCodeAdapter.ts +136 -6
  50. package/src/adapters/CodexAdapter.ts +126 -8
  51. package/src/adapters/GeminiCliAdapter.ts +102 -7
  52. package/src/index.ts +9 -1
  53. package/src/terminal/TerminalFocusManager.ts +1 -4
  54. package/src/terminal/TtyWriter.ts +1 -11
  55. package/src/utils/ClaudeSessionParser.ts +59 -5
  56. package/src/utils/applescript.ts +10 -0
  57. package/src/utils/session.ts +86 -45
@@ -12,10 +12,17 @@
12
12
 
13
13
  import * as fs from 'fs';
14
14
  import * as path from 'path';
15
- import type { AgentAdapter, AgentInfo, ProcessInfo, ConversationMessage } from './AgentAdapter';
15
+ import type {
16
+ AgentAdapter,
17
+ AgentInfo,
18
+ ProcessInfo,
19
+ ConversationMessage,
20
+ SessionSummary,
21
+ ListSessionsOptions,
22
+ } from './AgentAdapter';
16
23
  import { AgentStatus } from './AgentAdapter';
17
24
  import { listAgentProcesses, enrichProcesses } from '../utils/process';
18
- import { batchGetSessionFileBirthtimes } from '../utils/session';
25
+ import { batchGetSessionFileBirthtimes, isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session';
19
26
  import type { SessionFile } from '../utils/session';
20
27
  import { matchProcessesToSessions, generateAgentName } from '../utils/matching';
21
28
 
@@ -326,12 +333,8 @@ export class CodexAdapter implements AgentAdapter {
326
333
  getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] {
327
334
  const verbose = options?.verbose ?? false;
328
335
 
329
- let content: string;
330
- try {
331
- content = fs.readFileSync(sessionFilePath, 'utf-8');
332
- } catch {
333
- return [];
334
- }
336
+ const content = safeReadFile(sessionFilePath);
337
+ if (content === undefined) return [];
335
338
 
336
339
  const lines = content.trim().split('\n');
337
340
  const messages: ConversationMessage[] = [];
@@ -372,4 +375,119 @@ export class CodexAdapter implements AgentAdapter {
372
375
 
373
376
  return messages;
374
377
  }
378
+
379
+ async listSessions(opts?: ListSessionsOptions): Promise<SessionSummary[]> {
380
+ if (!isDirectory(this.codexSessionsDir)) return [];
381
+
382
+ const files = this.collectAllSessionFiles();
383
+ const summaries: SessionSummary[] = [];
384
+
385
+ for (const filePath of files) {
386
+ const summary = this.fileToSessionSummary(filePath);
387
+ if (!summary) continue;
388
+ if (opts?.cwd !== undefined && summary.cwd !== opts.cwd) continue;
389
+ summaries.push(summary);
390
+ }
391
+
392
+ return summaries;
393
+ }
394
+
395
+ /**
396
+ * Walk every YYYY/MM/DD directory under `codexSessionsDir` and return
397
+ * absolute paths of `.jsonl` files. Tolerates malformed layouts
398
+ * (skips entries that aren't directories at the expected depth).
399
+ */
400
+ private collectAllSessionFiles(): string[] {
401
+ const out: string[] = [];
402
+
403
+ for (const yearEntry of safeReaddir(this.codexSessionsDir)) {
404
+ const yearDir = path.join(this.codexSessionsDir, yearEntry);
405
+ if (!isDirectory(yearDir)) continue;
406
+
407
+ for (const monthEntry of safeReaddir(yearDir)) {
408
+ const monthDir = path.join(yearDir, monthEntry);
409
+ if (!isDirectory(monthDir)) continue;
410
+
411
+ for (const dayEntry of safeReaddir(monthDir)) {
412
+ const dayDir = path.join(monthDir, dayEntry);
413
+ if (!isDirectory(dayDir)) continue;
414
+
415
+ for (const fileEntry of safeReaddir(dayDir)) {
416
+ if (!fileEntry.endsWith('.jsonl')) continue;
417
+ out.push(path.join(dayDir, fileEntry));
418
+ }
419
+ }
420
+ }
421
+ }
422
+
423
+ return out;
424
+ }
425
+
426
+ /**
427
+ * Read a Codex session JSONL file and produce a {@link SessionSummary}.
428
+ * Returns null when the file is unreadable, has no `session_meta`, or
429
+ * lacks a session id.
430
+ */
431
+ private fileToSessionSummary(filePath: string): SessionSummary | null {
432
+ const content = safeReadFile(filePath);
433
+ if (content === undefined) return null;
434
+
435
+ const allLines = content.trim().split('\n');
436
+ if (!allLines[0]) return null;
437
+
438
+ let metaEntry: CodexEventEntry;
439
+ try {
440
+ metaEntry = JSON.parse(allLines[0]);
441
+ } catch {
442
+ return null;
443
+ }
444
+
445
+ if (metaEntry.type !== 'session_meta' || !metaEntry.payload?.id) {
446
+ return null;
447
+ }
448
+
449
+ let firstUserMessage = '';
450
+ let lastTimestamp: Date | null = null;
451
+
452
+ for (let i = 1; i < allLines.length; i++) {
453
+ let entry: CodexEventEntry;
454
+ try {
455
+ entry = JSON.parse(allLines[i]);
456
+ } catch {
457
+ continue;
458
+ }
459
+
460
+ const ts = this.parseTimestamp(entry.timestamp);
461
+ if (ts) lastTimestamp = ts;
462
+
463
+ if (
464
+ !firstUserMessage &&
465
+ entry.payload?.type === 'user_message' &&
466
+ typeof entry.payload.message === 'string' &&
467
+ entry.payload.message.trim().length > 0
468
+ ) {
469
+ firstUserMessage = entry.payload.message.trim();
470
+ }
471
+ }
472
+
473
+ const stat = safeStat(filePath);
474
+
475
+ const startedAt =
476
+ this.parseTimestamp(metaEntry.payload.timestamp) ||
477
+ lastTimestamp ||
478
+ stat?.birthtime ||
479
+ stat?.mtime ||
480
+ new Date();
481
+ const lastActive = lastTimestamp || startedAt;
482
+
483
+ return {
484
+ type: 'codex',
485
+ sessionId: metaEntry.payload.id,
486
+ cwd: metaEntry.payload.cwd || '',
487
+ firstUserMessage,
488
+ lastActive,
489
+ startedAt,
490
+ sessionFilePath: filePath,
491
+ };
492
+ }
375
493
  }
@@ -13,9 +13,17 @@
13
13
  import * as crypto from 'crypto';
14
14
  import * as fs from 'fs';
15
15
  import * as path from 'path';
16
- import type { AgentAdapter, AgentInfo, ProcessInfo, ConversationMessage } from './AgentAdapter';
16
+ import type {
17
+ AgentAdapter,
18
+ AgentInfo,
19
+ ProcessInfo,
20
+ ConversationMessage,
21
+ SessionSummary,
22
+ ListSessionsOptions,
23
+ } from './AgentAdapter';
17
24
  import { AgentStatus } from './AgentAdapter';
18
25
  import { listAgentProcesses, enrichProcesses } from '../utils/process';
26
+ import { isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session';
19
27
  import type { SessionFile } from '../utils/session';
20
28
  import { matchProcessesToSessions, generateAgentName } from '../utils/matching';
21
29
 
@@ -441,12 +449,8 @@ export class GeminiCliAdapter implements AgentAdapter {
441
449
  getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] {
442
450
  const verbose = options?.verbose ?? false;
443
451
 
444
- let content: string;
445
- try {
446
- content = fs.readFileSync(sessionFilePath, 'utf-8');
447
- } catch {
448
- return [];
449
- }
452
+ const content = safeReadFile(sessionFilePath);
453
+ if (content === undefined) return [];
450
454
 
451
455
  let parsed: GeminiSessionFile;
452
456
  try {
@@ -485,4 +489,95 @@ export class GeminiCliAdapter implements AgentAdapter {
485
489
 
486
490
  return messages;
487
491
  }
492
+
493
+ async listSessions(opts?: ListSessionsOptions): Promise<SessionSummary[]> {
494
+ if (!isDirectory(this.geminiTmpDir)) return [];
495
+
496
+ const summaries: SessionSummary[] = [];
497
+
498
+ for (const shortId of safeReaddir(this.geminiTmpDir)) {
499
+ const chatsDir = path.join(
500
+ this.geminiTmpDir,
501
+ shortId,
502
+ GeminiCliAdapter.CHATS_DIR_NAME,
503
+ );
504
+ if (!isDirectory(chatsDir)) continue;
505
+
506
+ for (const fileName of safeReaddir(chatsDir)) {
507
+ if (
508
+ !fileName.startsWith(GeminiCliAdapter.SESSION_FILE_PREFIX) ||
509
+ !fileName.endsWith('.json')
510
+ ) {
511
+ continue;
512
+ }
513
+
514
+ const filePath = path.join(chatsDir, fileName);
515
+ const summary = this.fileToSessionSummary(filePath);
516
+ if (!summary) continue;
517
+ if (opts?.cwd !== undefined && summary.cwd !== opts.cwd) continue;
518
+ summaries.push(summary);
519
+ }
520
+ }
521
+
522
+ return summaries;
523
+ }
524
+
525
+ /**
526
+ * Read a Gemini session JSON file and produce a {@link SessionSummary}.
527
+ * Returns null when the file is unreadable, the JSON doesn't parse,
528
+ * or the body lacks a sessionId.
529
+ */
530
+ private fileToSessionSummary(filePath: string): SessionSummary | null {
531
+ const content = safeReadFile(filePath);
532
+ if (content === undefined) return null;
533
+
534
+ let parsed: GeminiSessionFile;
535
+ try {
536
+ parsed = JSON.parse(content);
537
+ } catch {
538
+ return null;
539
+ }
540
+
541
+ if (!parsed.sessionId) return null;
542
+
543
+ const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
544
+ const firstUserMessage = this.extractFirstUserMessage(messages);
545
+
546
+ const cwd =
547
+ Array.isArray(parsed.directories) && parsed.directories.length > 0
548
+ ? parsed.directories[0]
549
+ : '';
550
+
551
+ const stat = safeStat(filePath);
552
+
553
+ const lastEntryTimestamp = this.parseTimestamp(
554
+ messages.length > 0 ? messages[messages.length - 1]?.timestamp : undefined,
555
+ );
556
+ const lastActive =
557
+ this.parseTimestamp(parsed.lastUpdated) ||
558
+ lastEntryTimestamp ||
559
+ stat?.mtime ||
560
+ new Date();
561
+ const startedAt =
562
+ this.parseTimestamp(parsed.startTime) || stat?.birthtime || stat?.mtime || lastActive;
563
+
564
+ return {
565
+ type: 'gemini_cli',
566
+ sessionId: parsed.sessionId,
567
+ cwd,
568
+ firstUserMessage,
569
+ lastActive,
570
+ startedAt,
571
+ sessionFilePath: filePath,
572
+ };
573
+ }
574
+
575
+ private extractFirstUserMessage(messages: GeminiMessageEntry[]): string {
576
+ for (const entry of messages) {
577
+ if (entry?.type !== 'user') continue;
578
+ const text = this.messageText(entry).trim();
579
+ if (text) return text;
580
+ }
581
+ return '';
582
+ }
488
583
  }
package/src/index.ts CHANGED
@@ -4,7 +4,15 @@ export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter';
4
4
  export { CodexAdapter } from './adapters/CodexAdapter';
5
5
  export { GeminiCliAdapter } from './adapters/GeminiCliAdapter';
6
6
  export { AgentStatus } from './adapters/AgentAdapter';
7
- export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo, ConversationMessage } from './adapters/AgentAdapter';
7
+ export type {
8
+ AgentAdapter,
9
+ AgentType,
10
+ AgentInfo,
11
+ ProcessInfo,
12
+ ConversationMessage,
13
+ SessionSummary,
14
+ ListSessionsOptions,
15
+ } from './adapters/AgentAdapter';
8
16
 
9
17
  export { TerminalFocusManager, TerminalType } from './terminal/TerminalFocusManager';
10
18
  export type { TerminalLocation } from './terminal/TerminalFocusManager';
@@ -1,6 +1,7 @@
1
1
  import { execFile } from 'child_process';
2
2
  import { promisify } from 'util';
3
3
  import { getProcessTty } from '../utils/process';
4
+ import { escapeAppleScript } from '../utils/applescript';
4
5
 
5
6
  const execFileAsync = promisify(execFile);
6
7
 
@@ -17,10 +18,6 @@ export interface TerminalLocation {
17
18
  tty: string; // e.g., "/dev/ttys030"
18
19
  }
19
20
 
20
- function escapeAppleScript(text: string): string {
21
- return text.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
22
- }
23
-
24
21
  export class TerminalFocusManager {
25
22
  /**
26
23
  * Find the terminal location (emulator info) for a given process ID
@@ -2,20 +2,10 @@ import { execFile } from 'child_process';
2
2
  import { promisify } from 'util';
3
3
  import type { TerminalLocation } from './TerminalFocusManager';
4
4
  import { TerminalType } from './TerminalFocusManager';
5
+ import { escapeAppleScript } from '../utils/applescript';
5
6
 
6
7
  const execFileAsync = promisify(execFile);
7
8
 
8
- /**
9
- * Escape a string for safe use inside an AppleScript double-quoted string.
10
- * Backslashes, double quotes, and newlines must be escaped.
11
- */
12
- function escapeAppleScript(text: string): string {
13
- return text
14
- .replace(/\\/g, '\\\\')
15
- .replace(/"/g, '\\"')
16
- .replace(/\r\n|\r|\n/g, '\\n');
17
- }
18
-
19
9
  export class TtyWriter {
20
10
  /**
21
11
  * Send a message as keyboard input to a terminal session.
@@ -47,6 +47,8 @@ export interface ClaudeSession {
47
47
  lastEntryType?: string;
48
48
  isInterrupted: boolean;
49
49
  lastUserMessage?: string;
50
+ /** First meaningful user prompt in the session (post noise filter) */
51
+ firstUserMessage?: string;
50
52
  }
51
53
 
52
54
  /** Entry types that are metadata, not conversation state. */
@@ -91,6 +93,7 @@ export class ClaudeSessionParser {
91
93
  let lastCwd: string | undefined;
92
94
  let isInterrupted = false;
93
95
  let lastUserMessage: string | undefined;
96
+ let firstUserMessage: string | undefined;
94
97
 
95
98
  for (const line of allLines) {
96
99
  try {
@@ -125,6 +128,9 @@ export class ClaudeSessionParser {
125
128
  const text = this.extractUserMessageText(msgContent);
126
129
  if (text) {
127
130
  lastUserMessage = text;
131
+ if (!firstUserMessage) {
132
+ firstUserMessage = text;
133
+ }
128
134
  }
129
135
  } else {
130
136
  isInterrupted = false;
@@ -144,6 +150,7 @@ export class ClaudeSessionParser {
144
150
  lastEntryType,
145
151
  isInterrupted,
146
152
  lastUserMessage,
153
+ firstUserMessage,
147
154
  };
148
155
  }
149
156
 
@@ -340,9 +347,9 @@ export class ClaudeSessionParser {
340
347
  if (!content) return undefined;
341
348
 
342
349
  if (typeof content === 'string') {
343
- const trimmed = content.trim();
344
- if (role === 'user' && isNoiseMessage(trimmed)) return undefined;
345
- return trimmed || undefined;
350
+ const cleaned = stripHarnessTags(content);
351
+ if (role === 'user' && isNoiseMessage(cleaned)) return undefined;
352
+ return cleaned || undefined;
346
353
  }
347
354
 
348
355
  if (!Array.isArray(content)) return undefined;
@@ -351,8 +358,10 @@ export class ClaudeSessionParser {
351
358
 
352
359
  for (const block of content) {
353
360
  if (block.type === 'text' && block.text?.trim()) {
354
- if (role === 'user' && isNoiseMessage(block.text.trim())) continue;
355
- parts.push(block.text.trim());
361
+ const cleaned = stripHarnessTags(block.text);
362
+ if (!cleaned) continue;
363
+ if (role === 'user' && isNoiseMessage(cleaned)) continue;
364
+ parts.push(cleaned);
356
365
  } else if (block.type === 'tool_use' && verbose) {
357
366
  const inputSummary = block.input?.file_path || block.input?.pattern || block.input?.command || '';
358
367
  parts.push(`[Tool: ${block.name}]${inputSummary ? ' ' + inputSummary : ''}`);
@@ -367,6 +376,51 @@ export class ClaudeSessionParser {
367
376
  }
368
377
  }
369
378
 
379
+ /**
380
+ * Tags whose entire block (including content) should be dropped — they are
381
+ * harness-injected prompt context (system reminders, hook output, command
382
+ * stdout), not meaningful conversation content.
383
+ */
384
+ const HARNESS_DROP_TAGS = [
385
+ 'system-reminder',
386
+ 'local-command-stdout',
387
+ 'local-command-stderr',
388
+ 'user-prompt-submit-hook',
389
+ 'command-stdout',
390
+ 'command-stderr',
391
+ 'bash-input',
392
+ 'bash-stdout',
393
+ 'bash-stderr',
394
+ 'command-message',
395
+ ] as const;
396
+
397
+ const HARNESS_DROP_RE = new RegExp(
398
+ `<(${HARNESS_DROP_TAGS.join('|')})>[\\s\\S]*?</\\1>`,
399
+ 'g',
400
+ );
401
+
402
+ const COMMAND_INVOCATION_RE =
403
+ /<command-name>([^<]+)<\/command-name>(?:\s*<command-args>([\s\S]*?)<\/command-args>)?/g;
404
+
405
+ /**
406
+ * Remove harness-injected XML blocks from message text and collapse
407
+ * <command-name>/<command-args> pairs into a "/name args" shorthand.
408
+ *
409
+ * Returns the cleaned, trimmed text. Returns an empty string if nothing
410
+ * survives stripping.
411
+ */
412
+ function stripHarnessTags(text: string): string {
413
+ let out = text.replace(HARNESS_DROP_RE, '');
414
+
415
+ out = out.replace(COMMAND_INVOCATION_RE, (_match, rawName: string, rawArgs?: string) => {
416
+ const name = rawName.trim();
417
+ const args = rawArgs?.trim();
418
+ return args ? `${name} ${args}` : name;
419
+ });
420
+
421
+ return out.replace(/\n{3,}/g, '\n\n').trim();
422
+ }
423
+
370
424
  /** Check if a message is noise (not a meaningful user intent). */
371
425
  function isNoiseMessage(text: string): boolean {
372
426
  return (
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Escape a string for safe use inside an AppleScript double-quoted string.
3
+ * Backslashes, double quotes, and newlines must be escaped.
4
+ */
5
+ export function escapeAppleScript(text: string): string {
6
+ return text
7
+ .replace(/\\/g, '\\\\')
8
+ .replace(/"/g, '\\"')
9
+ .replace(/\r\n|\r|\n/g, '\\n');
10
+ }
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Session File Utilities
3
3
  *
4
- * Shell command wrappers for discovering session files and their birth times.
5
- * Uses `stat` to get exact epoch-second birth timestamps without reading file contents.
4
+ * Utilities for discovering session files and their birth times.
5
+ * Uses Node.js fs APIs to get birth timestamps without reading file contents.
6
6
  */
7
7
 
8
+ import * as fs from 'fs';
8
9
  import * as path from 'path';
9
- import { execSync } from 'child_process';
10
10
 
11
11
  /**
12
12
  * Represents a session file with its birth time metadata.
@@ -29,63 +29,104 @@ export interface SessionFile {
29
29
  }
30
30
 
31
31
  /**
32
- * Get birth times for .jsonl session files across multiple directories in a single shell call.
33
- *
34
- * Combines all directory globs into one `stat` command to avoid per-directory exec overhead.
35
- * Returns empty array if no directories have .jsonl files or command fails.
36
- * resolvedCwd is left empty — the adapter must set it.
32
+ * Check whether a path exists and is a directory.
33
+ * Returns false on any error (missing path, permission denied, broken symlink, etc.).
37
34
  */
38
- export function batchGetSessionFileBirthtimes(dirs: string[]): SessionFile[] {
39
- if (dirs.length === 0) return [];
35
+ export function isDirectory(p: string): boolean {
36
+ return safeStat(p)?.isDirectory() ?? false;
37
+ }
40
38
 
39
+ /**
40
+ * `fs.statSync` that swallows errors and returns `undefined` on failure.
41
+ * Callers can pull whichever fields they need (mtime, birthtime, ...).
42
+ */
43
+ export function safeStat(filePath: string): fs.Stats | undefined {
41
44
  try {
42
- const isMacOS = process.platform === 'darwin';
43
- const globs = dirs.map((d) => `"${d}"/*.jsonl`).join(' ');
44
- // || true prevents non-zero exit when some globs have no .jsonl matches
45
- const command = isMacOS
46
- ? `stat -f '%B %N' ${globs} 2>/dev/null || true`
47
- : `stat --format='%W %n' ${globs} 2>/dev/null || true`;
45
+ return fs.statSync(filePath);
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ }
48
50
 
49
- const output = execSync(command, { encoding: 'utf-8' });
51
+ /**
52
+ * `fs.readFileSync` (utf-8) that swallows errors and returns `undefined`
53
+ * on failure. Use when an unreadable file should be skipped rather than
54
+ * raised.
55
+ */
56
+ export function safeReadFile(filePath: string): string | undefined {
57
+ try {
58
+ return fs.readFileSync(filePath, 'utf-8');
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ }
50
63
 
51
- return parseStatOutput(output);
64
+ /**
65
+ * `fs.readdirSync` that swallows errors and returns `[]` on failure.
66
+ * Useful when walking optional/transient directories where missing or
67
+ * unreadable entries should be skipped silently.
68
+ */
69
+ export function safeReaddir(dir: string): string[] {
70
+ try {
71
+ return fs.readdirSync(dir);
52
72
  } catch {
53
73
  return [];
54
74
  }
55
75
  }
56
76
 
57
77
  /**
58
- * Parse stat output lines into SessionFile entries.
78
+ * List entries in a directory that end with `.jsonl`. Returns `[]` on
79
+ * read errors. The result preserves directory order (no sorting).
59
80
  */
60
- function parseStatOutput(output: string): SessionFile[] {
61
- const results: SessionFile[] = [];
62
-
63
- for (const rawLine of output.trim().split('\n')) {
64
- const line = rawLine.trim();
65
- if (!line) continue;
66
-
67
- // Format: "<epoch_seconds> <filepath>"
68
- const spaceIdx = line.indexOf(' ');
69
- if (spaceIdx === -1) continue;
70
-
71
- const epochStr = line.slice(0, spaceIdx);
72
- const filePath = line.slice(spaceIdx + 1).trim();
73
-
74
- const epochSeconds = parseInt(epochStr, 10);
75
- if (!Number.isFinite(epochSeconds) || epochSeconds <= 0) continue;
81
+ export function listJsonl(dir: string): string[] {
82
+ return safeReaddir(dir).filter((name) => name.endsWith('.jsonl'));
83
+ }
76
84
 
77
- const fileName = path.basename(filePath);
78
- if (!fileName.endsWith('.jsonl')) continue;
85
+ /**
86
+ * Get birth times for .jsonl session files across multiple directories.
87
+ *
88
+ * Enumerates each directory with readdirSync and stats each .jsonl file
89
+ * to get its birth time. No shell commands are used.
90
+ * Returns empty array if no directories have .jsonl files or reads fail.
91
+ * resolvedCwd is left empty — the adapter must set it.
92
+ */
93
+ export function batchGetSessionFileBirthtimes(dirs: string[]): SessionFile[] {
94
+ if (dirs.length === 0) return [];
79
95
 
80
- const sessionId = fileName.replace(/\.jsonl$/, '');
96
+ const results: SessionFile[] = [];
81
97
 
82
- results.push({
83
- sessionId,
84
- filePath,
85
- projectDir: path.dirname(filePath),
86
- birthtimeMs: epochSeconds * 1000,
87
- resolvedCwd: '',
88
- });
98
+ for (const dir of dirs) {
99
+ let entries: string[];
100
+ try {
101
+ entries = fs.readdirSync(dir);
102
+ } catch {
103
+ continue;
104
+ }
105
+
106
+ for (const entry of entries) {
107
+ if (!entry.endsWith('.jsonl')) continue;
108
+
109
+ const filePath = path.join(dir, entry);
110
+
111
+ let birthtimeMs: number;
112
+ try {
113
+ birthtimeMs = fs.statSync(filePath).birthtimeMs;
114
+ } catch {
115
+ continue;
116
+ }
117
+
118
+ if (!Number.isFinite(birthtimeMs) || birthtimeMs <= 0) continue;
119
+
120
+ const sessionId = entry.replace(/\.jsonl$/, '');
121
+
122
+ results.push({
123
+ sessionId,
124
+ filePath,
125
+ projectDir: dir,
126
+ birthtimeMs,
127
+ resolvedCwd: '',
128
+ });
129
+ }
89
130
  }
90
131
 
91
132
  return results;