@ai-devkit/agent-manager 0.10.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 (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 +14 -1
  8. package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
  9. package/dist/adapters/ClaudeCodeAdapter.js +60 -0
  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 +159 -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 +76 -2
  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
@@ -2,8 +2,8 @@
2
2
  /**
3
3
  * Session File Utilities
4
4
  *
5
- * Shell command wrappers for discovering session files and their birth times.
6
- * Uses `stat` to get exact epoch-second birth timestamps without reading file contents.
5
+ * Utilities for discovering session files and their birth times.
6
+ * Uses Node.js fs APIs to get birth timestamps without reading file contents.
7
7
  */
8
8
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
9
9
  if (k2 === undefined) k2 = k;
@@ -39,62 +39,108 @@ var __importStar = (this && this.__importStar) || (function () {
39
39
  };
40
40
  })();
41
41
  Object.defineProperty(exports, "__esModule", { value: true });
42
+ exports.isDirectory = isDirectory;
43
+ exports.safeStat = safeStat;
44
+ exports.safeReadFile = safeReadFile;
45
+ exports.safeReaddir = safeReaddir;
46
+ exports.listJsonl = listJsonl;
42
47
  exports.batchGetSessionFileBirthtimes = batchGetSessionFileBirthtimes;
48
+ const fs = __importStar(require("fs"));
43
49
  const path = __importStar(require("path"));
44
- const child_process_1 = require("child_process");
45
50
  /**
46
- * Get birth times for .jsonl session files across multiple directories in a single shell call.
47
- *
48
- * Combines all directory globs into one `stat` command to avoid per-directory exec overhead.
49
- * Returns empty array if no directories have .jsonl files or command fails.
50
- * resolvedCwd is left empty — the adapter must set it.
51
+ * Check whether a path exists and is a directory.
52
+ * Returns false on any error (missing path, permission denied, broken symlink, etc.).
51
53
  */
