@ai-devkit/agent-manager 0.1.0 → 0.2.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.
@@ -19,11 +19,21 @@ interface SessionsIndex {
19
19
  originalPath: string;
20
20
  }
21
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
+
22
32
  /**
23
33
  * Entry in session JSONL file
24
34
  */
25
35
  interface SessionEntry {
26
- type?: 'assistant' | 'user' | 'progress' | 'thinking' | 'system' | 'message' | 'text';
36
+ type?: SessionEntryType;
27
37
  timestamp?: string;
28
38
  slug?: string;
29
39
  cwd?: string;
@@ -54,12 +64,15 @@ interface HistoryEntry {
54
64
  interface ClaudeSession {
55
65
  sessionId: string;
56
66
  projectPath: string;
67
+ lastCwd?: string;
57
68
  slug?: string;
58
69
  sessionLogPath: string;
59
70
  lastEntry?: SessionEntry;
60
71
  lastActive?: Date;
61
72
  }
62
73
 
74
+ type SessionMatchMode = 'cwd' | 'project-parent';
75
+
63
76
  /**
64
77
  * Claude Code Adapter
65
78
  *
@@ -98,75 +111,274 @@ export class ClaudeCodeAdapter implements AgentAdapter {
98
111
  * Detect running Claude Code agents
99
112
  */
100
113
  async detectAgents(): Promise<AgentInfo[]> {
101
- // 1. Find running claude processes
102
- const claudeProcesses = listProcesses({ namePattern: 'claude' });
114
+ const claudeProcesses = listProcesses({ namePattern: 'claude' }).filter((processInfo) =>
115
+ this.canHandle(processInfo),
116
+ );
103
117
 
104
118
  if (claudeProcesses.length === 0) {
105
119
  return [];
106
120
  }
107
121
 
108
- // 2. Read all sessions
109
122
  const sessions = this.readSessions();
110
-
111
- // 3. Read history for summaries
112
123
  const history = this.readHistory();
113
-
114
- // 4. Group processes by CWD
115
- const processesByCwd = new Map<string, ProcessInfo[]>();
116
- for (const p of claudeProcesses) {
117
- const list = processesByCwd.get(p.cwd) || [];
118
- list.push(p);
119
- processesByCwd.set(p.cwd, list);
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);
120
128
  }
121
129
 
122
- // 5. Match sessions to processes
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
+ });
135
+
136
+ const usedSessionIds = new Set<string>();
137
+ const assignedPids = new Set<number>();
123
138
  const agents: AgentInfo[] = [];
124
139
 
125
- for (const [cwd, processes] of processesByCwd) {
126
- // Find sessions for this project path
127
- const projectSessions = sessions.filter(s => s.projectPath === cwd);
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;
168
+ }
169
+
170
+ assignedPids.add(processInfo.pid);
171
+ agents.push(this.mapProcessOnlyAgent(processInfo, agents, historyByProjectPath, usedSessionIds));
172
+ }
173
+
174
+ return agents;
175
+ }
128
176
 
129
- if (projectSessions.length === 0) {
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)) {
130
186
  continue;
131
187
  }
132
188
 
133
- // Sort sessions by last active time (newest first)
134
- projectSessions.sort((a, b) => {
135
- const timeA = a.lastActive?.getTime() || 0;
136
- const timeB = b.lastActive?.getTime() || 0;
137
- return timeB - timeA;
138
- });
189
+ const historyEntry = this.selectHistoryForProcess(processInfo.cwd || '', historyByProjectPath, usedSessionIds);
190
+ if (!historyEntry) {
191
+ continue;
192
+ }
139
193
 
140
- // Map processes to the most recent sessions
141
- // If there are 2 processes, we take the 2 most recent sessions
142
- const activeSessions = projectSessions.slice(0, processes.length);
194
+ assignedPids.add(processInfo.pid);
195
+ usedSessionIds.add(historyEntry.sessionId);
196
+ agents.push(this.mapHistoryToAgent(processInfo, historyEntry, agents));
197
+ }
198
+ }
143
199
 
144
- for (let i = 0; i < activeSessions.length; i++) {
145
- const session = activeSessions[i];
146
- const process = processes[i]; // Assign process to session (arbitrary 1-to-1 mapping)
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
+ }
147
213
 
148
- const historyEntry = [...history].reverse().find(
149
- h => h.sessionId === session.sessionId
150
- );
151
- const summary = historyEntry?.display || 'Session started';
152
- const status = this.determineStatus(session);
153
- const agentName = this.generateAgentName(session, agents); // Pass currently built agents for collision checks
154
-
155
- agents.push({
156
- name: agentName,
157
- type: this.type,
158
- status,
159
- summary,
160
- pid: process.pid,
161
- projectPath: session.projectPath,
162
- sessionId: session.sessionId,
163
- slug: session.slug,
164
- lastActive: session.lastActive || new Date(),
165
- });
214
+ const session = this.selectBestSession(processInfo, sessions, usedSessionIds, mode);
215
+ if (!session) {
216
+ continue;
166
217
  }
218
+
219
+ usedSessionIds.add(session.sessionId);
220
+ assignedPids.add(processInfo.pid);
221
+ agents.push(this.mapSessionToAgent(session, processInfo, historyBySessionId, agents));
167
222
  }
223
+ }
168
224
 
