@ai-devkit/agent-manager 0.4.0 → 0.5.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 (50) hide show
  1. package/dist/adapters/AgentAdapter.d.ts +2 -0
  2. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  3. package/dist/adapters/ClaudeCodeAdapter.d.ts +29 -34
  4. package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
  5. package/dist/adapters/ClaudeCodeAdapter.js +138 -294
  6. package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
  7. package/dist/adapters/CodexAdapter.d.ts +32 -30
  8. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  9. package/dist/adapters/CodexAdapter.js +148 -282
  10. package/dist/adapters/CodexAdapter.js.map +1 -1
  11. package/dist/index.d.ts +1 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -10
  14. package/dist/index.js.map +1 -1
  15. package/dist/utils/index.d.ts +6 -3
  16. package/dist/utils/index.d.ts.map +1 -1
  17. package/dist/utils/index.js +12 -11
  18. package/dist/utils/index.js.map +1 -1
  19. package/dist/utils/matching.d.ts +39 -0
  20. package/dist/utils/matching.d.ts.map +1 -0
  21. package/dist/utils/matching.js +103 -0
  22. package/dist/utils/matching.js.map +1 -0
  23. package/dist/utils/process.d.ts +25 -40
  24. package/dist/utils/process.d.ts.map +1 -1
  25. package/dist/utils/process.js +151 -105
  26. package/dist/utils/process.js.map +1 -1
  27. package/dist/utils/session.d.ts +30 -0
  28. package/dist/utils/session.d.ts.map +1 -0
  29. package/dist/utils/session.js +101 -0
  30. package/dist/utils/session.js.map +1 -0
  31. package/package.json +1 -1
  32. package/src/__tests__/AgentManager.test.ts +0 -25
  33. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +598 -845
  34. package/src/__tests__/adapters/CodexAdapter.test.ts +467 -274
  35. package/src/__tests__/utils/matching.test.ts +191 -0
  36. package/src/__tests__/utils/process.test.ts +202 -0
  37. package/src/__tests__/utils/session.test.ts +117 -0
  38. package/src/adapters/AgentAdapter.ts +3 -0
  39. package/src/adapters/ClaudeCodeAdapter.ts +177 -425
  40. package/src/adapters/CodexAdapter.ts +155 -409
  41. package/src/index.ts +1 -3
  42. package/src/utils/index.ts +6 -3
  43. package/src/utils/matching.ts +92 -0
  44. package/src/utils/process.ts +133 -119
  45. package/src/utils/session.ts +92 -0
  46. package/dist/utils/file.d.ts +0 -52
  47. package/dist/utils/file.d.ts.map +0 -1
  48. package/dist/utils/file.js +0 -135
  49. package/dist/utils/file.js.map +0 -1
  50. package/src/utils/file.ts +0 -100
@@ -1,18 +1,11 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- import { execSync } from 'child_process';
4
3
  import type { AgentAdapter, AgentInfo, ProcessInfo } from './AgentAdapter';
5
4
  import { AgentStatus } from './AgentAdapter';
6
- import { listProcesses } from '../utils/process';
7
- import { readJson } from '../utils/file';
8
-
9
- /**
10
- * Structure of ~/.claude/projects/{path}/sessions-index.json
11
- */
12
- interface SessionsIndex {
13
- originalPath: string;
14
- }
15
-
5
+ import { listAgentProcesses, enrichProcesses } from '../utils/process';
6
+ import { batchGetSessionFileBirthtimes } from '../utils/session';
7
+ import type { SessionFile } from '../utils/session';
8
+ import { matchProcessesToSessions, generateAgentName } from '../utils/matching';
16
9
  /**
17
10
  * Entry in session JSONL file
18
11
  */
@@ -30,6 +23,26 @@ interface SessionEntry {
30
23
  };
31
24
  }
32
25
 
26
+ /**
27
+ * Entry in ~/.claude/sessions/<pid>.json written by Claude Code
28
+ */
29
+ interface PidFileEntry {
30
+ pid: number;
31
+ sessionId: string;
32
+ cwd: string;
33
+ startedAt: number; // epoch milliseconds
34
+ kind: string;
35
+ entrypoint: string;
36
+ }
37
+
38
+ /**
39
+ * A process directly matched to a session via PID file (authoritative path)
40
+ */
41
+ interface DirectMatch {
42
+ process: ProcessInfo;
43
+ sessionFile: SessionFile;
44
+ }
45
+
33
46
  /**
34
47
  * Claude Code session information
35
48
  */
