@ai-devkit/agent-manager 0.3.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 +49 -38
  4. package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
  5. package/dist/adapters/ClaudeCodeAdapter.js +286 -293
  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 -284
  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 +2 -2
  32. package/src/__tests__/AgentManager.test.ts +0 -25
  33. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +921 -205
  34. package/src/__tests__/adapters/CodexAdapter.test.ts +468 -269
  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 +341 -418
  40. package/src/adapters/CodexAdapter.ts +155 -420
  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,61 +1,46 @@
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';
10
3
  import type { AgentAdapter, AgentInfo, ProcessInfo } from './AgentAdapter';
11
4
  import { AgentStatus } from './AgentAdapter';
12
- import { listProcesses } from '../utils/process';
13
- import { readLastLines, readJsonLines, readJson } from '../utils/file';
14
-
15
- /**
16
- * Structure of ~/.claude/projects/{path}/sessions-index.json
17
- */
18
- interface SessionsIndex {
19
- originalPath: string;
20
- }
21
-
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
-
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';
32
9
  /**
33
10
  * Entry in session JSONL file
34
11
  */
35
12
  interface SessionEntry {
36
- type?: SessionEntryType;
13
+ type?: string;
37
14
  timestamp?: string;
38
15
  slug?: string;
39
16
  cwd?: string;
40
- sessionId?: string;
41
17
  message?: {
42
- content?: Array<{
18
+ content?: string | Array<{
43
19
  type?: string;
44
20
  text?: string;
45
21
  content?: string;
46
22
  }>;
47
23
  };
48
- [key: string]: unknown;
49
24
  }
50
25
 
51
26
  /**
52
- * Entry in ~/.claude/history.jsonl
27
+ * Entry in ~/.claude/sessions/<pid>.json written by Claude Code
53
28
  */
54
- interface HistoryEntry {
55
- display: string;
56
- timestamp: number;
57
- project: string;
29
+ interface PidFileEntry {
30
+ pid: number;
58
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;
59
44
  }
60
45
 
61
46
  /**
@@ -66,466 +51,386 @@ interface ClaudeSession {
66
51
  projectPath: string;
67
52
  lastCwd?: string;
68
53
  slug?: string;
69
- sessionLogPath: string;
70
- lastEntry?: SessionEntry;
71
- lastActive?: Date;
54
+ sessionStart: Date;
55
+ lastActive: Date;
56
+ lastEntryType?: string;
57
+ isInterrupted: boolean;
58
+ lastUserMessage?: string;
72
59
  }
73
60
 
74
- type SessionMatchMode = 'cwd' | 'project-parent';
75
-
76
61
  /**
77
62
  * Claude Code Adapter
78
- *
63
+ *
79
64
  * Detects Claude Code agents by:
80
- * 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
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
69
+ * 5. Extracting summary from last user message in session JSONL
85
70
  */
86
71
  export class ClaudeCodeAdapter implements AgentAdapter {
87
72
  readonly type = 'claude' as const;
88
73
 
89
- /** Threshold in minutes before considering a session idle */
90
- private static readonly IDLE_THRESHOLD_MINUTES = 5;
91
-
92
- private claudeDir: string;
93
74
  private projectsDir: string;
94
- private historyPath: string;
75
+ private sessionsDir: string;
95
76
 
96
77
  constructor() {
97
78
  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');
79
+ this.projectsDir = path.join(homeDir, '.claude', 'projects');
80
+ this.sessionsDir = path.join(homeDir, '.claude', 'sessions');
101
81
  }
102
82
 
103
83
  /**
104
84
  * Check if this adapter can handle a given process
105
85
  */
106
86
  canHandle(processInfo: ProcessInfo): boolean {
107
- return processInfo.command.toLowerCase().includes('claude');
87
+ return this.isClaudeExecutable(processInfo.command);
88
+ }
89
+
90
+ private isClaudeExecutable(command: string): boolean {
91
+ const executable = command.trim().split(/\s+/)[0] || '';
92
+ const base = path.basename(executable).toLowerCase();
93
+ return base === 'claude' || base === 'claude.exe';
108
94
  }
109
95
 
110
96
  /**
111
97
  * Detect running Claude Code agents
112
98
  */
113
99
  async detectAgents(): Promise<AgentInfo[]> {
114
- const claudeProcesses = listProcesses({ namePattern: 'claude' }).filter((processInfo) =>
115
- this.canHandle(processInfo),
116
- );
117
-
118
- if (claudeProcesses.length === 0) {
100
+ const processes = enrichProcesses(listAgentProcesses('claude'));
101
+ if (processes.length === 0) {
119
102
  return [];
120
103
  }
121
104
 
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
- }
105
+ // Step 1: try authoritative PID-file matching for every process
106
+ const { direct, fallback } = this.tryPidFileMatching(processes);
107
+
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
+ : [];
129
114
 
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
- });
115
+ const matchedPids = new Set([
116
+ ...direct.map((d) => d.process.pid),
117
+ ...legacyMatches.map((m) => m.process.pid),
118
+ ]);
135
119
 
136
- const usedSessionIds = new Set<string>();
137
- const assignedPids = new Set<number>();
138
120
  const agents: AgentInfo[] = [];
139
121
 
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
- );
165
- for (const processInfo of claudeProcesses) {
166
- if (assignedPids.has(processInfo.pid)) {
167
- continue;
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
+ }
130
+ }
131
+
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);
168
142
  }
