@ai-devkit/agent-manager 0.3.0 → 0.4.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.
@@ -1,16 +1,10 @@
1
- /**
2
- * Claude Code Adapter
3
- *
4
- * Detects running Claude Code agents by reading session files
5
- * from ~/.claude/ directory and correlating with running processes.
6
- */
7
-
8
1
  import * as fs from 'fs';
9
2
  import * as path from 'path';
3
+ import { execSync } from 'child_process';
10
4
  import type { AgentAdapter, AgentInfo, ProcessInfo } from './AgentAdapter';
11
5
  import { AgentStatus } from './AgentAdapter';
12
6
  import { listProcesses } from '../utils/process';
13
- import { readLastLines, readJsonLines, readJson } from '../utils/file';
7
+ import { readJson } from '../utils/file';
14
8
 
15
9
  /**
16
10
  * Structure of ~/.claude/projects/{path}/sessions-index.json
@@ -19,43 +13,21 @@ interface SessionsIndex {
19
13
  originalPath: string;
20
14
  }
21
15
 
22
- enum SessionEntryType {
23
- ASSISTANT = 'assistant',
24
- USER = 'user',
25
- PROGRESS = 'progress',
26
- THINKING = 'thinking',
27
- SYSTEM = 'system',
28
- MESSAGE = 'message',
29
- TEXT = 'text',
30
- }
31
-
32
16
  /**
33
17
  * Entry in session JSONL file
34
18
  */
