@ai-devkit/agent-manager 0.9.0 → 0.11.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 (63) hide show
  1. package/dist/AgentManager.d.ts +21 -1
  2. package/dist/AgentManager.d.ts.map +1 -1
  3. package/dist/AgentManager.js +47 -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 +14 -43
  8. package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
  9. package/dist/adapters/ClaudeCodeAdapter.js +60 -275
  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 +36 -27
  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 +114 -0
  29. package/dist/utils/ClaudeSessionParser.d.ts.map +1 -0
  30. package/dist/utils/ClaudeSessionParser.js +377 -0
  31. package/dist/utils/ClaudeSessionParser.js.map +1 -0
  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/process.d.ts +3 -4
  37. package/dist/utils/process.d.ts.map +1 -1
  38. package/dist/utils/process.js +11 -15
  39. package/dist/utils/process.js.map +1 -1
  40. package/dist/utils/session.d.ts +34 -5
  41. package/dist/utils/session.d.ts.map +1 -1
  42. package/dist/utils/session.js +90 -44
  43. package/dist/utils/session.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/AgentManager.ts +66 -4
  46. package/src/__tests__/AgentManager.test.ts +134 -2
  47. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +188 -32
  48. package/src/__tests__/adapters/CodexAdapter.test.ts +123 -3
  49. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +133 -0
  50. package/src/__tests__/utils/ClaudeSessionParser.test.ts +195 -0
  51. package/src/__tests__/utils/process.test.ts +27 -27
  52. package/src/__tests__/utils/session.test.ts +79 -43
  53. package/src/adapters/AgentAdapter.ts +76 -0
  54. package/src/adapters/ClaudeCodeAdapter.ts +82 -356
  55. package/src/adapters/CodexAdapter.ts +126 -8
  56. package/src/adapters/GeminiCliAdapter.ts +102 -7
  57. package/src/index.ts +9 -1
  58. package/src/terminal/TerminalFocusManager.ts +35 -26
  59. package/src/terminal/TtyWriter.ts +1 -11
  60. package/src/utils/ClaudeSessionParser.ts +437 -0
  61. package/src/utils/applescript.ts +10 -0
  62. package/src/utils/process.ts +21 -24
  63. 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,8 +1,8 @@
1
- import { exec, execFile } from 'child_process';
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
- const execAsync = promisify(exec);
6
6
  const execFileAsync = promisify(execFile);
7
7
 
8
8
  export enum TerminalType {
@@ -67,17 +67,16 @@ export class TerminalFocusManager {
67
67
  default:
68
68
  return false;
69
69
  }
70
- } catch (error) {
70
+ } catch {
71
71
  return false;
72
72
  }
73
73
  }
74
74
 
75
75
  private async findTmuxPane(tty: string): Promise<TerminalLocation | null> {
76
76
  try {
77
- // List all panes with their TTYs and identifiers
78
- // Format: /dev/ttys001|my-session:1.1
79
- // using | as separator to handle spaces in session names
80
- const { stdout } = await execAsync("tmux list-panes -a -F '#{pane_tty}|#{session_name}:#{window_index}.#{pane_index}'");
77
+ const { stdout } = await execFileAsync('tmux', [
78
+ 'list-panes', '-a', '-F', '#{pane_tty}|#{session_name}:#{window_index}.#{pane_index}'
79
+ ]);
81
80
 
82
81
  const lines = stdout.trim().split('\n');
83
82
  for (const line of lines) {
@@ -91,7 +90,7 @@ export class TerminalFocusManager {
91
90
  };
92
91
  }
93
92
  }
94
- } catch (error) {
93
+ } catch {
95
94
  // tmux might not be installed or running
96
95
  }
97
96
  return null;