143
+ }
169
144
 
170
- assignedPids.add(processInfo.pid);
171
- agents.push(this.mapProcessOnlyAgent(processInfo, agents, historyByProjectPath, usedSessionIds));
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
+ }
172
150
  }
173
151
 
174
152
  return agents;
175
153
  }
176
154
 
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
- }
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>();
165
+
166
+ for (const proc of processes) {
167
+ if (!proc.cwd) continue;
168
+
169
+ const projectDir = this.getProjectDir(proc.cwd);
170
+ if (dirToCwd.has(projectDir)) continue;
188
171
 
189
- const historyEntry = this.selectHistoryForProcess(processInfo.cwd || '', historyByProjectPath, usedSessionIds);
190
- if (!historyEntry) {
172
+ try {
173
+ if (!fs.statSync(projectDir).isDirectory()) continue;
174
+ } catch {
191
175
  continue;
192
176
  }
193
177
 
194
- assignedPids.add(processInfo.pid);
195
- usedSessionIds.add(historyEntry.sessionId);
196
- agents.push(this.mapHistoryToAgent(processInfo, historyEntry, agents));
178
+ dirToCwd.set(projectDir, proc.cwd);
197
179
  }
198
- }
199
180
 
200
- private assignSessionsForMode(
201
- mode: SessionMatchMode,
202
- claudeProcesses: ProcessInfo[],
203
- sessions: ClaudeSession[],
204
- usedSessionIds: Set<string>,
205
- assignedPids: Set<number>,
206
- historyBySessionId: Map<string, HistoryEntry>,
207
- agents: AgentInfo[],
208
- ): void {
209
- for (const processInfo of claudeProcesses) {
210
- if (assignedPids.has(processInfo.pid)) {
211
- continue;
212
- }
181
+ if (dirToCwd.size === 0) return [];
213
182
 
214
- const session = this.selectBestSession(processInfo, sessions, usedSessionIds, mode);
215
- if (!session) {
216
- continue;
217
- }
183
+ // Single batched stat call across all directories
184
+ const files = batchGetSessionFileBirthtimes([...dirToCwd.keys()]);
218
185
 
219
- usedSessionIds.add(session.sessionId);
220
- assignedPids.add(processInfo.pid);
221
- agents.push(this.mapSessionToAgent(session, processInfo, historyBySessionId, agents));
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) || '';
222
189
  }
223
- }
224
190
 
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
- }
191
+ return files;
192
+ }
235
193
 
236
- if (mode === 'cwd') {
237
- return this.pathEquals(processInfo.cwd, session.lastCwd)
238
- || this.pathEquals(processInfo.cwd, session.projectPath);
239
- }
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[] = [];
240
210
 
241
- if (mode === 'project-parent') {
242
- return this.isChildPath(processInfo.cwd, session.projectPath)
243
- || this.isChildPath(processInfo.cwd, session.lastCwd);
244
- }
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
+ }
245
226
 
246
- return false;
247
- });
227
+ const projectDir = this.getProjectDir(entry.cwd);
228
+ const jsonlPath = path.join(projectDir, `${entry.sessionId}.jsonl`);
248
229
 