35
19
  interface SessionEntry {
36
- type?: SessionEntryType;
20
+ type?: string;
37
21
  timestamp?: string;
38
22
  slug?: string;
39
23
  cwd?: string;
40
- sessionId?: string;
41
24
  message?: {
42
- content?: Array<{
25
+ content?: string | Array<{
43
26
  type?: string;
44
27
  text?: string;
45
28
  content?: string;
46
29
  }>;
47
30
  };
48
- [key: string]: unknown;
49
- }
50
-
51
- /**
52
- * Entry in ~/.claude/history.jsonl
53
- */
54
- interface HistoryEntry {
55
- display: string;
56
- timestamp: number;
57
- project: string;
58
- sessionId: string;
59
31
  }
60
32
 
61
33
  /**
@@ -66,135 +38,122 @@ interface ClaudeSession {
66
38
  projectPath: string;
67
39
  lastCwd?: string;
68
40
  slug?: string;
69
- sessionLogPath: string;
70
- lastEntry?: SessionEntry;
71
- lastActive?: Date;
41
+ sessionStart: Date;
42
+ lastActive: Date;
43
+ lastEntryType?: string;
44
+ isInterrupted: boolean;
45
+ lastUserMessage?: string;
72
46
  }
73
47
 
74
- type SessionMatchMode = 'cwd' | 'project-parent';
48
+ type SessionMatchMode = 'cwd' | 'missing-cwd' | 'parent-child';
75
49
 
76
50
  /**
77
51
  * Claude Code Adapter
78
- *
52
+ *
79
53
  * Detects Claude Code agents by:
80
54
  * 1. Finding running claude processes
81
- * 2. Reading session files from ~/.claude/projects/
82
- * 3. Matching sessions to processes via CWD
83
- * 4. Extracting status from session JSONL
84
- * 5. Extracting summary from history.jsonl
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
58
+ * 5. Extracting summary from last user message in session JSONL
85
59
  */
86
60
  export class ClaudeCodeAdapter implements AgentAdapter {
87
61
  readonly type = 'claude' as const;
88
62
 
89
- /** Threshold in minutes before considering a session idle */
90
- private static readonly IDLE_THRESHOLD_MINUTES = 5;
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;
91
69
 
92
- private claudeDir: string;
93
70
  private projectsDir: string;
94
- private historyPath: string;
95
71
 
96
72
  constructor() {
97
73
  const homeDir = process.env.HOME || process.env.USERPROFILE || '';
98
- this.claudeDir = path.join(homeDir, '.claude');
99
- this.projectsDir = path.join(this.claudeDir, 'projects');
100
- this.historyPath = path.join(this.claudeDir, 'history.jsonl');
74
+ this.projectsDir = path.join(homeDir, '.claude', 'projects');
101
75
  }
102
76
 
103
77
  /**
104
78
  * Check if this adapter can handle a given process
105
79
  */
106
80
  canHandle(processInfo: ProcessInfo): boolean {
107
- return processInfo.command.toLowerCase().includes('claude');
81
+ return this.isClaudeExecutable(processInfo.command);
82
+ }
83
+
84
+ private isClaudeExecutable(command: string): boolean {
85
+ const executable = command.trim().split(/\s+/)[0] || '';
86
+ const base = path.basename(executable).toLowerCase();
87
+ return base === 'claude' || base === 'claude.exe';
108
88
  }
109
89
 
110
90
  /**
111
91
  * Detect running Claude Code agents
112
92
  */
113
93
  async detectAgents(): Promise<AgentInfo[]> {
114
- const claudeProcesses = listProcesses({ namePattern: 'claude' }).filter((processInfo) =>
115
- this.canHandle(processInfo),
116
- );
117
-
94
+ const claudeProcesses = this.listClaudeProcesses();
118
95
  if (claudeProcesses.length === 0) {
119
96
  return [];
120
97
  }
121
98
 
122
- const sessions = this.readSessions();
123
- const history = this.readHistory();
124
- const historyByProjectPath = this.indexHistoryByProjectPath(history);
125
- const historyBySessionId = new Map<string, HistoryEntry>();
126
- for (const entry of history) {
127
- historyBySessionId.set(entry.sessionId, entry);
128
- }
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);
129
104
 
130
- const sortedSessions = [...sessions].sort((a, b) => {
131
- const timeA = a.lastActive?.getTime() || 0;
132
- const timeB = b.lastActive?.getTime() || 0;
133
- return timeB - timeA;
134
- });
105
+ if (sessions.length === 0) {
106
+ return claudeProcesses.map((p) =>
107
+ this.mapProcessOnlyAgent(p, []),
108
+ );
109
+ }
135
110
 
111
+ const sortedSessions = [...sessions].sort(
112
+ (a, b) => b.lastActive.getTime() - a.lastActive.getTime(),
113
+ );
136
114
  const usedSessionIds = new Set<string>();
137
115
  const assignedPids = new Set<number>();
138
116
  const agents: AgentInfo[] = [];
139
117
 
140
- this.assignSessionsForMode(
141
- 'cwd',
142
- claudeProcesses,
143
- sortedSessions,
144
- usedSessionIds,
145
- assignedPids,
146
- historyBySessionId,
147
- agents,
148
- );
149
- this.assignHistoryEntriesForExactProcessCwd(
150
- claudeProcesses,
151
- assignedPids,
152
- historyByProjectPath,
153
- usedSessionIds,
154
- agents,
155
- );
156
- this.assignSessionsForMode(
157
- 'project-parent',
158
- claudeProcesses,
159
- sortedSessions,
160
- usedSessionIds,
161
- assignedPids,
162
- historyBySessionId,
163
- agents,
164
- );
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
+ );
129
+ }
130
+
165
131
  for (const processInfo of claudeProcesses) {
166
132
  if (assignedPids.has(processInfo.pid)) {
167
133
  continue;
168
134
  }
169
135
 
170
136
  assignedPids.add(processInfo.pid);
171
- agents.push(this.mapProcessOnlyAgent(processInfo, agents, historyByProjectPath, usedSessionIds));
137
+ agents.push(this.mapProcessOnlyAgent(processInfo, agents));
172
138
  }
173
139
 
174
140
  return agents;
175
141
  }
176
142
 
177
- private assignHistoryEntriesForExactProcessCwd(
178
- claudeProcesses: ProcessInfo[],
179
- assignedPids: Set<number>,
180
- historyByProjectPath: Map<string, HistoryEntry[]>,
181
- usedSessionIds: Set<string>,
182
- agents: AgentInfo[],
183
- ): void {
184
- for (const processInfo of claudeProcesses) {
185
- if (assignedPids.has(processInfo.pid)) {
186
- continue;
187
- }
188
-
189
- const historyEntry = this.selectHistoryForProcess(processInfo.cwd || '', historyByProjectPath, usedSessionIds);
190
- if (!historyEntry) {
191
- continue;
192
- }
143
+ private listClaudeProcesses(): ProcessInfo[] {
144
+ return listProcesses({ namePattern: 'claude' }).filter((p) =>
145
+ this.canHandle(p),
146
+ );
147
+ }
193
148
 
194
- assignedPids.add(processInfo.pid);
195
- usedSessionIds.add(historyEntry.sessionId);
196
- agents.push(this.mapHistoryToAgent(processInfo, historyEntry, agents));
197
- }
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
+ );
198
157
  }