@@ -45,33 +58,26 @@ interface ClaudeSession {
45
58
  lastUserMessage?: string;
46
59
  }
47
60
 
48
- type SessionMatchMode = 'cwd' | 'missing-cwd' | 'parent-child';
49
-
50
61
  /**
51
62
  * Claude Code Adapter
52
63
  *
53
64
  * Detects Claude Code agents by:
54
- * 1. Finding running claude processes
55
- * 2. Getting process start times for accurate session matching
56
- * 3. Reading bounded session files from ~/.claude/projects/
57
- * 4. Matching sessions to processes via CWD then start time ranking
65
+ * 1. Finding running claude processes via shared listAgentProcesses()
66
+ * 2. Enriching with CWD and start times via shared enrichProcesses()
67
+ * 3. Attempting authoritative PID-file matching via ~/.claude/sessions/<pid>.json
68
+ * 4. Falling back to CWD+birthtime heuristic (matchProcessesToSessions) for processes without a PID file
58
69
  * 5. Extracting summary from last user message in session JSONL
59
70
  */
60
71
  export class ClaudeCodeAdapter implements AgentAdapter {
61
72
  readonly type = 'claude' as const;
62
73
 
63
- /** Limit session parsing per run to keep list latency bounded. */
64
- private static readonly MIN_SESSION_SCAN = 12;
65
- private static readonly MAX_SESSION_SCAN = 40;
66
- private static readonly SESSION_SCAN_MULTIPLIER = 4;
67
- /** Matching tolerance between process start time and session start time. */
68
- private static readonly PROCESS_SESSION_TIME_TOLERANCE_MS = 2 * 60 * 1000;
69
-
70
74
  private projectsDir: string;
75
+ private sessionsDir: string;
71
76
 
72
77
  constructor() {
73
78
  const homeDir = process.env.HOME || process.env.USERPROFILE || '';
74
79
  this.projectsDir = path.join(homeDir, '.claude', 'projects');
80
+ this.sessionsDir = path.join(homeDir, '.claude', 'sessions');
75
81
  }
76
82
 
77
83
  /**
@@ -91,398 +97,202 @@ export class ClaudeCodeAdapter implements AgentAdapter {
91
97
  * Detect running Claude Code agents
92
98
  */
93
99
  async detectAgents(): Promise<AgentInfo[]> {
94
- const claudeProcesses = this.listClaudeProcesses();
95
- if (claudeProcesses.length === 0) {
100
+ const processes = enrichProcesses(listAgentProcesses('claude'));
101
+ if (processes.length === 0) {
96
102
  return [];
97
103
  }
98
104
 
99
- const processStartByPid = this.getProcessStartTimes(
100
- claudeProcesses.map((p) => p.pid),
101
- );
102
- const sessionScanLimit = this.calculateSessionScanLimit(claudeProcesses.length);
103
- const sessions = this.readSessions(sessionScanLimit);
105
+ // Step 1: try authoritative PID-file matching for every process
106
+ const { direct, fallback } = this.tryPidFileMatching(processes);
104
107
 
105
- if (sessions.length === 0) {
106
- return claudeProcesses.map((p) =>
107
- this.mapProcessOnlyAgent(p, []),
108
- );
109
- }
108
+ // Step 2: run legacy CWD+birthtime matching only for processes without a PID file
109
+ const legacySessions = this.discoverSessions(fallback);
110
+ const legacyMatches =
111
+ fallback.length > 0 && legacySessions.length > 0
112
+ ? matchProcessesToSessions(fallback, legacySessions)
113
+ : [];
114
+
115
+ const matchedPids = new Set([
116
+ ...direct.map((d) => d.process.pid),
117
+ ...legacyMatches.map((m) => m.process.pid),
118
+ ]);
110
119
 
111
- const sortedSessions = [...sessions].sort(
112
- (a, b) => b.lastActive.getTime() - a.lastActive.getTime(),
113
- );
114
- const usedSessionIds = new Set<string>();
115
- const assignedPids = new Set<number>();
116
120
  const agents: AgentInfo[] = [];
117
121
 
118
- const modes: SessionMatchMode[] = ['cwd', 'missing-cwd', 'parent-child'];
119
- for (const mode of modes) {
120
- this.assignSessionsForMode(
121
- mode,
122
- claudeProcesses,
123
- sortedSessions,
124
- usedSessionIds,
125
- assignedPids,
126
- processStartByPid,
127
- agents,
128
- );
122
+ // Build agents from direct (PID-file) matches
123
+ for (const { process: proc, sessionFile } of direct) {
124
+ const sessionData = this.readSession(sessionFile.filePath, sessionFile.resolvedCwd);
125
+ if (sessionData) {
126
+ agents.push(this.mapSessionToAgent(sessionData, proc, sessionFile));
127
+ } else {
128
+ matchedPids.delete(proc.pid);
129
+ }
129
130
  }
130
131
 
131
- for (const processInfo of claudeProcesses) {
132
- if (assignedPids.has(processInfo.pid)) {
133
- continue;
132
+ // Build agents from legacy matches
133
+ for (const match of legacyMatches) {
134
+ const sessionData = this.readSession(
135
+ match.session.filePath,
136
+ match.session.resolvedCwd,
137
+ );
138
+ if (sessionData) {
139
+ agents.push(this.mapSessionToAgent(sessionData, match.process, match.session));
140
+ } else {
141
+ matchedPids.delete(match.process.pid);
134
142
  }
143
+ }
135
144
 
136
- assignedPids.add(processInfo.pid);
137
- agents.push(this.mapProcessOnlyAgent(processInfo, agents));
145
+ // Any process with no match (direct or legacy) appears as IDLE
146
+ for (const proc of processes) {
147
+ if (!matchedPids.has(proc.pid)) {
148
+ agents.push(this.mapProcessOnlyAgent(proc));
149
+ }
138
150
  }
139
151
 
140
152
  return agents;
141
153
  }
142
154
 
143
- private listClaudeProcesses(): ProcessInfo[] {
144
- return listProcesses({ namePattern: 'claude' }).filter((p) =>
145
- this.canHandle(p),
146
- );
147
- }
155
+ /**
156
+ * Discover session files for the given processes.
157
+ *
158
+ * For each unique process CWD, encodes it to derive the expected
159
+ * ~/.claude/projects/<encoded>/ directory, then gets session file birthtimes
160
+ * via a single batched stat call across all directories.
161
+ */
162
+ private discoverSessions(processes: ProcessInfo[]): SessionFile[] {
163
+ // Collect valid project dirs and map them back to their CWD
164
+ const dirToCwd = new Map<string, string>();
148
165
 
149
- private calculateSessionScanLimit(processCount: number): number {
150
- return Math.min(
151
- Math.max(
152
- processCount * ClaudeCodeAdapter.SESSION_SCAN_MULTIPLIER,
153
- ClaudeCodeAdapter.MIN_SESSION_SCAN,
154
- ),
155
- ClaudeCodeAdapter.MAX_SESSION_SCAN,
156
- );
157
- }
166
+ for (const proc of processes) {
167
+ if (!proc.cwd) continue;
158
168
 
159
- private assignSessionsForMode(
160
- mode: SessionMatchMode,
161
- claudeProcesses: ProcessInfo[],
162
- sessions: ClaudeSession[],
163
- usedSessionIds: Set<string>,
164
- assignedPids: Set<number>,
165
- processStartByPid: Map<number, Date>,
166
- agents: AgentInfo[],
167
- ): void {
168
- for (const processInfo of claudeProcesses) {
169
- if (assignedPids.has(processInfo.pid)) {
170
- continue;
171
- }
169
+ const projectDir = this.getProjectDir(proc.cwd);
170
+ if (dirToCwd.has(projectDir)) continue;
172
171
 
173
- const session = this.selectBestSession(
174
- processInfo,
175
- sessions,
176
- usedSessionIds,
177
- processStartByPid,
178
- mode,
179
- );
180
- if (!session) {
172
+ try {
173
+ if (!fs.statSync(projectDir).isDirectory()) continue;
174
+ } catch {
181
175
  continue;
182
176
  }
183
177
 
184
- usedSessionIds.add(session.sessionId);
185
- assignedPids.add(processInfo.pid);
186
- agents.push(this.mapSessionToAgent(session, processInfo, agents));
178
+ dirToCwd.set(projectDir, proc.cwd);
179
+ }
180
+
181
+ if (dirToCwd.size === 0) return [];
182
+
183
+ // Single batched stat call across all directories
184
+ const files = batchGetSessionFileBirthtimes([...dirToCwd.keys()]);
185
+
186
+ // Set resolvedCwd based on which project dir the file belongs to
187
+ for (const file of files) {
188
+ file.resolvedCwd = dirToCwd.get(file.projectDir) || '';
189
+ }
190
+
191
+ return files;
192
+ }
193
+
194
+ /**
195
+ * Attempt to match each process to its session via ~/.claude/sessions/<pid>.json.
196
+ *
197
+ * Returns:
198
+ * direct — processes matched authoritatively via PID file
199
+ * fallback — processes with no valid PID file (sent to legacy matching)
200
+ *
201
+ * Per-process fallback triggers on: file absent, malformed JSON,
202
+ * stale startedAt (>60 s from proc.startTime), or missing JSONL.
203
+ */
204
+ private tryPidFileMatching(processes: ProcessInfo[]): {
205
+ direct: DirectMatch[];
206
+ fallback: ProcessInfo[];
207
+ } {
208
+ const direct: DirectMatch[] = [];
209
+ const fallback: ProcessInfo[] = [];
210
+
211
+ for (const proc of processes) {
212
+ const pidFilePath = path.join(this.sessionsDir, `${proc.pid}.json`);
213
+ try {
214
+ const entry = JSON.parse(
215
+ fs.readFileSync(pidFilePath, 'utf-8'),
216
+ ) as PidFileEntry;
217
+
218
+ // Stale-file guard: reject PID files from a previous process with the same PID
219
+ if (proc.startTime) {
220
+ const deltaMs = Math.abs(proc.startTime.getTime() - entry.startedAt);
221
+ if (deltaMs > 60000) {
222
+ fallback.push(proc);
223
+ continue;
224
+ }
225
+ }
226
+
227
+ const projectDir = this.getProjectDir(entry.cwd);
228
+ const jsonlPath = path.join(projectDir, `${entry.sessionId}.jsonl`);
229
+
230
+ if (!fs.existsSync(jsonlPath)) {
231
+ fallback.push(proc);
232
+ continue;
233
+ }
234
+
235
+ direct.push({
236
+ process: proc,
237
+ sessionFile: {
238
+ sessionId: entry.sessionId,
239
+ filePath: jsonlPath,
240
+ projectDir,
241
+ birthtimeMs: entry.startedAt,
242
+ resolvedCwd: entry.cwd,
243
+ },
244
+ });
245
+ } catch {
246
+ // PID file absent, unreadable, or malformed — fall back per-process
247
+ fallback.push(proc);
248
+ }
187
249
  }
250
+
251
+ return { direct, fallback };
252
+ }
253
+
254
+ /**
255
+ * Derive the Claude Code project directory for a given CWD.
256
+ *
257
+ * Claude Code encodes paths by replacing '/' with '-':
258
+ * /Users/foo/bar → ~/.claude/projects/-Users-foo-bar/
259
+ */
260
+ private getProjectDir(cwd: string): string {
261
+ const encoded = cwd.replace(/\//g, '-');
262
+ return path.join(this.projectsDir, encoded);
188
263
  }
189
264
 
190
265
  private mapSessionToAgent(
191
266
  session: ClaudeSession,
192
267
  processInfo: ProcessInfo,
193
- existingAgents: AgentInfo[],
268
+ sessionFile: SessionFile,
194
269
  ): AgentInfo {
195
270
  return {
196
- name: this.generateAgentName(session, existingAgents),
271
+ name: generateAgentName(processInfo.cwd, processInfo.pid),
197
272
  type: this.type,
198
273
  status: this.determineStatus(session),
199
274
  summary: session.lastUserMessage || 'Session started',
200
275
  pid: processInfo.pid,
201
- projectPath: session.projectPath || processInfo.cwd || '',
202
- sessionId: session.sessionId,
276
+ projectPath: sessionFile.resolvedCwd || processInfo.cwd || '',
277
+ sessionId: sessionFile.sessionId,
203
278
  slug: session.slug,
204
279
  lastActive: session.lastActive,
205
280
  };
206
281
  }
207
282
 
208
- private mapProcessOnlyAgent(
209
- processInfo: ProcessInfo,
210
- existingAgents: AgentInfo[],
211
- ): AgentInfo {
212
- const processCwd = processInfo.cwd || '';
213
- const projectName = path.basename(processCwd) || 'claude';
214
- const hasDuplicate = existingAgents.some((a) => a.projectPath === processCwd);
215
-
283
+ private mapProcessOnlyAgent(processInfo: ProcessInfo): AgentInfo {
216
284
  return {
217
- name: hasDuplicate ? `${projectName} (pid-${processInfo.pid})` : projectName,
285
+ name: generateAgentName(processInfo.cwd || '', processInfo.pid),
218
286
  type: this.type,
219
287
  status: AgentStatus.IDLE,
220
288
  summary: 'Unknown',
221
289
  pid: processInfo.pid,
222
- projectPath: processCwd,
290
+ projectPath: processInfo.cwd || '',
223
291
  sessionId: `pid-${processInfo.pid}`,
224
292
  lastActive: new Date(),
225
293
  };
226
294
  }
227
295
 
228
- private selectBestSession(
229
- processInfo: ProcessInfo,
230
- sessions: ClaudeSession[],
231
- usedSessionIds: Set<string>,
232
- processStartByPid: Map<number, Date>,
233
- mode: SessionMatchMode,
234
- ): ClaudeSession | undefined {
235
- const candidates = this.filterCandidateSessions(
236
- processInfo,
237
- sessions,
238
- usedSessionIds,
239
- mode,
240
- );
241
-
242
- if (candidates.length === 0) {
243
- return undefined;
244
- }
245
-
246
- const processStart = processStartByPid.get(processInfo.pid);
247
- if (!processStart) {
248
- return candidates.sort(
249
- (a, b) => b.lastActive.getTime() - a.lastActive.getTime(),
250
- )[0];
251
- }
252
-
253
- const best = this.rankCandidatesByStartTime(candidates, processStart)[0];
254
- if (!best) {
255
- return undefined;
256
- }
257
-
258
- // In early modes (cwd/missing-cwd), defer assignment when the best
259
- // candidate is outside start-time tolerance — a closer match may
260
- // exist in parent-child mode (e.g., worktree sessions).
261
- if (mode !== 'parent-child') {
262
- const diffMs = Math.abs(
263
- best.sessionStart.getTime() - processStart.getTime(),
264
- );
265
- if (diffMs > ClaudeCodeAdapter.PROCESS_SESSION_TIME_TOLERANCE_MS) {
266
- return undefined;
267
- }
268
- }
269
-
270
- return best;
271
- }
272
-
273
- private filterCandidateSessions(
274
- processInfo: ProcessInfo,
275
- sessions: ClaudeSession[],
276
- usedSessionIds: Set<string>,
277
- mode: SessionMatchMode,
278
- ): ClaudeSession[] {
279
- return sessions.filter((session) => {
280
- if (usedSessionIds.has(session.sessionId)) {
281
- return false;
282
- }
283
-
284
- if (mode === 'cwd') {
285
- return (
286
- this.pathEquals(processInfo.cwd, session.projectPath) ||
287
- this.pathEquals(processInfo.cwd, session.lastCwd)
288
- );
289
- }
290
-
291
- if (mode === 'missing-cwd') {
292
- return !session.projectPath;
293
- }
294
-
295
- // parent-child mode: match if process CWD equals, is under, or is
296
- // a parent of session project/lastCwd. This also catches exact CWD
297
- // matches that were deferred from `cwd` mode due to start-time tolerance.
298
- return (
299
- this.pathRelated(processInfo.cwd, session.projectPath) ||
300
- this.pathRelated(processInfo.cwd, session.lastCwd)
301
- );
302
- });
303
- }
304
-
305
- private rankCandidatesByStartTime(
306
- candidates: ClaudeSession[],
307
- processStart: Date,
308
- ): ClaudeSession[] {
309
- const toleranceMs = ClaudeCodeAdapter.PROCESS_SESSION_TIME_TOLERANCE_MS;
310
-
311
- return candidates
312
- .map((session) => {
313
- const diffMs = Math.abs(
314
- session.sessionStart.getTime() - processStart.getTime(),
315
- );
316
- const outsideTolerance = diffMs > toleranceMs ? 1 : 0;
317
- return {
318
- session,
319
- rank: outsideTolerance,
320
- diffMs,
321
- recency: session.lastActive.getTime(),
322
- };
323
- })
324
- .sort((a, b) => {
325
- if (a.rank !== b.rank) return a.rank - b.rank;
326
- // Within tolerance (rank 0): prefer most recently active session.
327
- // The exact diff is noise — a 6s vs 45s difference is meaningless,
328
- // but the session with recent activity is more likely the real one.
329
- if (a.rank === 0) return b.recency - a.recency;
330
- // Outside tolerance: prefer smallest time difference, then recency.
331
- if (a.diffMs !== b.diffMs) return a.diffMs - b.diffMs;
332
- return b.recency - a.recency;
333
- })
334
- .map((ranked) => ranked.session);
335
- }
336
-
337
- private getProcessStartTimes(pids: number[]): Map<number, Date> {
338
- if (pids.length === 0 || process.env.JEST_WORKER_ID) {
339
- return new Map();
340
- }
341
-
342
- try {
343
- const output = execSync(
344
- `ps -o pid=,etime= -p ${pids.join(',')}`,
345
- { encoding: 'utf-8' },
346
- );
347
- const nowMs = Date.now();
348
- const startTimes = new Map<number, Date>();
349
-
350
- for (const rawLine of output.split('\n')) {
351
- const line = rawLine.trim();
352
- if (!line) continue;
353
-
354
- const parts = line.split(/\s+/);
355
- if (parts.length < 2) continue;
356
-
357
- const pid = Number.parseInt(parts[0], 10);
358
- const elapsedSeconds = this.parseElapsedSeconds(parts[1]);
359
- if (!Number.isFinite(pid) || elapsedSeconds === null) continue;
360
-
361
- startTimes.set(pid, new Date(nowMs - elapsedSeconds * 1000));
362
- }
363
-
364
- return startTimes;
365
- } catch {
366
- return new Map();
367
- }
368
- }
369
-
370
- private parseElapsedSeconds(etime: string): number | null {
371
- const match = etime
372
- .trim()
373
- .match(/^(?:(\d+)-)?(?:(\d{1,2}):)?(\d{1,2}):(\d{2})$/);
374
- if (!match) {
375
- return null;
376
- }
377
-
378
- const days = Number.parseInt(match[1] || '0', 10);
379
- const hours = Number.parseInt(match[2] || '0', 10);
380
- const minutes = Number.parseInt(match[3] || '0', 10);
381
- const seconds = Number.parseInt(match[4] || '0', 10);
382
-
383
- return ((days * 24 + hours) * 60 + minutes) * 60 + seconds;
384
- }
385
-
386
- /**
387
- * Read Claude Code sessions with bounded scanning
388
- */
389
- private readSessions(limit: number): ClaudeSession[] {
390
- const sessionFiles = this.findSessionFiles(limit);
391
- const sessions: ClaudeSession[] = [];
392
-
393
- for (const file of sessionFiles) {
394
- try {
395
- const session = this.readSession(file.filePath, file.projectPath);
396
- if (session) {
397
- sessions.push(session);
398
- }
399
- } catch (error) {
400
- console.error(`Failed to parse Claude session ${file.filePath}:`, error);
401
- }
402
- }
403
-
404
- return sessions;
405
- }
406
-
407
- /**
408
- * Find session files bounded by mtime, sorted most-recent first
409
- */
410
- private findSessionFiles(
411
- limit: number,
412
- ): Array<{ filePath: string; projectPath: string; mtimeMs: number }> {
413
- if (!fs.existsSync(this.projectsDir)) {
414
- return [];
415
- }
416
-
417
- const files: Array<{
418
- filePath: string;
419
- projectPath: string;
420
- mtimeMs: number;
421
- }> = [];
422
-
423
- for (const dirName of fs.readdirSync(this.projectsDir)) {
424
- if (dirName.startsWith('.')) {
425
- continue;
426
- }
427
-
428
- const projectDir = path.join(this.projectsDir, dirName);
429
- try {
430
- if (!fs.statSync(projectDir).isDirectory()) continue;
431
- } catch {
432
- continue;
433
- }
434
-
435
- const indexPath = path.join(projectDir, 'sessions-index.json');
436
- const index = readJson<SessionsIndex>(indexPath);
437
- const projectPath = index?.originalPath || '';
438
-
439
- for (const entry of fs.readdirSync(projectDir)) {
440
- if (!entry.endsWith('.jsonl')) {
441
- continue;
442
- }
443
-
444
- const filePath = path.join(projectDir, entry);
445
- try {
446
- files.push({
447
- filePath,
448
- projectPath,
449
- mtimeMs: fs.statSync(filePath).mtimeMs,
450
- });
451
- } catch {
452
- continue;
453
- }
454
- }
455
- }
456
-
457
- // Ensure breadth: include at least the most recent session per project,
458
- // then fill remaining slots with globally most-recent sessions.
459
- const sorted = files.sort((a, b) => b.mtimeMs - a.mtimeMs);
460
- const result: typeof files = [];
461
- const seenProjects = new Set<string>();
462
-
463
- // First pass: one most-recent session per project directory
464
- for (const file of sorted) {
465
- const projDir = path.dirname(file.filePath);
466
- if (!seenProjects.has(projDir)) {
467
- seenProjects.add(projDir);
468
- result.push(file);
469
- }
470
- }
471
-
472
- // Second pass: fill remaining slots with globally most-recent
473
- if (result.length < limit) {
474
- const resultSet = new Set(result.map((f) => f.filePath));
475
- for (const file of sorted) {
476
- if (result.length >= limit) break;
477
- if (!resultSet.has(file.filePath)) {
478
- result.push(file);
479
- }
480
- }
481
- }
482
-
483
- return result.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, limit);
484
- }
485
-
486
296
  /**
487
297
  * Parse a single session file into ClaudeSession
488
298
  */
@@ -627,57 +437,6 @@ export class ClaudeCodeAdapter implements AgentAdapter {
627
437
  return AgentStatus.UNKNOWN;
628
438
  }
629
439
 
630
- /**
631
- * Generate unique agent name
632
- * Uses project basename, appends slug if multiple sessions for same project
633
- */
634
- private generateAgentName(
635
- session: ClaudeSession,
636
- existingAgents: AgentInfo[],
637
- ): string {
638
- const projectName = path.basename(session.projectPath) || 'claude';
639
-
640
- const sameProjectAgents = existingAgents.filter(
641
- (a) => a.projectPath === session.projectPath,
642
- );
643
-
644
- if (sameProjectAgents.length === 0) {
645
- return projectName;
646
- }
647
-
648
- if (session.slug) {
649
- const slugPart = session.slug.includes('-')
650
- ? session.slug.split('-')[0]
651
- : session.slug.slice(0, 8);
652
- return `${projectName} (${slugPart})`;
653
- }
654
-
655
- return `${projectName} (${session.sessionId.slice(0, 8)})`;
656
- }
657
-
658
- /** Check if two paths are equal, or one is a parent/child of the other. */
659
- private pathRelated(a?: string, b?: string): boolean {
660
- return this.pathEquals(a, b) || this.isChildPath(a, b) || this.isChildPath(b, a);
661
- }
662
-
663
- private pathEquals(a?: string, b?: string): boolean {
664
- if (!a || !b) {
665
- return false;
666
- }
667
-
668
- return this.normalizePath(a) === this.normalizePath(b);
669
- }
670
-
671
- private isChildPath(child?: string, parent?: string): boolean {
672
- if (!child || !parent) {
673
- return false;
674
- }
675
-
676
- const normalizedChild = this.normalizePath(child);
677
- const normalizedParent = this.normalizePath(parent);
678
- return normalizedChild.startsWith(`${normalizedParent}${path.sep}`);
679
- }
680
-
681
440
  /**
682
441
  * Extract meaningful text from a user message content.
683
442
  * Handles string and array formats, skill command expansion, and noise filtering.
@@ -758,11 +517,4 @@ export class ClaudeCodeAdapter implements AgentAdapter {
758
517
  return type === 'last-prompt' || type === 'file-history-snapshot';
759
518
  }
760
519
 
761
- private normalizePath(value: string): string {
762
- const resolved = path.resolve(value);
763
- if (resolved.length > 1 && resolved.endsWith(path.sep)) {
764
- return resolved.slice(0, -1);
765
- }
766
- return resolved;
767
- }
768
520
  }