52
- function batchGetSessionFileBirthtimes(dirs) {
53
- if (dirs.length === 0)
54
- return [];
54
+ function isDirectory(p) {
55
+ return safeStat(p)?.isDirectory() ?? false;
56
+ }
57
+ /**
58
+ * `fs.statSync` that swallows errors and returns `undefined` on failure.
59
+ * Callers can pull whichever fields they need (mtime, birthtime, ...).
60
+ */
61
+ function safeStat(filePath) {
55
62
  try {
56
- const isMacOS = process.platform === 'darwin';
57
- const globs = dirs.map((d) => `"${d}"/*.jsonl`).join(' ');
58
- // || true prevents non-zero exit when some globs have no .jsonl matches
59
- const command = isMacOS
60
- ? `stat -f '%B %N' ${globs} 2>/dev/null || true`
61
- : `stat --format='%W %n' ${globs} 2>/dev/null || true`;
62
- const output = (0, child_process_1.execSync)(command, { encoding: 'utf-8' });
63
- return parseStatOutput(output);
63
+ return fs.statSync(filePath);
64
+ }
65
+ catch {
66
+ return undefined;
67
+ }
68
+ }
69
+ /**
70
+ * `fs.readFileSync` (utf-8) that swallows errors and returns `undefined`
71
+ * on failure. Use when an unreadable file should be skipped rather than
72
+ * raised.
73
+ */
74
+ function safeReadFile(filePath) {
75
+ try {
76
+ return fs.readFileSync(filePath, 'utf-8');
77
+ }
78
+ catch {
79
+ return undefined;
80
+ }
81
+ }
82
+ /**
83
+ * `fs.readdirSync` that swallows errors and returns `[]` on failure.
84
+ * Useful when walking optional/transient directories where missing or
85
+ * unreadable entries should be skipped silently.
86
+ */
87
+ function safeReaddir(dir) {
88
+ try {
89
+ return fs.readdirSync(dir);
64
90
  }
65
91
  catch {
66
92
  return [];
67
93
  }
68
94
  }
69
95
  /**
70
- * Parse stat output lines into SessionFile entries.
96
+ * List entries in a directory that end with `.jsonl`. Returns `[]` on
97
+ * read errors. The result preserves directory order (no sorting).
98
+ */
99
+ function listJsonl(dir) {
100
+ return safeReaddir(dir).filter((name) => name.endsWith('.jsonl'));
101
+ }
102
+ /**
103
+ * Get birth times for .jsonl session files across multiple directories.
104
+ *
105
+ * Enumerates each directory with readdirSync and stats each .jsonl file
106
+ * to get its birth time. No shell commands are used.
107
+ * Returns empty array if no directories have .jsonl files or reads fail.
108
+ * resolvedCwd is left empty — the adapter must set it.
71
109
  */
72
- function parseStatOutput(output) {
110
+ function batchGetSessionFileBirthtimes(dirs) {
111
+ if (dirs.length === 0)
112
+ return [];
73
113
  const results = [];
74
- for (const rawLine of output.trim().split('\n')) {
75
- const line = rawLine.trim();
76
- if (!line)
77
- continue;
78
- // Format: "<epoch_seconds> <filepath>"
79
- const spaceIdx = line.indexOf(' ');
80
- if (spaceIdx === -1)
81
- continue;
82
- const epochStr = line.slice(0, spaceIdx);
83
- const filePath = line.slice(spaceIdx + 1).trim();
84
- const epochSeconds = parseInt(epochStr, 10);
85
- if (!Number.isFinite(epochSeconds) || epochSeconds <= 0)
86
- continue;
87
- const fileName = path.basename(filePath);
88
- if (!fileName.endsWith('.jsonl'))
114
+ for (const dir of dirs) {
115
+ let entries;
116
+ try {
117
+ entries = fs.readdirSync(dir);
118
+ }
119
+ catch {
89
120
  continue;
90
- const sessionId = fileName.replace(/\.jsonl$/, '');
91
- results.push({
92
- sessionId,
93
- filePath,
94
- projectDir: path.dirname(filePath),
95
- birthtimeMs: epochSeconds * 1000,
96
- resolvedCwd: '',
97
- });
121
+ }
122
+ for (const entry of entries) {
123
+ if (!entry.endsWith('.jsonl'))
124
+ continue;
125
+ const filePath = path.join(dir, entry);
126
+ let birthtimeMs;
127
+ try {
128
+ birthtimeMs = fs.statSync(filePath).birthtimeMs;
129
+ }
130
+ catch {
131
+ continue;
132
+ }
133
+ if (!Number.isFinite(birthtimeMs) || birthtimeMs <= 0)
134
+ continue;
135
+ const sessionId = entry.replace(/\.jsonl$/, '');
136
+ results.push({
137
+ sessionId,
138
+ filePath,
139
+ projectDir: dir,
140
+ birthtimeMs,
141
+ resolvedCwd: '',
142
+ });
143
+ }
98
144
  }
99
145
  return results;
100
146
  }
@@ -1 +1 @@
1
- {"version":3,"file":"session.js","sourceRoot":"","sources":["../../src/utils/session.ts"],"names":[],"mappings":";AAAA;;;;;GAKG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCH,sEAiBC;AA/CD,2CAA6B;AAC7B,iDAAyC;AAsBzC;;;;;;GAMG;AACH,SAAgB,6BAA6B,CAAC,IAAc;IACxD,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAEjC,IAAI,CAAC;QACD,MAAM,OAAO,GAAG,OAAO,CAAC,QAAQ,KAAK,QAAQ,CAAC;QAC9C,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAC1D,wEAAwE;QACxE,MAAM,OAAO,GAAG,OAAO;YACnB,CAAC,CAAC,mBAAmB,KAAK,sBAAsB;YAChD,CAAC,CAAC,yBAAyB,KAAK,sBAAsB,CAAC;QAE3D,MAAM,MAAM,GAAG,IAAA,wBAAQ,EAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC,CAAC;QAExD,OAAO,eAAe,CAAC,MAAM,CAAC,CAAC;IACnC,CAAC;IAAC,MAAM,CAAC;QACL,OAAO,EAAE,CAAC;IACd,CAAC;AACL,CAAC;AAED;;GAEG;AACH,SAAS,eAAe,CAAC,MAAc;IACnC,MAAM,OAAO,GAAkB,EAAE,CAAC;IAElC,KAAK,MAAM,OAAO,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QAC9C,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,IAAI,CAAC,IAAI;YAAE,SAAS;QAEpB,uCAAuC;QACvC,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QACnC,IAAI,QAAQ,KAAK,CAAC,CAAC;YAAE,SAAS;QAE9B,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;QACzC,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,QAAQ,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAEjD,MAAM,YAAY,GAAG,QAAQ,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;QAC5C,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,YAAY,CAAC,IAAI,YAAY,IAAI,CAAC;YAAE,SAAS;QAElE,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QACzC,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,QAAQ,CAAC;YAAE,SAAS;QAE3C,MAAM,SAAS,GAAG,QAAQ,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;QAEnD,OAAO,CAAC,IAAI,CAAC;YACT,SAAS;YACT,QAAQ;YACR,UAAU,EAAE,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC;YAClC,WAAW,EAAE,YAAY,GAAG,IAAI;YAChC,WAAW,EAAE,EAAE;SAClB,CAAC,CAAC;IACP,CAAC;IAED,OAAO,OAAO,CAAC;AACnB,CAAC"}
1
+ {"version":3,"file":"session.js","sourceRoot":"","sources":["../../src/utils/session.ts"],"names":[],"mappings":";AAAA;;;;;GAKG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BH,kCAEC;AAMD,4BAMC;AAOD,oCAMC;AAOD,kCAMC;AAMD,8BAEC;AAUD,sEAwCC;AA7HD,uCAAyB;AACzB,2CAA6B;AAsB7B;;;GAGG;AACH,SAAgB,WAAW,CAAC,CAAS;IACjC,OAAO,QAAQ,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,IAAI,KAAK,CAAC;AAC/C,CAAC;AAED;;;GAGG;AACH,SAAgB,QAAQ,CAAC,QAAgB;IACrC,IAAI,CAAC;QACD,OAAO,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IACjC,CAAC;IAAC,MAAM,CAAC;QACL,OAAO,SAAS,CAAC;IACrB,CAAC;AACL,CAAC;AAED;;;;GAIG;AACH,SAAgB,YAAY,CAAC,QAAgB;IACzC,IAAI,CAAC;QACD,OAAO,EAAE,CAAC,YAAY,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;IAC9C,CAAC;IAAC,MAAM,CAAC;QACL,OAAO,SAAS,CAAC;IACrB,CAAC;AACL,CAAC;AAED;;;;GAIG;AACH,SAAgB,WAAW,CAAC,GAAW;IACnC,IAAI,CAAC;QACD,OAAO,EAAE,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;IAC/B,CAAC;IAAC,MAAM,CAAC;QACL,OAAO,EAAE,CAAC;IACd,CAAC;AACL,CAAC;AAED;;;GAGG;AACH,SAAgB,SAAS,CAAC,GAAW;IACjC,OAAO,WAAW,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC;AACtE,CAAC;AAED;;;;;;;GAOG;AACH,SAAgB,6BAA6B,CAAC,IAAc;IACxD,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAEjC,MAAM,OAAO,GAAkB,EAAE,CAAC;IAElC,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;QACrB,IAAI,OAAiB,CAAC;QACtB,IAAI,CAAC;YACD,OAAO,GAAG,EAAE,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;QAClC,CAAC;QAAC,MAAM,CAAC;YACL,SAAS;QACb,CAAC;QAED,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;YAC1B,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,QAAQ,CAAC;gBAAE,SAAS;YAExC,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;YAEvC,IAAI,WAAmB,CAAC;YACxB,IAAI,CAAC;gBACD,WAAW,GAAG,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,WAAW,CAAC;YACpD,CAAC;YAAC,MAAM,CAAC;gBACL,SAAS;YACb,CAAC;YAED,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,IAAI,WAAW,IAAI,CAAC;gBAAE,SAAS;YAEhE,MAAM,SAAS,GAAG,KAAK,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;YAEhD,OAAO,CAAC,IAAI,CAAC;gBACT,SAAS;gBACT,QAAQ;gBACR,UAAU,EAAE,GAAG;gBACf,WAAW;gBACX,WAAW,EAAE,EAAE;aAClB,CAAC,CAAC;QACP,CAAC;IACL,CAAC;IAED,OAAO,OAAO,CAAC;AACnB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-devkit/agent-manager",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Standalone agent detection and management utilities for AI DevKit",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -5,7 +5,12 @@
5
5
  * Manages adapter registration and aggregates results from all adapters.
6
6
  */
7
7
 
8
- import type { AgentAdapter, AgentInfo } from './adapters/AgentAdapter';
8
+ import type {
9
+ AgentAdapter,
10
+ AgentInfo,
11
+ SessionSummary,
12
+ ListSessionsOptions,
13
+ } from './adapters/AgentAdapter';
9
14
  import { AgentStatus } from './adapters/AgentAdapter';
10
15
 
11
16
  /**
@@ -141,12 +146,59 @@ export class AgentManager {
141
146
  return this.sortAgentsByStatus(allAgents);
142
147
  }
143
148
 
149
+ /**
150
+ * List historical sessions across every registered adapter.
151
+ *
152
+ * When `opts.type` is set, adapters whose `type` doesn't match are
153
+ * skipped without being called. The remaining adapters' results are
154
+ * merged and sorted by `lastActive` descending. Adapter failures are
155
+ * caught (one-line stderr warning) so one broken adapter doesn't hide
156
+ * the others.
157
+ *
158
+ * @param opts Filter options computed by the CLI; the manager passes
159
+ * them through to each adapter unchanged.
160
+ */
161
+ async listSessions(opts?: ListSessionsOptions): Promise<SessionSummary[]> {
162
+ const targetAdapters = Array.from(this.adapters.values()).filter(
163
+ (adapter) => opts?.type === undefined || adapter.type === opts.type,
164
+ );
165
+
166
+ const errors: Array<{ type: string; error: Error }> = [];
167
+
168
+ const results = await Promise.all(
169
+ targetAdapters.map(async (adapter) => {
170
+ try {
171
+ return await adapter.listSessions(opts);
172
+ } catch (error) {
173
+ const err = error instanceof Error ? error : new Error(String(error));
174
+ errors.push({ type: adapter.type, error: err });
175
+ return [];
176
+ }
177
+ }),
178
+ );
179
+
180
+ if (errors.length > 0) {
181
+ console.error(`Warning: ${errors.length} adapter(s) failed to list sessions:`);
182
+ for (const { type, error } of errors) {
183
+ console.error(` - ${type}: ${error.message}`);
184
+ }
185
+ }
186
+
187
+ const merged: SessionSummary[] = [];
188
+ for (const list of results) {
189
+ merged.push(...list);
190
+ }
191
+
192
+ merged.sort((a, b) => b.lastActive.getTime() - a.lastActive.getTime());
193
+ return merged;
194
+ }
195
+
144
196
  /**
145
197
  * Sort agents by status priority
146
- *
198
+ *
147
199
  * Priority order: waiting > running > idle > unknown
148
200
  * This ensures agents that need attention appear first.
149
- *
201
+ *
150
202
  * @param agents Array of agents to sort
151
203
  * @returns Sorted array of agents
152
204
  */
@@ -4,15 +4,25 @@
4
4
 
5
5
  import { describe, it, expect, beforeEach } from '@jest/globals';
6
6
  import { AgentManager } from '../AgentManager';
7
- import type { AgentAdapter, AgentInfo, AgentType, ConversationMessage } from '../adapters/AgentAdapter';
7
+ import type {
8
+ AgentAdapter,
9
+ AgentInfo,
10
+ AgentType,
11
+ ConversationMessage,
12
+ SessionSummary,
13
+ } from '../adapters/AgentAdapter';
8
14
  import { AgentStatus } from '../adapters/AgentAdapter';
9
15
 
10
16
  // Mock adapter for testing
11
17
  class MockAdapter implements AgentAdapter {
18
+ public lastListSessionsOpts: unknown = undefined;
19
+
12
20
  constructor(
13
21
  public readonly type: AgentType,
14
22
  private mockAgents: AgentInfo[] = [],
15
- private shouldFail: boolean = false
23
+ private shouldFail: boolean = false,
24
+ private mockSessions: SessionSummary[] = [],
25
+ private shouldFailListSessions: boolean = false,
16
26
  ) { }
17
27
 
18
28
  async detectAgents(): Promise<AgentInfo[]> {
@@ -30,6 +40,14 @@ class MockAdapter implements AgentAdapter {
30
40
  return [];
31
41
  }
32
42
 
43
+ async listSessions(opts?: unknown): Promise<SessionSummary[]> {
44
+ this.lastListSessionsOpts = opts;
45
+ if (this.shouldFailListSessions) {
46
+ throw new Error(`Mock adapter ${this.type} listSessions failed`);
47
+ }
48
+ return this.mockSessions;
49
+ }
50
+
33
51
  setAgents(agents: AgentInfo[]): void {
34
52
  this.mockAgents = agents;
35
53
  }
@@ -37,6 +55,14 @@ class MockAdapter implements AgentAdapter {
37
55
  setFail(shouldFail: boolean): void {
38
56
  this.shouldFail = shouldFail;
39
57
  }
58
+
59
+ setSessions(sessions: SessionSummary[]): void {
60
+ this.mockSessions = sessions;
61
+ }
62
+
63
+ setFailListSessions(shouldFail: boolean): void {
64
+ this.shouldFailListSessions = shouldFail;
65
+ }
40
66
  }
41
67
 
42
68
  // Helper to create mock agent
@@ -226,6 +252,112 @@ describe('AgentManager', () => {
226
252
  });
227
253
  });
228
254
 
255
+ describe('listSessions', () => {
256
+ function createMockSession(overrides: Partial<SessionSummary> = {}): SessionSummary {
257
+ return {
258
+ type: 'claude',
259
+ sessionId: 'session-1',
260
+ cwd: '/repo',
261
+ firstUserMessage: 'hello',
262
+ lastActive: new Date('2025-01-01T00:00:00Z'),
263
+ startedAt: new Date('2025-01-01T00:00:00Z'),
264
+ sessionFilePath: '/tmp/session-1.jsonl',
265
+ ...overrides,
266
+ };
267
+ }
268
+
269
+ it('returns empty array when no adapters are registered', async () => {
270
+ const result = await manager.listSessions();
271
+ expect(result).toEqual([]);
272
+ });
273
+
274
+ it('merges sessions from every registered adapter', async () => {
275
+ const claudeSession = createMockSession({ type: 'claude', sessionId: 'c1' });
276
+ const codexSession = createMockSession({ type: 'codex', sessionId: 'cx1' });
277
+ manager.registerAdapter(new MockAdapter('claude', [], false, [claudeSession]));
278
+ manager.registerAdapter(new MockAdapter('codex', [], false, [codexSession]));
279
+
280
+ const result = await manager.listSessions();
281
+
282
+ expect(result).toHaveLength(2);
283
+ expect(result.map((s) => s.sessionId).sort()).toEqual(['c1', 'cx1']);
284
+ });
285
+
286
+ it('sorts merged sessions by lastActive descending', async () => {
287
+ const older = createMockSession({
288
+ sessionId: 'older',
289
+ lastActive: new Date('2025-01-01T00:00:00Z'),
290
+ });
291
+ const newer = createMockSession({
292
+ type: 'codex',
293
+ sessionId: 'newer',
294
+ lastActive: new Date('2025-06-01T00:00:00Z'),
295
+ });
296
+ manager.registerAdapter(new MockAdapter('claude', [], false, [older]));
297
+ manager.registerAdapter(new MockAdapter('codex', [], false, [newer]));
298
+
299
+ const result = await manager.listSessions();
300
+
301
+ expect(result.map((s) => s.sessionId)).toEqual(['newer', 'older']);
302
+ });
303
+
304
+ it('skips adapters whose type does not match opts.type', async () => {
305
+ const claudeAdapter = new MockAdapter(
306
+ 'claude',
307
+ [],
308
+ false,
309
+ [createMockSession({ type: 'claude', sessionId: 'c1' })],
310
+ );
311
+ const codexAdapter = new MockAdapter(
312
+ 'codex',
313
+ [],
314
+ false,
315
+ [createMockSession({ type: 'codex', sessionId: 'cx1' })],
316
+ );
317
+ manager.registerAdapter(claudeAdapter);
318
+ manager.registerAdapter(codexAdapter);
319
+
320
+ const result = await manager.listSessions({ type: 'claude' });
321
+
322
+ expect(result).toHaveLength(1);
323
+ expect(result[0].sessionId).toBe('c1');
324
+ // Codex adapter must not have been called
325
+ expect(codexAdapter.lastListSessionsOpts).toBeUndefined();
326
+ expect(claudeAdapter.lastListSessionsOpts).toEqual({ type: 'claude' });
327
+ });
328
+
329
+ it('tolerates an adapter that throws and still returns the others', async () => {
330
+ const goodSession = createMockSession({ sessionId: 'good' });
331
+ manager.registerAdapter(new MockAdapter('claude', [], false, [goodSession]));
332
+ manager.registerAdapter(
333
+ new MockAdapter('codex', [], false, [], true /* failListSessions */),
334
+ );
335
+
336
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
337
+
338
+ try {
339
+ const result = await manager.listSessions();
340
+ expect(result).toHaveLength(1);
341
+ expect(result[0].sessionId).toBe('good');
342
+ expect(consoleErrorSpy).toHaveBeenCalled();
343
+ } finally {
344
+ consoleErrorSpy.mockRestore();
345
+ }
346
+ });
347
+
348
+ it('passes the same opts to every called adapter', async () => {
349
+ const a = new MockAdapter('claude', [], false, []);
350
+ const b = new MockAdapter('codex', [], false, []);
351
+ manager.registerAdapter(a);
352
+ manager.registerAdapter(b);
353
+
354
+ await manager.listSessions({ cwd: '/Users/test/proj' });
355
+
356
+ expect(a.lastListSessionsOpts).toEqual({ cwd: '/Users/test/proj' });
357
+ expect(b.lastListSessionsOpts).toEqual({ cwd: '/Users/test/proj' });
358
+ });
359
+ });
360
+
229
361
  describe('resolveAgent', () => {
230
362
  it('should return null for empty input or empty agents list', () => {
231
363
  const agent = createMockAgent({ name: 'test-agent' });
@@ -18,9 +18,13 @@ jest.mock('../../utils/process', () => ({
18
18
  enrichProcesses: jest.fn(),
19
19
  }));
20
20
 
21
- jest.mock('../../utils/session', () => ({
22
- batchGetSessionFileBirthtimes: jest.fn(),
23
- }));
21
+ jest.mock('../../utils/session', () => {
22
+ const actual = jest.requireActual('../../utils/session') as typeof import('../../utils/session');
23
+ return {
24
+ ...actual,
25
+ batchGetSessionFileBirthtimes: jest.fn(),
26
+ };
27
+ });
24
28
 
25
29
  jest.mock('../../utils/matching', () => ({
26
30
  matchProcessesToSessions: jest.fn(),
@@ -1288,4 +1292,156 @@ describe('ClaudeCodeAdapter', () => {
1288
1292
  expect(messages[0].content).toBe('Real question');
1289
1293
  });
1290
1294
  });
1295
+
1296
+ describe('listSessions', () => {
1297
+ let tmpDir: string;
1298
+ let projectsDir: string;
1299
+
1300
+ beforeEach(() => {
1301
+ tmpDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'claude-list-'));
1302
+ projectsDir = path.join(tmpDir, 'projects');
1303
+ fs.mkdirSync(projectsDir, { recursive: true });
1304
+ (adapter as any).projectsDir = projectsDir;
1305
+ });
1306
+
1307
+ afterEach(() => {
1308
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1309
+ });
1310
+
1311
+ function writeSession(projectDir: string, sessionId: string, lines: object[]): string {
1312
+ fs.mkdirSync(projectDir, { recursive: true });
1313
+ const filePath = path.join(projectDir, `${sessionId}.jsonl`);
1314
+ fs.writeFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n'));
1315
+ return filePath;
1316
+ }
1317
+
1318
+ it('returns empty when projects dir does not exist', async () => {
1319
+ fs.rmSync(projectsDir, { recursive: true, force: true });
1320
+ const result = await adapter.listSessions();
1321
+ expect(result).toEqual([]);
1322
+ });
1323
+
1324
+ it('returns sessions from a single cwd-scoped project dir', async () => {
1325
+ const cwd = '/Users/test/proj';
1326
+ const projDir = path.join(projectsDir, '-Users-test-proj');
1327
+ const filePath = writeSession(projDir, 'sess-1', [
1328
+ { type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd, message: { content: 'first prompt' } },
1329
+ { type: 'assistant', timestamp: '2025-01-01T00:01:00Z' },
1330
+ ]);
1331
+
1332
+ const result = await adapter.listSessions({ cwd });
1333
+
1334
+ expect(result).toHaveLength(1);
1335
+ expect(result[0]).toMatchObject({
1336
+ type: 'claude',
1337
+ sessionId: 'sess-1',
1338
+ cwd,
1339
+ firstUserMessage: 'first prompt',
1340
+ sessionFilePath: filePath,
1341
+ });
1342
+ expect(result[0].lastActive).toBeInstanceOf(Date);
1343
+ expect(result[0].startedAt).toBeInstanceOf(Date);
1344
+ });
1345
+
1346
+ it('lists sessions from all project dirs when no cwd filter', async () => {
1347
+ const cwdA = '/Users/test/proj-a';
1348
+ const cwdB = '/Users/test/proj-b';
1349
+ writeSession(path.join(projectsDir, '-Users-test-proj-a'), 'a', [
1350
+ { type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: cwdA, message: { content: 'msg-a' } },
1351
+ ]);
1352
+ writeSession(path.join(projectsDir, '-Users-test-proj-b'), 'b', [
1353
+ { type: 'user', timestamp: '2025-01-02T00:00:00Z', cwd: cwdB, message: { content: 'msg-b' } },
1354
+ ]);
1355
+
1356
+ const result = await adapter.listSessions();
1357
+
1358
+ expect(result).toHaveLength(2);
1359
+ expect(result.map((r) => r.sessionId).sort()).toEqual(['a', 'b']);
1360
+ const cwds = result.map((r) => r.cwd).sort();
1361
+ expect(cwds).toEqual([cwdA, cwdB]);
1362
+ });
1363
+
1364
+ it('drops sessions whose recorded cwd does not match opts.cwd (strict equality)', async () => {
1365
+ const cwdReal = '/Users/test/foo';
1366
+ const cwdRequested = '/Users/test/foo/sub';
1367
+ writeSession(path.join(projectsDir, '-Users-test-foo'), 's', [
1368
+ { type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: cwdReal, message: { content: 'hi' } },
1369
+ ]);
1370
+
1371
+ // Encoded dir for the requested cwd doesn't exist → return []
1372
+ const result = await adapter.listSessions({ cwd: cwdRequested });
1373
+ expect(result).toEqual([]);
1374
+ });
1375
+
1376
+ it('drops sessions whose recorded cwd disagrees with the encoded dir', async () => {
1377
+ // Edge case: encoded dir lookup matches, but session content
1378
+ // records a different cwd. Strict-equality filter must reject.
1379
+ const requested = '/Users/test/proj';
1380
+ const projDir = path.join(projectsDir, '-Users-test-proj');
1381
+ writeSession(projDir, 's', [
1382
+ { type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: '/different/path', message: { content: 'mismatch' } },
1383
+ ]);
1384
+
1385
+ const result = await adapter.listSessions({ cwd: requested });
1386
+ expect(result).toEqual([]);
1387
+ });
1388
+
1389
+ it('finds sessions whose recorded cwd lives in a different encoded dir (worktree case)', async () => {
1390
+ // Real-world case: Claude Code is launched in /repo, then chdirs into
1391
+ // /repo/.worktrees/feature. The session file is stored under the
1392
+ // ENCODED launch dir, but its content records the worktree path.
1393
+ // listSessions({ cwd: worktree }) must still find it.
1394
+ const launchDir = path.join(projectsDir, '-repo');
1395
+ const worktreeCwd = '/repo/.worktrees/feature';
1396
+ writeSession(launchDir, 'wt', [
1397
+ { type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd: worktreeCwd, message: { content: 'in worktree' } },
1398
+ ]);
1399
+
1400
+ const result = await adapter.listSessions({ cwd: worktreeCwd });
1401
+ expect(result).toHaveLength(1);
1402
+ expect(result[0]).toMatchObject({
1403
+ sessionId: 'wt',
1404
+ cwd: worktreeCwd,
1405
+ firstUserMessage: 'in worktree',
1406
+ });
1407
+ });
1408
+
1409
+ it('skips malformed session files', async () => {
1410
+ const cwd = '/Users/test/p';
1411
+ const projDir = path.join(projectsDir, '-Users-test-p');
1412
+ fs.mkdirSync(projDir, { recursive: true });
1413
+ fs.writeFileSync(path.join(projDir, 'bad.jsonl'), 'not valid json');
1414
+ writeSession(projDir, 'good', [
1415
+ { type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd, message: { content: 'ok' } },
1416
+ ]);
1417
+
1418
+ const result = await adapter.listSessions({ cwd });
1419
+ expect(result).toHaveLength(1);
1420
+ expect(result[0].sessionId).toBe('good');
1421
+ });
1422
+
1423
+ it('captures first user message after filtering noise', async () => {
1424
+ const cwd = '/Users/test/q';
1425
+ writeSession(path.join(projectsDir, '-Users-test-q'), 's', [
1426
+ { type: 'user', timestamp: '2025-01-01T00:00:00Z', cwd, message: { content: 'Tool loaded.' } },
1427
+ { type: 'user', timestamp: '2025-01-01T00:00:01Z', cwd, message: { content: 'real first prompt' } },
1428
+ { type: 'user', timestamp: '2025-01-01T00:00:02Z', cwd, message: { content: 'second prompt' } },
1429
+ ]);
1430
+
1431
+ const result = await adapter.listSessions({ cwd });
1432
+ expect(result).toHaveLength(1);
1433
+ expect(result[0].firstUserMessage).toBe('real first prompt');
1434
+ });
1435
+
1436
+ it('returns empty firstUserMessage when no user message exists', async () => {
1437
+ const cwd = '/Users/test/empty';
1438
+ writeSession(path.join(projectsDir, '-Users-test-empty'), 's', [
1439
+ { type: 'assistant', timestamp: '2025-01-01T00:00:00Z' },
1440
+ ]);
1441
+
1442
+ const result = await adapter.listSessions({ cwd });
1443
+ expect(result).toHaveLength(1);
1444
+ expect(result[0].firstUserMessage).toBe('');
1445
+ });
1446
+ });
1291
1447
  });