199
158
 
200
159
  private assignSessionsForMode(
@@ -203,7 +162,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
203
162
  sessions: ClaudeSession[],
204
163
  usedSessionIds: Set<string>,
205
164
  assignedPids: Set<number>,
206
- historyBySessionId: Map<string, HistoryEntry>,
165
+ processStartByPid: Map<number, Date>,
207
166
  agents: AgentInfo[],
208
167
  ): void {
209
168
  for (const processInfo of claudeProcesses) {
@@ -211,321 +170,457 @@ export class ClaudeCodeAdapter implements AgentAdapter {
211
170
  continue;
212
171
  }
213
172
 
214
- const session = this.selectBestSession(processInfo, sessions, usedSessionIds, mode);
173
+ const session = this.selectBestSession(
174
+ processInfo,
175
+ sessions,
176
+ usedSessionIds,
177
+ processStartByPid,
178
+ mode,
179
+ );
215
180
  if (!session) {
216
181
  continue;
217
182
  }
218
183
 
219
184
  usedSessionIds.add(session.sessionId);
220
185
  assignedPids.add(processInfo.pid);
221
- agents.push(this.mapSessionToAgent(session, processInfo, historyBySessionId, agents));
186
+ agents.push(this.mapSessionToAgent(session, processInfo, agents));
222
187
  }
223
188
  }
224
189
 
225
- private selectBestSession(
226
- processInfo: ProcessInfo,
227
- sessions: ClaudeSession[],
228
- usedSessionIds: Set<string>,
229
- mode: SessionMatchMode,
230
- ): ClaudeSession | null {
231
- const candidates = sessions.filter((session) => {
232
- if (usedSessionIds.has(session.sessionId)) {
233
- return false;
234
- }
235
-
236
- if (mode === 'cwd') {
237
- return this.pathEquals(processInfo.cwd, session.lastCwd)
238
- || this.pathEquals(processInfo.cwd, session.projectPath);
239
- }
240
-
241
- if (mode === 'project-parent') {
242
- return this.isChildPath(processInfo.cwd, session.projectPath)
243
- || this.isChildPath(processInfo.cwd, session.lastCwd);
244
- }
245
-
246
- return false;
247
- });
248
-
249
- if (candidates.length === 0) {
250
- return null;
251
- }
252
-
253
- if (mode !== 'project-parent') {
254
- return candidates[0];
255
- }
256
-
257
- return candidates.sort((a, b) => {
258
- const depthA = Math.max(this.pathDepth(a.projectPath), this.pathDepth(a.lastCwd));
259
- const depthB = Math.max(this.pathDepth(b.projectPath), this.pathDepth(b.lastCwd));
260
- if (depthA !== depthB) {
261
- return depthB - depthA;
262
- }
263
-
264
- const lastActiveA = a.lastActive?.getTime() || 0;
265
- const lastActiveB = b.lastActive?.getTime() || 0;
266
- return lastActiveB - lastActiveA;
267
- })[0];
268
- }
269
-
270
190
  private mapSessionToAgent(
271
191
  session: ClaudeSession,
272
192
  processInfo: ProcessInfo,
273
- historyBySessionId: Map<string, HistoryEntry>,
274
193
  existingAgents: AgentInfo[],
275
194
  ): AgentInfo {
276
- const historyEntry = historyBySessionId.get(session.sessionId);
277
-
278
195
  return {
279
196
  name: this.generateAgentName(session, existingAgents),
280
197
  type: this.type,
281
198
  status: this.determineStatus(session),
282
- summary: historyEntry?.display || 'Session started',
199
+ summary: session.lastUserMessage || 'Session started',
283
200
  pid: processInfo.pid,
284
201
  projectPath: session.projectPath || processInfo.cwd || '',
285
202
  sessionId: session.sessionId,
286
203
  slug: session.slug,
287
- lastActive: session.lastActive || new Date(),
204
+ lastActive: session.lastActive,
288
205
  };
289
206
  }