249
- if (candidates.length === 0) {
250
- return null;
251
- }
230
+ if (!fs.existsSync(jsonlPath)) {
231
+ fallback.push(proc);
232
+ continue;
233
+ }
252
234
 
253
- if (mode !== 'project-parent') {
254
- return candidates[0];
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
+ }
255
249
  }
256
250
 
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
- }
251
+ return { direct, fallback };
252
+ }
263
253
 
264
- const lastActiveA = a.lastActive?.getTime() || 0;
265
- const lastActiveB = b.lastActive?.getTime() || 0;
266
- return lastActiveB - lastActiveA;
267
- })[0];
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);
268
263
  }
269
264
 
270
265
  private mapSessionToAgent(
271
266
  session: ClaudeSession,
272
267
  processInfo: ProcessInfo,
273
- historyBySessionId: Map<string, HistoryEntry>,
274
- existingAgents: AgentInfo[],
268
+ sessionFile: SessionFile,
275
269
  ): AgentInfo {
276
- const historyEntry = historyBySessionId.get(session.sessionId);
277
-
278
270
  return {
279
- name: this.generateAgentName(session, existingAgents),
271
+ name: generateAgentName(processInfo.cwd, processInfo.pid),
280
272
  type: this.type,
281
273
  status: this.determineStatus(session),
282
- summary: historyEntry?.display || 'Session started',
274
+ summary: session.lastUserMessage || 'Session started',
283
275
  pid: processInfo.pid,
284
- projectPath: session.projectPath || processInfo.cwd || '',
285
- sessionId: session.sessionId,
276
+ projectPath: sessionFile.resolvedCwd || processInfo.cwd || '',
277
+ sessionId: sessionFile.sessionId,
286
278
  slug: session.slug,
287
- lastActive: session.lastActive || new Date(),
288
- };
289
- }
290
-
291
- private mapProcessOnlyAgent(
292
- processInfo: ProcessInfo,
293
- existingAgents: AgentInfo[],
294
- historyByProjectPath: Map<string, HistoryEntry[]>,
295
- usedSessionIds: Set<string>,
296
- ): 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
- };
312
-
313
- return {
314
- name: this.generateAgentName(processSession, existingAgents),
315
- type: this.type,
316
- status: AgentStatus.RUNNING,
317
- summary: historyEntry?.display || 'Claude process running',
318
- pid: processInfo.pid,
319
- projectPath,
320
- sessionId: processSession.sessionId,
321
- lastActive: processSession.lastActive || new Date(),
279
+ lastActive: session.lastActive,
322
280
  };
323
281
  }
324
282
 
325
- private mapHistoryToAgent(
326
- 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
- };
338
-
283
+ private mapProcessOnlyAgent(processInfo: ProcessInfo): AgentInfo {
339
284
  return {
340
- name: this.generateAgentName(historySession, existingAgents),
285
+ name: generateAgentName(processInfo.cwd || '', processInfo.pid),
341
286
  type: this.type,
342
- status: AgentStatus.RUNNING,
343
- summary: historyEntry.display || 'Claude process running',
287
+ status: AgentStatus.IDLE,
288
+ summary: 'Unknown',
344
289
  pid: processInfo.pid,
345
- projectPath,
346
- sessionId: historySession.sessionId,
347
- lastActive: historySession.lastActive || new Date(),
290
+ projectPath: processInfo.cwd || '',
291
+ sessionId: `pid-${processInfo.pid}`,
292
+ lastActive: new Date(),
348
293
  };
349
294
  }
350
295
 
351
- private indexHistoryByProjectPath(historyEntries: HistoryEntry[]): Map<string, HistoryEntry[]> {
352
- const grouped = new Map<string, HistoryEntry[]>();
353
-
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);
359
- }
360
-
361
- for (const [key, list] of grouped.entries()) {
362
- grouped.set(
363
- key,
364
- [...list].sort((a, b) => b.timestamp - a.timestamp),
365
- );
366
- }
367
-
368
- return grouped;
369
- }
370
-
371
- private selectHistoryForProcess(
372
- processCwd: string,
373
- historyByProjectPath: Map<string, HistoryEntry[]>,
374
- usedSessionIds: Set<string>,
375
- ): HistoryEntry | undefined {
376
- if (!processCwd) {
377
- return undefined;
378
- }
379
-
380
- const candidates = historyByProjectPath.get(this.normalizePath(processCwd)) || [];
381
- return candidates.find((entry) => !usedSessionIds.has(entry.sessionId));
382
- }
383
-
384
296
  /**
385
- * Read all Claude Code sessions
297
+ * Parse a single session file into ClaudeSession
386
298
  */