169
- return agents;
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
+ private mapSessionToAgent(
271
+ session: ClaudeSession,
272
+ processInfo: ProcessInfo,
273
+ historyBySessionId: Map<string, HistoryEntry>,
274
+ existingAgents: AgentInfo[],
275
+ ): AgentInfo {
276
+ const historyEntry = historyBySessionId.get(session.sessionId);
277
+
278
+ return {
279
+ name: this.generateAgentName(session, existingAgents),
280
+ type: this.type,
281
+ status: this.determineStatus(session),
282
+ summary: historyEntry?.display || 'Session started',
283
+ pid: processInfo.pid,
284
+ projectPath: session.projectPath || processInfo.cwd || '',
285
+ sessionId: session.sessionId,
286
+ 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(),
322
+ };
323
+ }
324
+
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
+
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
+ }
350
+
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));
170
382
  }
171
383
 
172
384
  /**
@@ -214,6 +426,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
214
426
  sessions.push({
215
427
  sessionId,
216
428
  projectPath: sessionsIndex.originalPath,
429
+ lastCwd: sessionData.lastCwd,
217
430
  slug: sessionData.slug,
218
431
  sessionLogPath,
219
432
  lastEntry: sessionData.lastEntry,
@@ -237,12 +450,14 @@ export class ClaudeCodeAdapter implements AgentAdapter {
237
450
  slug?: string;
238
451
  lastEntry?: SessionEntry;
239
452
  lastActive?: Date;
453
+ lastCwd?: string;
240
454
  } {
241
455
  const lines = readLastLines(logPath, 100);
242
456
 
243
457
  let slug: string | undefined;
244
458
  let lastEntry: SessionEntry | undefined;
245
459
  let lastActive: Date | undefined;
460
+ let lastCwd: string | undefined;
246
461
 
247
462
  for (const line of lines) {
248
463
  try {
@@ -257,12 +472,16 @@ export class ClaudeCodeAdapter implements AgentAdapter {
257
472
  if (entry.timestamp) {
258
473
  lastActive = new Date(entry.timestamp);
259
474
  }
475
+
476
+ if (typeof entry.cwd === 'string' && entry.cwd.trim().length > 0) {
477
+ lastCwd = entry.cwd;
478
+ }
260
479
  } catch (error) {
261
480
  continue;
262
481
  }
263
482
  }
264
483
 
265
- return { slug, lastEntry, lastActive };
484
+ return { slug, lastEntry, lastActive, lastCwd };
266
485
  }
267
486
 
268
487
  /**
@@ -289,12 +508,12 @@ export class ClaudeCodeAdapter implements AgentAdapter {
289
508
  return AgentStatus.IDLE;
290
509
  }
291
510
 
292
- if (entryType === 'user') {
511
+ if (entryType === SessionEntryType.USER) {
293
512
  // Check if user interrupted manually - this puts agent back in waiting state
294
513
  const content = session.lastEntry.message?.content;
295
514
  if (Array.isArray(content)) {
296
515
  const isInterrupted = content.some(c =>
297
- (c.type === 'text' && c.text?.includes('[Request interrupted')) ||
516
+ (c.type === SessionEntryType.TEXT && c.text?.includes('[Request interrupted')) ||
298
517
  (c.type === 'tool_result' && c.content?.includes('[Request interrupted'))
299
518
  );
300
519
  if (isInterrupted) return AgentStatus.WAITING;
@@ -302,11 +521,11 @@ export class ClaudeCodeAdapter implements AgentAdapter {
302
521
  return AgentStatus.RUNNING;
303
522
  }
304
523
 
305
- if (entryType === 'progress' || entryType === 'thinking') {
524
+ if (entryType === SessionEntryType.PROGRESS || entryType === SessionEntryType.THINKING) {
306
525
  return AgentStatus.RUNNING;
307
- } else if (entryType === 'assistant') {
526
+ } else if (entryType === SessionEntryType.ASSISTANT) {
308
527
  return AgentStatus.WAITING;
309
- } else if (entryType === 'system') {
528
+ } else if (entryType === SessionEntryType.SYSTEM) {
310
529
  return AgentStatus.IDLE;
311
530
  }
312
531
 
@@ -318,7 +537,7 @@ export class ClaudeCodeAdapter implements AgentAdapter {
318
537
  * Uses project basename, appends slug if multiple sessions for same project
319
538
  */
320
539
  private generateAgentName(session: ClaudeSession, existingAgents: AgentInfo[]): string {
321
- const projectName = path.basename(session.projectPath);
540
+ const projectName = path.basename(session.projectPath) || 'claude';
322
541
 
323
542
  const sameProjectAgents = existingAgents.filter(
324
543
  a => a.projectPath === session.projectPath
@@ -341,4 +560,38 @@ export class ClaudeCodeAdapter implements AgentAdapter {
341
560
  return `${projectName} (${session.sessionId.slice(0, 8)})`;
342
561
  }
343
562
 
563
+ private pathEquals(a?: string, b?: string): boolean {
564
+ if (!a || !b) {
565
+ return false;
566
+ }
567
+
568
+ return this.normalizePath(a) === this.normalizePath(b);
569
+ }
570
+
571
+ private isChildPath(child?: string, parent?: string): boolean {
572
+ if (!child || !parent) {
573
+ return false;
574
+ }
575
+
576
+ const normalizedChild = this.normalizePath(child);
577
+ const normalizedParent = this.normalizePath(parent);
578
+ return normalizedChild === normalizedParent || normalizedChild.startsWith(`${normalizedParent}${path.sep}`);
579
+ }
580
+
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);
585
+ }
586
+ return resolved;
587
+ }
588
+
589
+ private pathDepth(value?: string): number {
590
+ if (!value) {
591
+ return 0;
592
+ }
593
+
594
+ return this.normalizePath(value).split(path.sep).filter(Boolean).length;
595
+ }
596
+
344
597
  }