290
207
 
291
208
  private mapProcessOnlyAgent(
292
209
  processInfo: ProcessInfo,
293
210
  existingAgents: AgentInfo[],
294
- historyByProjectPath: Map<string, HistoryEntry[]>,
295
- usedSessionIds: Set<string>,
296
211
  ): AgentInfo {
297
- const projectPath = processInfo.cwd || '';
298
- const historyEntry = this.selectHistoryForProcess(projectPath, historyByProjectPath, usedSessionIds);
299
- const sessionId = historyEntry?.sessionId || `pid-${processInfo.pid}`;
300
- const lastActive = historyEntry ? new Date(historyEntry.timestamp) : new Date();
301
- if (historyEntry) {
302
- usedSessionIds.add(historyEntry.sessionId);
303
- }
304
-
305
- const processSession: ClaudeSession = {
306
- sessionId,
307
- projectPath,
308
- lastCwd: projectPath,
309
- sessionLogPath: '',
310
- lastActive,
311
- };
212
+ const processCwd = processInfo.cwd || '';
213
+ const projectName = path.basename(processCwd) || 'claude';
214
+ const hasDuplicate = existingAgents.some((a) => a.projectPath === processCwd);
312
215
 
313
216
  return {
314
- name: this.generateAgentName(processSession, existingAgents),
217
+ name: hasDuplicate ? `${projectName} (pid-${processInfo.pid})` : projectName,
315
218
  type: this.type,
316
- status: AgentStatus.RUNNING,
317
- summary: historyEntry?.display || 'Claude process running',
219
+ status: AgentStatus.IDLE,
220
+ summary: 'Unknown',
318
221
  pid: processInfo.pid,
319
- projectPath,
320
- sessionId: processSession.sessionId,
321
- lastActive: processSession.lastActive || new Date(),
222
+ projectPath: processCwd,
223
+ sessionId: `pid-${processInfo.pid}`,
224
+ lastActive: new Date(),
322
225
  };
323
226
  }
324
227
 
325
- private mapHistoryToAgent(
228
+ private selectBestSession(
326
229
  processInfo: ProcessInfo,
327
- historyEntry: HistoryEntry,
328
- existingAgents: AgentInfo[],
329
- ): AgentInfo {
330
- const projectPath = processInfo.cwd || historyEntry.project;
331
- const historySession: ClaudeSession = {
332
- sessionId: historyEntry.sessionId,
333
- projectPath,
334
- lastCwd: projectPath,
335
- sessionLogPath: '',
336
- lastActive: new Date(historyEntry.timestamp),
337
- };
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
+ );
338
241
 
339
- return {
340
- name: this.generateAgentName(historySession, existingAgents),
341
- type: this.type,
342
- status: AgentStatus.RUNNING,
343
- summary: historyEntry.display || 'Claude process running',
344
- pid: processInfo.pid,
345
- projectPath,
346
- sessionId: historySession.sessionId,
347
- lastActive: historySession.lastActive || new Date(),
348
- };
349
- }
242
+ if (candidates.length === 0) {
243
+ return undefined;
244
+ }
350
245
 
351
- private indexHistoryByProjectPath(historyEntries: HistoryEntry[]): Map<string, HistoryEntry[]> {
352
- const grouped = new Map<string, HistoryEntry[]>();
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
+ }
353
252
 
354
- for (const entry of historyEntries) {
355
- const key = this.normalizePath(entry.project);
356
- const list = grouped.get(key) || [];
357
- list.push(entry);
358
- grouped.set(key, list);
253
+ const best = this.rankCandidatesByStartTime(candidates, processStart)[0];
254
+ if (!best) {
255
+ return undefined;
359
256
  }
360
257
 
361
- for (const [key, list] of grouped.entries()) {
362
- grouped.set(
363
- key,
364
- [...list].sort((a, b) => b.timestamp - a.timestamp),
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(),
365
264
  );
265
+ if (diffMs > ClaudeCodeAdapter.PROCESS_SESSION_TIME_TOLERANCE_MS) {
266
+ return undefined;
267
+ }
366
268
  }
367
269
 
368
- return grouped;
270
+ return best;
369
271
  }