@@ -100,15 +99,19 @@ export class TerminalFocusManager {
100
99
  private async findITerm2Session(tty: string): Promise<TerminalLocation | null> {
101
100
  try {
102
101
  // Check if iTerm2 is running first to avoid launching it
103
- const { stdout: isRunning } = await execAsync('pgrep -x iTerm2 || echo "no"');
104
- if (isRunning.trim() === "no") return null;
102
+ await execFileAsync('pgrep', ['-x', 'iTerm2']);
103
+ } catch {
104
+ return null;
105
+ }
105
106
 
107
+ try {
108
+ const escapedTty = escapeAppleScript(tty);
106
109
  const script = `
107
110
  tell application "iTerm"
108
111
  repeat with w in windows
109
112
  repeat with t in tabs of w
110
113
  repeat with s in sessions of t
111
- if tty of s is "${tty}" then
114
+ if tty of s is "${escapedTty}" then
112
115
  return "found"
113
116
  end if
114
117
  end repeat
@@ -117,7 +120,7 @@ export class TerminalFocusManager {
117
120
  end tell
118
121
  `;
119
122
 
120
- const { stdout } = await execAsync(`osascript -e '${script}'`);
123
+ const { stdout } = await execFileAsync('osascript', ['-e', script]);
121
124
  if (stdout.trim() === "found") {
122
125
  return {
123
126
  type: TerminalType.ITERM2,
@@ -125,23 +128,27 @@ export class TerminalFocusManager {
125
128
  tty
126
129
  };
127
130
  }
128
- } catch (error) {
129
- // iTerm2 not found or script failed
131
+ } catch {
132
+ // iTerm2 script failed
130
133
  }
131
134
  return null;
132
135
  }
133
136
 
134
137
  private async findTerminalAppWindow(tty: string): Promise<TerminalLocation | null> {
135
138
  try {
136
- // Check if Terminal is running
137
- const { stdout: isRunning } = await execAsync('ps -eo pid=,comm= | grep "Terminal.app" || echo "no"');
138
- if (isRunning.trim() === "no") return null;
139
+ // Check if Terminal.app is running
140
+ await execFileAsync('pgrep', ['-x', 'Terminal']);
141
+ } catch {
142
+ return null;
143
+ }
139
144
 
145
+ try {
146
+ const escapedTty = escapeAppleScript(tty);
140
147
  const script = `
141
148
  tell application "Terminal"
142
149
  repeat with w in windows
143
150
  repeat with t in tabs of w
144
- if tty of t is "${tty}" then
151
+ if tty of t is "${escapedTty}" then
145
152
  return "found"
146
153
  end if
147
154
  end repeat
@@ -149,7 +156,7 @@ export class TerminalFocusManager {
149
156
  end tell
150
157
  `;
151
158
 
152
- const { stdout } = await execAsync(`osascript -e '${script}'`);
159
+ const { stdout } = await execFileAsync('osascript', ['-e', script]);
153
160
  if (stdout.trim() === "found") {
154
161
  return {
155
162
  type: TerminalType.TERMINAL_APP,
@@ -157,8 +164,8 @@ export class TerminalFocusManager {
157
164
  tty
158
165
  };
159
166
  }
160
- } catch (error) {
161
- // Terminal not found or script failed
167
+ } catch {
168
+ // Terminal.app script failed
162
169
  }
163
170
  return null;
164
171
  }
@@ -167,19 +174,20 @@ export class TerminalFocusManager {
167
174
  try {
168
175
  await execFileAsync('tmux', ['switch-client', '-t', identifier]);
169
176
  return true;
170
- } catch (error) {
177
+ } catch {
171
178
  return false;
172
179
  }
173
180
  }
174
181
 
175
182
  private async focusITerm2Session(tty: string): Promise<boolean> {
183
+ const escapedTty = escapeAppleScript(tty);
176
184
  const script = `
177
185
  tell application "iTerm"
178
186
  activate
179
187
  repeat with w in windows
180
188
  repeat with t in tabs of w
181
189
  repeat with s in sessions of t
182
- if tty of s is "${tty}" then
190
+ if tty of s is "${escapedTty}" then
183
191
  select s
184
192
  return "true"
185
193
  end if
@@ -188,17 +196,18 @@ export class TerminalFocusManager {
188
196
  end repeat
189
197
  end tell
190
198
  `;
191
- const { stdout } = await execAsync(`osascript -e '${script}'`);
199
+ const { stdout } = await execFileAsync('osascript', ['-e', script]);
192
200
  return stdout.trim() === "true";
193
201
  }
194
202
 
195
203
  private async focusTerminalAppWindow(tty: string): Promise<boolean> {
204
+ const escapedTty = escapeAppleScript(tty);
196
205
  const script = `
197
206
  tell application "Terminal"
198
207
  activate
199
208
  repeat with w in windows
200
209
  repeat with t in tabs of w
201
- if tty of t is "${tty}" then
210
+ if tty of t is "${escapedTty}" then
202
211
  set index of w to 1
203
212
  set selected tab of w to t
204
213
  return "true"
@@ -207,7 +216,7 @@ export class TerminalFocusManager {
207
216
  end repeat
208
217
  end tell
209
218
  `;
210
- const { stdout } = await execAsync(`osascript -e '${script}'`);
219
+ const { stdout } = await execFileAsync('osascript', ['-e', script]);
211
220
  return stdout.trim() === "true";
212
221
  }
213
222
  }
@@ -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.