387
- private readSessions(): ClaudeSession[] {
388
- if (!fs.existsSync(this.projectsDir)) {
389
- return [];
390
- }
391
-
392
- const sessions: ClaudeSession[] = [];
393
- const projectDirs = fs.readdirSync(this.projectsDir);
394
-
395
- for (const dirName of projectDirs) {
396
- if (dirName.startsWith('.')) {
397
- continue;
398
- }
399
-
400
- const projectDir = path.join(this.projectsDir, dirName);
401
- if (!fs.statSync(projectDir).isDirectory()) {
402
- continue;
403
- }
299
+ private readSession(
300
+ filePath: string,
301
+ projectPath: string,
302
+ ): ClaudeSession | null {
303
+ const sessionId = path.basename(filePath, '.jsonl');
404
304
 
405
- // Read sessions-index.json to get original project path
406
- const indexPath = path.join(projectDir, 'sessions-index.json');
407
- if (!fs.existsSync(indexPath)) {
408
- continue;
409
- }
305
+ let content: string;
306
+ try {
307
+ content = fs.readFileSync(filePath, 'utf-8');
308
+ } catch {
309
+ return null;
310
+ }
410
311
 
411
- const sessionsIndex = readJson<SessionsIndex>(indexPath);
412
- if (!sessionsIndex) {
413
- console.error(`Failed to parse ${indexPath}`);
414
- continue;
415
- }
312
+ const allLines = content.trim().split('\n');
313
+ if (allLines.length === 0) {
314
+ return null;
315
+ }
416
316
 
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);
422
-
423
- 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,
434
- });
435
- } catch (error) {
436
- console.error(`Failed to read session ${sessionId}:`, error);
437
- continue;
317
+ // Parse first line for sessionStart.
318
+ // Claude Code may emit a "file-history-snapshot" as the first entry, which
319
+ // stores its timestamp inside "snapshot.timestamp" rather than at the root.
320
+ let sessionStart: Date | null = null;
321
+ try {
322
+ const firstEntry = JSON.parse(allLines[0]);
323
+ const rawTs: string | undefined =
324
+ firstEntry.timestamp || firstEntry.snapshot?.timestamp;
325
+ if (rawTs) {
326
+ const ts = new Date(rawTs);
327
+ if (!Number.isNaN(ts.getTime())) {
328
+ sessionStart = ts;
438
329
  }
439
330
  }
331
+ } catch {
332
+ /* skip */
440
333
  }
441
334
 
442
- return sessions;
443
- }
444
-
445
- /**
446
- * Read a session JSONL file
447
- * Only reads last 100 lines for performance with large files
448
- */
449
- private readSessionLog(logPath: string): {
450
- slug?: string;
451
- lastEntry?: SessionEntry;
452
- lastActive?: Date;
453
- lastCwd?: string;
454
- } {
455
- const lines = readLastLines(logPath, 100);
456
-
335
+ // Parse all lines for session state (file already in memory)
457
336
  let slug: string | undefined;
458
- let lastEntry: SessionEntry | undefined;
337
+ let lastEntryType: string | undefined;
459
338
  let lastActive: Date | undefined;
460
339
  let lastCwd: string | undefined;
340
+ let isInterrupted = false;
341
+ let lastUserMessage: string | undefined;
461
342
 
462
- for (const line of lines) {
343
+ for (const line of allLines) {
463
344
  try {
464
345
  const entry: SessionEntry = JSON.parse(line);
465
346
 
466
- if (entry.slug && !slug) {
467
- slug = entry.slug;
347
+ if (entry.timestamp) {
348
+ const ts = new Date(entry.timestamp);
349
+ if (!Number.isNaN(ts.getTime())) {
350
+ lastActive = ts;
351
+ }
468
352
  }
469
353
 
470
- lastEntry = entry;
471
-
472
- if (entry.timestamp) {
473
- lastActive = new Date(entry.timestamp);
354
+ if (entry.slug && !slug) {
355
+ slug = entry.slug;
474
356
  }
475
357
 
476
358
  if (typeof entry.cwd === 'string' && entry.cwd.trim().length > 0) {
477
359
  lastCwd = entry.cwd;
478
360
  }
479
- } catch (error) {
361
+
362
+ if (entry.type && !this.isMetadataEntryType(entry.type)) {
363
+ lastEntryType = entry.type;
364
+
365
+ if (entry.type === 'user') {
366
+ const msgContent = entry.message?.content;
367
+ isInterrupted =
368
+ Array.isArray(msgContent) &&
369
+ msgContent.some(
370
+ (c) =>
371
+ (c.type === 'text' &&
372
+ c.text?.includes('[Request interrupted')) ||
373
+ (c.type === 'tool_result' &&
374
+ c.content?.includes('[Request interrupted')),
375
+ );
376
+
377
+ // Extract user message text for summary fallback
378
+ const text = this.extractUserMessageText(msgContent);
379
+ if (text) {
380
+ lastUserMessage = text;
381
+ }
382
+ } else {
383
+ isInterrupted = false;
384
+ }
385
+ }
386
+ } catch {
480
387
  continue;
481
388
  }
482
389
  }
483
390
 
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);
391
+ return {
392
+ sessionId,
393
+ projectPath: projectPath || lastCwd || '',
394
+ lastCwd,
395
+ slug,
396
+ sessionStart: sessionStart || lastActive || new Date(),
397
+ lastActive: lastActive || new Date(),
398
+ lastEntryType,
399
+ isInterrupted,
400
+ lastUserMessage,
401
+ };
493
402
  }
494
403
 
495
404
  /**
496
- * Determine agent status from session entry
405
+ * Determine agent status from session state
497
406
  */
498
407
  private determineStatus(session: ClaudeSession): AgentStatus {
499
- if (!session.lastEntry) {
408
+ if (!session.lastEntryType) {
500
409
  return AgentStatus.UNKNOWN;
501
410
  }
502
411
 
503
- const entryType = session.lastEntry.type;
504
- const lastActive = session.lastActive || new Date(0);
505
- const ageMinutes = (Date.now() - lastActive.getTime()) / 1000 / 60;
412
+ // No age-based IDLE override: every agent in the list is backed by
413
+ // a running process (found via ps), so the entry type is the best
414
+ // indicator of actual state.
506
415
 
507
- if (ageMinutes > ClaudeCodeAdapter.IDLE_THRESHOLD_MINUTES) {
508
- return AgentStatus.IDLE;
416
+ if (session.lastEntryType === 'user') {
417
+ return session.isInterrupted
418
+ ? AgentStatus.WAITING
419
+ : AgentStatus.RUNNING;
509
420
  }
510
421
 
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
- }
422
+ if (
423
+ session.lastEntryType === 'progress' ||
424
+ session.lastEntryType === 'thinking'
425
+ ) {
521
426
  return AgentStatus.RUNNING;
522
427
  }
523
428
 
524
- if (entryType === SessionEntryType.PROGRESS || entryType === SessionEntryType.THINKING) {
525
- return AgentStatus.RUNNING;
526
- } else if (entryType === SessionEntryType.ASSISTANT) {
429
+ if (session.lastEntryType === 'assistant') {
527
430
  return AgentStatus.WAITING;
528
- } else if (entryType === SessionEntryType.SYSTEM) {
431
+ }
432
+
433
+ if (session.lastEntryType === 'system') {
529
434
  return AgentStatus.IDLE;
530
435
  }
531
436
 
@@ -533,65 +438,83 @@ export class ClaudeCodeAdapter implements AgentAdapter {
533
438
  }
534
439
 
535
440
  /**
536
- * Generate unique agent name
537
- * Uses project basename, appends slug if multiple sessions for same project
441
+ * Extract meaningful text from a user message content.
442
+ * Handles string and array formats, skill command expansion, and noise filtering.
538
443
  */
539
- private generateAgentName(session: ClaudeSession, existingAgents: AgentInfo[]): string {
540
- const projectName = path.basename(session.projectPath) || 'claude';
444
+ private extractUserMessageText(
445
+ content: string | Array<{ type?: string; text?: string }> | undefined,
446
+ ): string | undefined {
447
+ if (!content) {
448
+ return undefined;
449
+ }
541
450
 
542
- const sameProjectAgents = existingAgents.filter(
543
- a => a.projectPath === session.projectPath
544
- );
451
+ let raw: string | undefined;
545
452
 
546
- if (sameProjectAgents.length === 0) {
547
- return projectName;
453
+ if (typeof content === 'string') {
454
+ raw = content.trim();
455
+ } else if (Array.isArray(content)) {
456
+ for (const block of content) {
457
+ if (block.type === 'text' && block.text?.trim()) {
458
+ raw = block.text.trim();
459
+ break;
460
+ }
461
+ }
548
462
  }
549
463
 
550
- // Multiple sessions for same project, append slug
551
- if (session.slug) {
552
- // Use first word of slug for brevity (with safety check for format)
553
- const slugPart = session.slug.includes('-')
554
- ? session.slug.split('-')[0]
555
- : session.slug.slice(0, 8);
556
- return `${projectName} (${slugPart})`;
464
+ if (!raw) {
465
+ return undefined;
557
466
  }
558
467
 
559
- // No slug available, use session ID prefix
560
- return `${projectName} (${session.sessionId.slice(0, 8)})`;
561
- }
562
-
563
- private pathEquals(a?: string, b?: string): boolean {
564
- if (!a || !b) {
565
- return false;
468
+ // Skill slash-command: extract /command-name and args
469
+ if (raw.startsWith('<command-message>')) {
470
+ return this.parseCommandMessage(raw);
566
471
  }
567
472
 
568
- return this.normalizePath(a) === this.normalizePath(b);
569
- }
473
+ // Expanded skill content: extract ARGUMENTS line if present, skip otherwise
474
+ if (raw.startsWith('Base directory for this skill:')) {
475
+ const argsMatch = raw.match(/\nARGUMENTS:\s*(.+)/);
476
+ return argsMatch?.[1]?.trim() || undefined;
477
+ }
570
478
 
571
- private isChildPath(child?: string, parent?: string): boolean {
572
- if (!child || !parent) {
573
- return false;
479
+ // Filter noise
480
+ if (this.isNoiseMessage(raw)) {
481
+ return undefined;
574
482
  }
575
483
 
576
- const normalizedChild = this.normalizePath(child);
577
- const normalizedParent = this.normalizePath(parent);
578
- return normalizedChild === normalizedParent || normalizedChild.startsWith(`${normalizedParent}${path.sep}`);
484
+ return raw;
579
485
  }
580
486
 
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);
487
+ /**
488
+ * Parse a <command-message> string into "/command args" format.
489
+ */
490
+ private parseCommandMessage(raw: string): string | undefined {
491
+ const nameMatch = raw.match(/<command-name>([^<]+)<\/command-name>/);
492
+ const argsMatch = raw.match(/<command-args>([^<]+)<\/command-args>/);
493
+ const name = nameMatch?.[1]?.trim();
494
+ if (!name) {
495
+ return undefined;
585
496
  }
586
- return resolved;
497
+ const args = argsMatch?.[1]?.trim();
498
+ return args ? `${name} ${args}` : name;
587
499
  }
588
500
 
589
- private pathDepth(value?: string): number {
590
- if (!value) {
591
- return 0;
592
- }
501
+ /**
502
+ * Check if a message is noise (not a meaningful user intent).
503
+ */
504
+ private isNoiseMessage(text: string): boolean {
505
+ return (
506
+ text.startsWith('[Request interrupted') ||
507
+ text === 'Tool loaded.' ||
508
+ text.startsWith('This session is being continued')
509
+ );
510
+ }
593
511
 
594
- return this.normalizePath(value).split(path.sep).filter(Boolean).length;
512
+ /**
513
+ * Check if an entry type is metadata (not conversation state).
514
+ * These should not overwrite lastEntryType used for status determination.
515
+ */
516
+ private isMetadataEntryType(type: string): boolean {
517
+ return type === 'last-prompt' || type === 'file-history-snapshot';
595
518
  }
596
519
 
597
520
  }