370
272
 
371
- private selectHistoryForProcess(
372
- processCwd: string,
373
- historyByProjectPath: Map<string, HistoryEntry[]>,
273
+ private filterCandidateSessions(
274
+ processInfo: ProcessInfo,
275
+ sessions: ClaudeSession[],
374
276
  usedSessionIds: Set<string>,
375
- ): HistoryEntry | undefined {
376
- if (!processCwd) {
377
- return undefined;
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();
378
340
  }
379
341
 
380
- const candidates = historyByProjectPath.get(this.normalizePath(processCwd)) || [];
381
- return candidates.find((entry) => !usedSessionIds.has(entry.sessionId));
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;
382
384
  }
383
385
 
384
386
  /**
385
- * Read all Claude Code sessions
387
+ * Read Claude Code sessions with bounded scanning
386
388
  */
387
- private readSessions(): ClaudeSession[] {
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 }> {
388
413
  if (!fs.existsSync(this.projectsDir)) {
389
414
  return [];
390
415
  }
391
416
 
392
- const sessions: ClaudeSession[] = [];
393
- const projectDirs = fs.readdirSync(this.projectsDir);
417
+ const files: Array<{
418
+ filePath: string;
419
+ projectPath: string;
420
+ mtimeMs: number;
421
+ }> = [];
394
422
 
395
- for (const dirName of projectDirs) {
423
+ for (const dirName of fs.readdirSync(this.projectsDir)) {
396
424
  if (dirName.startsWith('.')) {
397
425
  continue;
398
426
  }
399
427
 
400
428
  const projectDir = path.join(this.projectsDir, dirName);
401
- if (!fs.statSync(projectDir).isDirectory()) {
429
+ try {
430
+ if (!fs.statSync(projectDir).isDirectory()) continue;
431
+ } catch {
402
432
  continue;
403
433
  }
404
434
 
405
- // Read sessions-index.json to get original project path
406
435
  const indexPath = path.join(projectDir, 'sessions-index.json');
407
- if (!fs.existsSync(indexPath)) {
408
- continue;
409
- }
410
-
411
- const sessionsIndex = readJson<SessionsIndex>(indexPath);
412
- if (!sessionsIndex) {
413
- console.error(`Failed to parse ${indexPath}`);
414
- continue;
415
- }
436
+ const index = readJson<SessionsIndex>(indexPath);
437
+ const projectPath = index?.originalPath || '';
416
438
 
417
- const sessionFiles = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
418
-
419
- for (const sessionFile of sessionFiles) {
420
- const sessionId = sessionFile.replace('.jsonl', '');
421
- const sessionLogPath = path.join(projectDir, sessionFile);
439
+ for (const entry of fs.readdirSync(projectDir)) {
440
+ if (!entry.endsWith('.jsonl')) {
441
+ continue;
442
+ }
422
443
 
444
+ const filePath = path.join(projectDir, entry);
423
445
  try {
424
- const sessionData = this.readSessionLog(sessionLogPath);
425
-
426
- sessions.push({
427
- sessionId,
428
- projectPath: sessionsIndex.originalPath,
429
- lastCwd: sessionData.lastCwd,
430
- slug: sessionData.slug,
431
- sessionLogPath,
432
- lastEntry: sessionData.lastEntry,
433
- lastActive: sessionData.lastActive,
446
+ files.push({
447
+ filePath,
448
+ projectPath,
449
+ mtimeMs: fs.statSync(filePath).mtimeMs,
434
450
  });
435
- } catch (error) {
436
- console.error(`Failed to read session ${sessionId}:`, error);
451
+ } catch {
437
452
  continue;
438
453
  }
439
454
  }
440
455
  }
441
456
 
442
- return sessions;
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);
443
484
  }
444
485
 
445
486
  /**
446
- * Read a session JSONL file
447
- * Only reads last 100 lines for performance with large files
487
+ * Parse a single session file into ClaudeSession
448
488
  */
449
- private readSessionLog(logPath: string): {
450
- slug?: string;
451
- lastEntry?: SessionEntry;
452
- lastActive?: Date;
453
- lastCwd?: string;
454
- } {
455
- const lines = readLastLines(logPath, 100);
489
+ private readSession(
490
+ filePath: string,
491
+ projectPath: string,
492
+ ): ClaudeSession | null {
493
+ const sessionId = path.basename(filePath, '.jsonl');
456
494
 
495
+ let content: string;
496
+ try {
497
+ content = fs.readFileSync(filePath, 'utf-8');
498
+ } catch {
499
+ return null;
500
+ }
501
+
502
+ const allLines = content.trim().split('\n');
503
+ if (allLines.length === 0) {
504
+ return null;
505
+ }
506
+
507
+ // Parse first line for sessionStart.
508
+ // Claude Code may emit a "file-history-snapshot" as the first entry, which
509
+ // stores its timestamp inside "snapshot.timestamp" rather than at the root.
510
+ let sessionStart: Date | null = null;
511
+ try {
512
+ const firstEntry = JSON.parse(allLines[0]);
513
+ const rawTs: string | undefined =
514
+ firstEntry.timestamp || firstEntry.snapshot?.timestamp;
515
+ if (rawTs) {
516
+ const ts = new Date(rawTs);
517
+ if (!Number.isNaN(ts.getTime())) {
518
+ sessionStart = ts;
519
+ }
520
+ }
521
+ } catch {
522
+ /* skip */
523
+ }
524
+
525
+ // Parse all lines for session state (file already in memory)
457
526
  let slug: string | undefined;
458
- let lastEntry: SessionEntry | undefined;
527
+ let lastEntryType: string | undefined;
459
528
  let lastActive: Date | undefined;
460
529
  let lastCwd: string | undefined;
530
+ let isInterrupted = false;
531
+ let lastUserMessage: string | undefined;
461
532
 
462
- for (const line of lines) {
533
+ for (const line of allLines) {
463
534
  try {
464
535
  const entry: SessionEntry = JSON.parse(line);
465
536
 
466
- if (entry.slug && !slug) {
467
- slug = entry.slug;
537
+ if (entry.timestamp) {
538
+ const ts = new Date(entry.timestamp);
539
+ if (!Number.isNaN(ts.getTime())) {
540
+ lastActive = ts;
541
+ }
468
542
  }
469
543
 
470
- lastEntry = entry;
471
-
472
- if (entry.timestamp) {
473
- lastActive = new Date(entry.timestamp);
544
+ if (entry.slug && !slug) {
545
+ slug = entry.slug;
474
546
  }
475
547
 
476
548
  if (typeof entry.cwd === 'string' && entry.cwd.trim().length > 0) {
477
549
  lastCwd = entry.cwd;
478
550
  }
479
- } catch (error) {
551
+
552
+ if (entry.type && !this.isMetadataEntryType(entry.type)) {
553
+ lastEntryType = entry.type;
554
+
555
+ if (entry.type === 'user') {
556
+ const msgContent = entry.message?.content;
557
+ isInterrupted =
558
+ Array.isArray(msgContent) &&
559
+ msgContent.some(
560
+ (c) =>
561
+ (c.type === 'text' &&
562
+ c.text?.includes('[Request interrupted')) ||
563
+ (c.type === 'tool_result' &&
564
+ c.content?.includes('[Request interrupted')),
565
+ );
566
+
567
+ // Extract user message text for summary fallback
568
+ const text = this.extractUserMessageText(msgContent);
569
+ if (text) {
570
+ lastUserMessage = text;
571
+ }
572
+ } else {
573
+ isInterrupted = false;
574
+ }
575
+ }
576
+ } catch {
480
577
  continue;
481
578
  }
482
579
  }
483
580
 
484
- return { slug, lastEntry, lastActive, lastCwd };
485
- }
486
-
487
- /**
488
- * Read history.jsonl for user prompts
489
- * Only reads last 100 lines for performance
490
- */
491
- private readHistory(): HistoryEntry[] {
492
- return readJsonLines<HistoryEntry>(this.historyPath, 100);
581
+ return {
582
+ sessionId,
583
+ projectPath: projectPath || lastCwd || '',
584
+ lastCwd,
585
+ slug,
586
+ sessionStart: sessionStart || lastActive || new Date(),
587
+ lastActive: lastActive || new Date(),
588
+ lastEntryType,
589
+ isInterrupted,
590
+ lastUserMessage,
591
+ };
493
592
  }
494
593
 
495
594
  /**
496
- * Determine agent status from session entry
595
+ * Determine agent status from session state
497
596
  */
498
597
  private determineStatus(session: ClaudeSession): AgentStatus {
499
- if (!session.lastEntry) {
598
+ if (!session.lastEntryType) {
500
599
  return AgentStatus.UNKNOWN;
501
600
  }
502
601
 
503
- const entryType = session.lastEntry.type;
504
- const lastActive = session.lastActive || new Date(0);
505
- const ageMinutes = (Date.now() - lastActive.getTime()) / 1000 / 60;
602
+ // No age-based IDLE override: every agent in the list is backed by
603
+ // a running process (found via ps), so the entry type is the best
604
+ // indicator of actual state.
506
605
 
507
- if (ageMinutes > ClaudeCodeAdapter.IDLE_THRESHOLD_MINUTES) {
508
- return AgentStatus.IDLE;
606
+ if (session.lastEntryType === 'user') {
607
+ return session.isInterrupted
608
+ ? AgentStatus.WAITING
609
+ : AgentStatus.RUNNING;
509
610
  }
510
611
 
511
- if (entryType === SessionEntryType.USER) {
512
- // Check if user interrupted manually - this puts agent back in waiting state
513
- const content = session.lastEntry.message?.content;
514
- if (Array.isArray(content)) {
515
- const isInterrupted = content.some(c =>
516
- (c.type === SessionEntryType.TEXT && c.text?.includes('[Request interrupted')) ||
517
- (c.type === 'tool_result' && c.content?.includes('[Request interrupted'))
518
- );
519
- if (isInterrupted) return AgentStatus.WAITING;
520
- }
612
+ if (
613
+ session.lastEntryType === 'progress' ||
614
+ session.lastEntryType === 'thinking'
615
+ ) {
521
616
  return AgentStatus.RUNNING;
522
617
  }
523
618
 
524
- if (entryType === SessionEntryType.PROGRESS || entryType === SessionEntryType.THINKING) {
525
- return AgentStatus.RUNNING;
526
- } else if (entryType === SessionEntryType.ASSISTANT) {
619
+ if (session.lastEntryType === 'assistant') {
527
620
  return AgentStatus.WAITING;
528
- } else if (entryType === SessionEntryType.SYSTEM) {
621
+ }
622
+
623
+ if (session.lastEntryType === 'system') {
529
624
  return AgentStatus.IDLE;
530
625
  }
531
626
 
@@ -536,30 +631,35 @@ export class ClaudeCodeAdapter implements AgentAdapter {
536
631
  * Generate unique agent name
537
632
  * Uses project basename, appends slug if multiple sessions for same project
538
633
  */
539
- private generateAgentName(session: ClaudeSession, existingAgents: AgentInfo[]): string {
634
+ private generateAgentName(
635
+ session: ClaudeSession,
636
+ existingAgents: AgentInfo[],
637
+ ): string {
540
638
  const projectName = path.basename(session.projectPath) || 'claude';
541
639
 
542
640
  const sameProjectAgents = existingAgents.filter(
543
- a => a.projectPath === session.projectPath
641
+ (a) => a.projectPath === session.projectPath,
544
642
  );
545
643
 
546
644
  if (sameProjectAgents.length === 0) {
547
645
  return projectName;
548
646
  }
549
647
 
550
- // Multiple sessions for same project, append slug
551
648
  if (session.slug) {
552
- // Use first word of slug for brevity (with safety check for format)
553
649
  const slugPart = session.slug.includes('-')
554
650
  ? session.slug.split('-')[0]
555
651
  : session.slug.slice(0, 8);
556
652
  return `${projectName} (${slugPart})`;
557
653
  }
558
654
 
559
- // No slug available, use session ID prefix
560
655
  return `${projectName} (${session.sessionId.slice(0, 8)})`;
561
656
  }
562
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
+
563
663
  private pathEquals(a?: string, b?: string): boolean {
564
664
  if (!a || !b) {
565
665
  return false;
@@ -575,23 +675,94 @@ export class ClaudeCodeAdapter implements AgentAdapter {
575
675
 
576
676
  const normalizedChild = this.normalizePath(child);
577
677
  const normalizedParent = this.normalizePath(parent);
578
- return normalizedChild === normalizedParent || normalizedChild.startsWith(`${normalizedParent}${path.sep}`);
678
+ return normalizedChild.startsWith(`${normalizedParent}${path.sep}`);
579
679
  }
580
680
 
581
- private normalizePath(value: string): string {
582
- const resolved = path.resolve(value);
583
- if (resolved.length > 1 && resolved.endsWith(path.sep)) {
584
- return resolved.slice(0, -1);
681
+ /**
682
+ * Extract meaningful text from a user message content.
683
+ * Handles string and array formats, skill command expansion, and noise filtering.
684
+ */
685
+ private extractUserMessageText(
686
+ content: string | Array<{ type?: string; text?: string }> | undefined,
687
+ ): string | undefined {
688
+ if (!content) {
689
+ return undefined;
585
690
  }
586
- return resolved;
691
+
692
+ let raw: string | undefined;
693
+
694
+ if (typeof content === 'string') {
695
+ raw = content.trim();
696
+ } else if (Array.isArray(content)) {
697
+ for (const block of content) {
698
+ if (block.type === 'text' && block.text?.trim()) {
699
+ raw = block.text.trim();
700
+ break;
701
+ }
702
+ }
703
+ }
704
+
705
+ if (!raw) {
706
+ return undefined;
707
+ }
708
+
709
+ // Skill slash-command: extract /command-name and args
710
+ if (raw.startsWith('<command-message>')) {
711
+ return this.parseCommandMessage(raw);
712
+ }
713
+
714
+ // Expanded skill content: extract ARGUMENTS line if present, skip otherwise
715
+ if (raw.startsWith('Base directory for this skill:')) {
716
+ const argsMatch = raw.match(/\nARGUMENTS:\s*(.+)/);
717
+ return argsMatch?.[1]?.trim() || undefined;
718
+ }
719
+
720
+ // Filter noise
721
+ if (this.isNoiseMessage(raw)) {
722
+ return undefined;
723
+ }
724
+
725
+ return raw;
587
726
  }
588
727
 
589
- private pathDepth(value?: string): number {
590
- if (!value) {
591
- return 0;
728
+ /**
729
+ * Parse a <command-message> string into "/command args" format.
730
+ */
731
+ private parseCommandMessage(raw: string): string | undefined {
732
+ const nameMatch = raw.match(/<command-name>([^<]+)<\/command-name>/);
733
+ const argsMatch = raw.match(/<command-args>([^<]+)<\/command-args>/);
734
+ const name = nameMatch?.[1]?.trim();
735
+ if (!name) {
736
+ return undefined;
592
737
  }
738
+ const args = argsMatch?.[1]?.trim();
739
+ return args ? `${name} ${args}` : name;
740
+ }
741
+
742
+ /**
743
+ * Check if a message is noise (not a meaningful user intent).
744
+ */
745
+ private isNoiseMessage(text: string): boolean {
746
+ return (
747
+ text.startsWith('[Request interrupted') ||
748
+ text === 'Tool loaded.' ||
749
+ text.startsWith('This session is being continued')
750
+ );
751
+ }
593
752
 
594
- return this.normalizePath(value).split(path.sep).filter(Boolean).length;
753
+ /**
754
+ * Check if an entry type is metadata (not conversation state).
755
+ * These should not overwrite lastEntryType used for status determination.
756
+ */
757
+ private isMetadataEntryType(type: string): boolean {
758
+ return type === 'last-prompt' || type === 'file-history-snapshot';
595
759
  }
596
760
 
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
+ }
597
768
  }