@ai-devkit/agent-manager 0.4.0 → 0.6.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 +21 -2
  2. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  3. package/dist/adapters/ClaudeCodeAdapter.d.ts +44 -35
  4. package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
  5. package/dist/adapters/ClaudeCodeAdapter.js +230 -298
  6. package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
  7. package/dist/adapters/CodexAdapter.d.ts +41 -31
  8. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  9. package/dist/adapters/CodexAdapter.js +198 -278
  10. package/dist/adapters/CodexAdapter.js.map +1 -1
  11. package/dist/index.d.ts +2 -4
  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 +107 -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 +5 -27
  33. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +754 -830
  34. package/src/__tests__/adapters/CodexAdapter.test.ts +581 -273
  35. package/src/__tests__/utils/matching.test.ts +199 -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 +23 -4
  39. package/src/adapters/ClaudeCodeAdapter.ts +285 -437
  40. package/src/adapters/CodexAdapter.ts +202 -400
  41. package/src/index.ts +2 -4
  42. package/src/utils/index.ts +6 -3
  43. package/src/utils/matching.ts +96 -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,29 +1,23 @@
1
1
  /**
2
2
  * Codex Adapter
3
3
  *
4
- * Detects running Codex agents by combining:
5
- * 1. Running `codex` processes
6
- * 2. Session metadata under ~/.codex/sessions
4
+ * Detects running Codex agents by:
5
+ * 1. Finding running codex processes via shared listAgentProcesses()
6
+ * 2. Enriching with CWD and start times via shared enrichProcesses()
7
+ * 3. Discovering session files from ~/.codex/sessions/YYYY/MM/DD/ via shared batchGetSessionFileBirthtimes()
8
+ * 4. Setting resolvedCwd from session_meta first line
9
+ * 5. Matching sessions to processes via shared matchProcessesToSessions()
10
+ * 6. Extracting summary from last event entry in session JSONL
7
11
  */
8
12
 
9
13
  import * as fs from 'fs';
10
14
  import * as path from 'path';
11
- import { execSync } from 'child_process';
12
- import type { AgentAdapter, AgentInfo, ProcessInfo } from './AgentAdapter';
15
+ import type { AgentAdapter, AgentInfo, ProcessInfo, ConversationMessage } from './AgentAdapter';
13
16
  import { AgentStatus } from './AgentAdapter';
14
- import { listProcesses } from '../utils/process';
15
- import { readJsonLines } from '../utils/file';
16
-
17
- interface CodexSessionMetaPayload {
18
- id?: string;
19
- timestamp?: string;
20
- cwd?: string;
21
- }
22
-
23
- interface CodexSessionMetaEntry {
24
- type?: string;
25
- payload?: CodexSessionMetaPayload;
26
- }
17
+ import { listAgentProcesses, enrichProcesses } from '../utils/process';
18
+ import { batchGetSessionFileBirthtimes } from '../utils/session';
19
+ import type { SessionFile } from '../utils/session';
20
+ import { matchProcessesToSessions, generateAgentName } from '../utils/matching';
27
21
 
28
22
  interface CodexEventEntry {
29
23
  timestamp?: string;
@@ -31,6 +25,9 @@ interface CodexEventEntry {
31
25
  payload?: {
32
26
  type?: string;
33
27
  message?: string;
28
+ id?: string;
29
+ cwd?: string;
30
+ timestamp?: string;
34
31
  };
35
32
  }
36
33
 
@@ -43,21 +40,12 @@ interface CodexSession {
43
40
  lastPayloadType?: string;
44
41
  }
45
42
 
46
- type SessionMatchMode = 'cwd' | 'missing-cwd';
47
-
48
43
  export class CodexAdapter implements AgentAdapter {
49
44
  readonly type = 'codex' as const;
50
45
 
51
- /** Keep status thresholds aligned across adapters. */
52
46
  private static readonly IDLE_THRESHOLD_MINUTES = 5;
53
- /** Limit session parsing per run to keep list latency bounded. */
54
- private static readonly MIN_SESSION_SCAN = 12;
55
- private static readonly MAX_SESSION_SCAN = 40;
56
- private static readonly SESSION_SCAN_MULTIPLIER = 4;
57
- /** Also include session files around process start day to recover long-lived processes. */
47
+ /** Include session files around process start day to recover long-lived processes. */
58
48
  private static readonly PROCESS_START_DAY_WINDOW_DAYS = 1;
59
- /** Matching tolerance between process start time and session start time. */
60
- private static readonly PROCESS_SESSION_TIME_TOLERANCE_MS = 2 * 60 * 1000;
61
49
 
62
50
  private codexSessionsDir: string;
63
51
 
@@ -70,264 +58,113 @@ export class CodexAdapter implements AgentAdapter {
70
58
  return this.isCodexExecutable(processInfo.command);
71
59
  }
72
60
 
61
+ /**
62
+ * Detect running Codex agents
63
+ */
73
64
  async detectAgents(): Promise<AgentInfo[]> {
74
- const codexProcesses = this.listCodexProcesses();
75
-
76
- if (codexProcesses.length === 0) {
77
- return [];
78
- }
79
-
80
- const processStartByPid = this.getProcessStartTimes(codexProcesses.map((processInfo) => processInfo.pid));
65
+ const processes = enrichProcesses(listAgentProcesses('codex'));
66
+ if (processes.length === 0) return [];
81
67
 
82
- const sessionScanLimit = this.calculateSessionScanLimit(codexProcesses.length);
83
- const sessions = this.readSessions(sessionScanLimit, processStartByPid);
68
+ const { sessions, contentCache } = this.discoverSessions(processes);
84
69
  if (sessions.length === 0) {
85
- return codexProcesses.map((processInfo) =>
86
- this.mapProcessOnlyAgent(processInfo, [], 'No Codex session metadata found'),
87
- );
70
+ return processes.map((p) => this.mapProcessOnlyAgent(p));
88
71
  }
89
72
 
90
- const sortedSessions = [...sessions].sort(
91
- (a, b) => b.lastActive.getTime() - a.lastActive.getTime(),
92
- );
93
- const usedSessionIds = new Set<string>();
94
- const assignedPids = new Set<number>();
73
+ const matches = matchProcessesToSessions(processes, sessions);
74
+ const matchedPids = new Set(matches.map((m) => m.process.pid));
95
75
  const agents: AgentInfo[] = [];
96
76
 
97
- // Match exact cwd first, then missing-cwd sessions.
98
- this.assignSessionsForMode(
99
- 'cwd',
100
- codexProcesses,
101
- sortedSessions,
102
- usedSessionIds,
103
- assignedPids,
104
- processStartByPid,
105
- agents,
106
- );
107
- this.assignSessionsForMode(
108
- 'missing-cwd',
109
- codexProcesses,
110
- sortedSessions,
111
- usedSessionIds,
112
- assignedPids,
113
- processStartByPid,
114
- agents,
115
- );
116
-
117
- // Every running codex process should still be listed.
118
- for (const processInfo of codexProcesses) {
119
- if (assignedPids.has(processInfo.pid)) {
120
- continue;
77
+ for (const match of matches) {
78
+ const cachedContent = contentCache.get(match.session.filePath);
79
+ const sessionData = this.parseSession(cachedContent, match.session.filePath);
80
+ if (sessionData) {
81
+ agents.push(this.mapSessionToAgent(sessionData, match.process, match.session.filePath));
82
+ } else {
83
+ matchedPids.delete(match.process.pid);
121
84
  }
122
-
123
- this.addProcessOnlyAgent(processInfo, assignedPids, agents);
124
85
  }
125
86
 
126
- return agents;
127
- }
128
-
129
- private listCodexProcesses(): ProcessInfo[] {
130
- return listProcesses({ namePattern: 'codex' }).filter((processInfo) =>
131
- this.canHandle(processInfo),
132
- );
133
- }
134
-
135
- private calculateSessionScanLimit(processCount: number): number {
136
- return Math.min(
137
- Math.max(
138
- processCount * CodexAdapter.SESSION_SCAN_MULTIPLIER,
139
- CodexAdapter.MIN_SESSION_SCAN,
140
- ),
141
- CodexAdapter.MAX_SESSION_SCAN,
142
- );
143
- }
144
-
145
- private assignSessionsForMode(
146
- mode: SessionMatchMode,
147
- codexProcesses: ProcessInfo[],
148
- sessions: CodexSession[],
149
- usedSessionIds: Set<string>,
150
- assignedPids: Set<number>,
151
- processStartByPid: Map<number, Date>,
152
- agents: AgentInfo[],
153
- ): void {
154
- for (const processInfo of codexProcesses) {
155
- if (assignedPids.has(processInfo.pid)) {
156
- continue;
157
- }
158
-
159
- const session = this.selectBestSession(
160
- processInfo,
161
- sessions,
162
- usedSessionIds,
163
- processStartByPid,
164
- mode,
165
- );
166
- if (!session) {
167
- continue;
87
+ for (const proc of processes) {
88
+ if (!matchedPids.has(proc.pid)) {
89
+ agents.push(this.mapProcessOnlyAgent(proc));
168
90
  }
169
-
170
- this.addMappedSessionAgent(session, processInfo, usedSessionIds, assignedPids, agents);
171
91
  }
172
- }
173
-
174
- private addMappedSessionAgent(
175
- session: CodexSession,
176
- processInfo: ProcessInfo,
177
- usedSessionIds: Set<string>,
178
- assignedPids: Set<number>,
179
- agents: AgentInfo[],
180
- ): void {
181
- usedSessionIds.add(session.sessionId);
182
- assignedPids.add(processInfo.pid);
183
- agents.push(this.mapSessionToAgent(session, processInfo, agents));
184
- }
185
-
186
- private addProcessOnlyAgent(
187
- processInfo: ProcessInfo,
188
- assignedPids: Set<number>,
189
- agents: AgentInfo[],
190
- ): void {
191
- assignedPids.add(processInfo.pid);
192
- agents.push(this.mapProcessOnlyAgent(processInfo, agents));
193
- }
194
-
195
- private mapSessionToAgent(
196
- session: CodexSession,
197
- processInfo: ProcessInfo,
198
- existingAgents: AgentInfo[],
199
- ): AgentInfo {
200
- return {
201
- name: this.generateAgentName(session, existingAgents),
202
- type: this.type,
203
- status: this.determineStatus(session),
204
- summary: session.summary || 'Codex session active',
205
- pid: processInfo.pid,
206
- projectPath: session.projectPath || processInfo.cwd || '',
207
- sessionId: session.sessionId,
208
- lastActive: session.lastActive,
209
- };
210
- }
211
-
212
- private mapProcessOnlyAgent(
213
- processInfo: ProcessInfo,
214
- existingAgents: AgentInfo[],
215
- summary: string = 'Codex process running',
216
- ): AgentInfo {
217
- const syntheticSession: CodexSession = {
218
- sessionId: `pid-${processInfo.pid}`,
219
- projectPath: processInfo.cwd || '',
220
- summary,
221
- sessionStart: new Date(),
222
- lastActive: new Date(),
223
- lastPayloadType: 'process_only',
224
- };
225
92
 
226
- return {
227
- name: this.generateAgentName(syntheticSession, existingAgents),
228
- type: this.type,
229
- status: AgentStatus.RUNNING,
230
- summary,
231
- pid: processInfo.pid,
232
- projectPath: processInfo.cwd || '',
233
- sessionId: syntheticSession.sessionId,
234
- lastActive: syntheticSession.lastActive,
235
- };
93
+ return agents;
236
94
  }
237
95
 
238
- private readSessions(limit: number, processStartByPid: Map<number, Date>): CodexSession[] {
239
- const sessionFiles = this.findSessionFiles(limit, processStartByPid);
240
- const sessions: CodexSession[] = [];
241
-
242
- for (const sessionFile of sessionFiles) {
96
+ /**
97
+ * Discover session files for the given processes.
98
+ *
99
+ * Uses process start times to determine which YYYY/MM/DD date directories
100
+ * to scan (±1 day window), then batches stat calls across all directories.
101
+ * Reads each file once and caches content for later parsing by parseSession().
102
+ * Sets resolvedCwd from session_meta first line.
103
+ */
104
+ private discoverSessions(processes: ProcessInfo[]): {
105
+ sessions: SessionFile[];
106
+ contentCache: Map<string, string>;
107
+ } {
108
+ const empty = { sessions: [], contentCache: new Map<string, string>() };
109
+ if (!fs.existsSync(this.codexSessionsDir)) return empty;
110
+
111
+ const dateDirs = this.getDateDirs(processes);
112
+ if (dateDirs.length === 0) return empty;
113
+
114
+ const files = batchGetSessionFileBirthtimes(dateDirs);
115
+ const contentCache = new Map<string, string>();
116
+
117
+ // Read each file once: extract CWD for matching, cache content for later parsing
118
+ for (const file of files) {
243
119
  try {
244
- const session = this.readSession(sessionFile);
245
- if (session) {
246
- sessions.push(session);
247
- }
248
- } catch (error) {
249
- console.error(`Failed to parse Codex session ${sessionFile}:`, error);
250
- }
251
- }
252
-
253
- return sessions;
254
- }
255
-
256
- private findSessionFiles(limit: number, processStartByPid: Map<number, Date>): string[] {
257
- if (!fs.existsSync(this.codexSessionsDir)) {
258
- return [];
259
- }
260
-
261
- const files: Array<{ path: string; mtimeMs: number }> = [];
262
- const stack: string[] = [this.codexSessionsDir];
263
-
264
- while (stack.length > 0) {
265
- const currentDir = stack.pop();
266
- if (!currentDir || !fs.existsSync(currentDir)) {
267
- continue;
268
- }
269
-
270
- for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
271
- const fullPath = path.join(currentDir, entry.name);
272
- if (entry.isDirectory()) {
273
- stack.push(fullPath);
274
- continue;
275
- }
276
-
277
- if (entry.isFile() && entry.name.endsWith('.jsonl')) {
278
- try {
279
- files.push({
280
- path: fullPath,
281
- mtimeMs: fs.statSync(fullPath).mtimeMs,
282
- });
283
- } catch {
284
- continue;
120
+ const content = fs.readFileSync(file.filePath, 'utf-8');
121
+ contentCache.set(file.filePath, content);
122
+
123
+ const firstLine = content.split('\n')[0]?.trim();
124
+ if (firstLine) {
125
+ const parsed = JSON.parse(firstLine);
126
+ if (parsed.type === 'session_meta') {
127
+ file.resolvedCwd = parsed.payload?.cwd || '';
285
128
  }
286
129
  }
130
+ } catch {
131
+ // Skip unreadable files
287
132
  }
288
133
  }
289
134
 
290
- const recentFiles = files
291
- .sort((a, b) => b.mtimeMs - a.mtimeMs)
292
- .slice(0, limit)
293
- .map((file) => file.path);
294
- const processDayFiles = this.findProcessDaySessionFiles(processStartByPid);
295
-
296
- const selectedPaths = new Set(recentFiles);
297
- for (const processDayFile of processDayFiles) {
298
- selectedPaths.add(processDayFile);
299
- }
300
-
301
- return Array.from(selectedPaths);
135
+ return { sessions: files, contentCache };
302
136
  }
303
137
 
304
- private findProcessDaySessionFiles(processStartByPid: Map<number, Date>): string[] {
305
- const files: string[] = [];
138
+ /**
139
+ * Determine which date directories to scan based on process start times.
140
+ * Returns only directories that actually exist.
141
+ */
142
+ private getDateDirs(processes: ProcessInfo[]): string[] {
306
143
  const dayKeys = new Set<string>();
307
- const dayWindow = CodexAdapter.PROCESS_START_DAY_WINDOW_DAYS;
144
+ const window = CodexAdapter.PROCESS_START_DAY_WINDOW_DAYS;
308
145
 
309
- for (const processStart of processStartByPid.values()) {
310
- for (let offset = -dayWindow; offset <= dayWindow; offset++) {
311
- const day = new Date(processStart.getTime());
146
+ for (const proc of processes) {
147
+ const startTime = proc.startTime || new Date();
148
+ for (let offset = -window; offset <= window; offset++) {
149
+ const day = new Date(startTime.getTime());
312
150
  day.setDate(day.getDate() + offset);
313
151
  dayKeys.add(this.toSessionDayKey(day));
314
152
  }
315
153
  }
316
154
 
155
+ const dirs: string[] = [];
317
156
  for (const dayKey of dayKeys) {
318
157
  const dayDir = path.join(this.codexSessionsDir, dayKey);
319
- if (!fs.existsSync(dayDir)) {
320
- continue;
321
- }
322
-
323
- for (const entry of fs.readdirSync(dayDir, { withFileTypes: true })) {
324
- if (entry.isFile() && entry.name.endsWith('.jsonl')) {
325
- files.push(path.join(dayDir, entry.name));
158
+ try {
159
+ if (fs.statSync(dayDir).isDirectory()) {
160
+ dirs.push(dayDir);
326
161
  }
162
+ } catch {
163
+ continue;
327
164
  }
328
165
  }
329
166
 
330
- return files;
167
+ return dirs;
331
168
  }
332
169
 
333
170
  private toSessionDayKey(date: Date): string {
@@ -337,18 +174,45 @@ export class CodexAdapter implements AgentAdapter {
337
174
  return path.join(yyyy, mm, dd);
338
175
  }
339
176
 
340
- private readSession(filePath: string): CodexSession | null {
341
- const firstLine = this.readFirstLine(filePath);
342
- if (!firstLine) {
177
+ /**
178
+ * Parse session file content into CodexSession.
179
+ * Uses cached content if available, otherwise reads from disk.
180
+ */
181
+ private parseSession(cachedContent: string | undefined, filePath: string): CodexSession | null {
182
+ let content: string;
183
+ if (cachedContent !== undefined) {
184
+ content = cachedContent;
185
+ } else {
186
+ try {
187
+ content = fs.readFileSync(filePath, 'utf-8');
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
193
+ const allLines = content.trim().split('\n');
194
+ if (!allLines[0]) return null;
195
+
196
+ let metaEntry: CodexEventEntry;
197
+ try {
198
+ metaEntry = JSON.parse(allLines[0]);
199
+ } catch {
343
200
  return null;
344
201
  }
345
202
 
346
- const metaEntry = this.parseSessionMeta(firstLine);
347
- if (!metaEntry?.payload?.id) {
203
+ if (metaEntry.type !== 'session_meta' || !metaEntry.payload?.id) {
348
204
  return null;
349
205
  }
350
206
 
351
- const entries = readJsonLines<CodexEventEntry>(filePath, 300);
207
+ const entries: CodexEventEntry[] = [];
208
+ for (const line of allLines) {
209
+ try {
210
+ entries.push(JSON.parse(line));
211
+ } catch {
212
+ continue;
213
+ }
214
+ }
215
+
352
216
  const lastEntry = this.findLastEventEntry(entries);
353
217
  const lastPayloadType = lastEntry?.payload?.type;
354
218
 
@@ -370,21 +234,31 @@ export class CodexAdapter implements AgentAdapter {
370
234
  };
371
235
  }
372
236
 
373
- private readFirstLine(filePath: string): string {
374
- const content = fs.readFileSync(filePath, 'utf-8');
375
- return content.split('\n')[0]?.trim() || '';
237
+ private mapSessionToAgent(session: CodexSession, processInfo: ProcessInfo, filePath: string): AgentInfo {
238
+ return {
239
+ name: generateAgentName(session.projectPath || processInfo.cwd || '', processInfo.pid),
240
+ type: this.type,
241
+ status: this.determineStatus(session),
242
+ summary: session.summary || 'Codex session active',
243
+ pid: processInfo.pid,
244
+ projectPath: session.projectPath || processInfo.cwd || '',
245
+ sessionId: session.sessionId,
246
+ lastActive: session.lastActive,
247
+ sessionFilePath: filePath,
248
+ };
376
249
  }
377
250
 
378
- private parseSessionMeta(line: string): CodexSessionMetaEntry | null {
379
- try {
380
- const parsed = JSON.parse(line) as CodexSessionMetaEntry;
381
- if (parsed.type !== 'session_meta') {
382
- return null;
383
- }
384
- return parsed;
385
- } catch {
386
- return null;
387
- }
251
+ private mapProcessOnlyAgent(processInfo: ProcessInfo): AgentInfo {
252
+ return {
253
+ name: generateAgentName(processInfo.cwd || '', processInfo.pid),
254
+ type: this.type,
255
+ status: AgentStatus.RUNNING,
256
+ summary: 'Codex process running',
257
+ pid: processInfo.pid,
258
+ projectPath: processInfo.cwd || '',
259
+ sessionId: `pid-${processInfo.pid}`,
260
+ lastActive: new Date(),
261
+ };
388
262
  }
389
263
 
390
264
  private findLastEventEntry(entries: CodexEventEntry[]): CodexEventEntry | undefined {
@@ -398,122 +272,28 @@ export class CodexAdapter implements AgentAdapter {
398
272
  }
399
273
 
400
274
  private parseTimestamp(value?: string): Date | null {
401
- if (!value) {
402
- return null;
403
- }
404
-
275
+ if (!value) return null;
405
276
  const timestamp = new Date(value);
406
277
  return Number.isNaN(timestamp.getTime()) ? null : timestamp;
407
278
  }
408
279
 
409
- private selectBestSession(
410
- processInfo: ProcessInfo,
411
- sessions: CodexSession[],
412
- usedSessionIds: Set<string>,
413
- processStartByPid: Map<number, Date>,
414
- mode: SessionMatchMode,
415
- ): CodexSession | undefined {
416
- const candidates = this.filterCandidateSessions(processInfo, sessions, usedSessionIds, mode);
417
-
418
- if (candidates.length === 0) {
419
- return undefined;
420
- }
421
-
422
- const processStart = processStartByPid.get(processInfo.pid);
423
- if (!processStart) {
424
- return candidates.sort((a, b) => b.lastActive.getTime() - a.lastActive.getTime())[0];
425
- }
426
-
427
- return this.rankCandidatesByStartTime(candidates, processStart)[0];
428
- }
429
-
430
- private filterCandidateSessions(
431
- processInfo: ProcessInfo,
432
- sessions: CodexSession[],
433
- usedSessionIds: Set<string>,
434
- mode: SessionMatchMode,
435
- ): CodexSession[] {
436
- return sessions.filter((session) => {
437
- if (usedSessionIds.has(session.sessionId)) {
438
- return false;
439
- }
440
-
441
- if (mode === 'cwd') {
442
- return session.projectPath === processInfo.cwd;
443
- }
444
-
445
- if (mode === 'missing-cwd') {
446
- return !session.projectPath;
447
- }
448
- });
449
- }
450
-
451
- private rankCandidatesByStartTime(candidates: CodexSession[], processStart: Date): CodexSession[] {
452
- const toleranceMs = CodexAdapter.PROCESS_SESSION_TIME_TOLERANCE_MS;
453
-
454
- return candidates
455
- .map((session) => {
456
- const diffMs = Math.abs(session.sessionStart.getTime() - processStart.getTime());
457
- const outsideTolerance = diffMs > toleranceMs ? 1 : 0;
458
- return {
459
- session,
460
- rank: outsideTolerance,
461
- diffMs,
462
- recency: session.lastActive.getTime(),
463
- };
464
- })
465
- .sort((a, b) => {
466
- if (a.rank !== b.rank) return a.rank - b.rank;
467
- if (a.diffMs !== b.diffMs) return a.diffMs - b.diffMs;
468
- return b.recency - a.recency;
469
- })
470
- .map((ranked) => ranked.session);
471
- }
472
-
473
- private getProcessStartTimes(pids: number[]): Map<number, Date> {
474
- if (pids.length === 0 || process.env.JEST_WORKER_ID) {
475
- return new Map();
476
- }
477
-
478
- try {
479
- const output = execSync(`ps -o pid=,etime= -p ${pids.join(',')}`, {
480
- encoding: 'utf-8',
481
- });
482
- const nowMs = Date.now();
483
- const startTimes = new Map<number, Date>();
484
-
485
- for (const rawLine of output.split('\n')) {
486
- const line = rawLine.trim();
487
- if (!line) continue;
488
-
489
- const parts = line.split(/\s+/);
490
- if (parts.length < 2) continue;
491
-
492
- const pid = Number.parseInt(parts[0], 10);
493
- const elapsedSeconds = this.parseElapsedSeconds(parts[1]);
494
- if (!Number.isFinite(pid) || elapsedSeconds === null) continue;
495
-
496
- startTimes.set(pid, new Date(nowMs - elapsedSeconds * 1000));
497
- }
280
+ private determineStatus(session: CodexSession): AgentStatus {
281
+ const diffMs = Date.now() - session.lastActive.getTime();
282
+ const diffMinutes = diffMs / 60000;
498
283
 
499
- return startTimes;
500
- } catch {
501
- return new Map();
284
+ if (diffMinutes > CodexAdapter.IDLE_THRESHOLD_MINUTES) {
285
+ return AgentStatus.IDLE;
502
286
  }
503
- }
504
287
 
505
- private parseElapsedSeconds(etime: string): number | null {
506
- const match = etime.trim().match(/^(?:(\d+)-)?(?:(\d{1,2}):)?(\d{1,2}):(\d{2})$/);
507
- if (!match) {
508
- return null;
288
+ if (
289
+ session.lastPayloadType === 'agent_message' ||
290
+ session.lastPayloadType === 'task_complete' ||
291
+ session.lastPayloadType === 'turn_aborted'
292
+ ) {
293
+ return AgentStatus.WAITING;
509
294
  }
510
295
 
511
- const days = Number.parseInt(match[1] || '0', 10);
512
- const hours = Number.parseInt(match[2] || '0', 10);
513
- const minutes = Number.parseInt(match[3] || '0', 10);
514
- const seconds = Number.parseInt(match[4] || '0', 10);
515
-
516
- return (((days * 24 + hours) * 60 + minutes) * 60) + seconds;
296
+ return AgentStatus.RUNNING;
517
297
  }
518
298
 
519
299
  private extractSummary(entries: CodexEventEntry[]): string {
@@ -528,9 +308,7 @@ export class CodexAdapter implements AgentAdapter {
528
308
  }
529
309
 
530
310
  private truncate(value: string, maxLength: number): string {
531
- if (value.length <= maxLength) {
532
- return value;
533
- }
311
+ if (value.length <= maxLength) return value;
534
312
  return `${value.slice(0, maxLength - 3)}...`;
535
313
  }
536
314
 
@@ -540,34 +318,58 @@ export class CodexAdapter implements AgentAdapter {
540
318
  return base === 'codex' || base === 'codex.exe';
541
319
  }
542
320
 
543
- private determineStatus(session: CodexSession): AgentStatus {
544
- const diffMs = Date.now() - session.lastActive.getTime();
545
- const diffMinutes = diffMs / 60000;
321
+ /**
322
+ * Read the full conversation from a Codex session JSONL file.
323
+ *
324
+ * Codex entries use payload.type to indicate message role and payload.message for content.
325
+ */
326
+ getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] {
327
+ const verbose = options?.verbose ?? false;
546
328
 
547
- if (diffMinutes > CodexAdapter.IDLE_THRESHOLD_MINUTES) {
548
- return AgentStatus.IDLE;
329
+ let content: string;
330
+ try {
331
+ content = fs.readFileSync(sessionFilePath, 'utf-8');
332
+ } catch {
333
+ return [];
549
334
  }
550
335
 
551
- if (
552
- session.lastPayloadType === 'agent_message' ||
553
- session.lastPayloadType === 'task_complete' ||
554
- session.lastPayloadType === 'turn_aborted'
555
- ) {
556
- return AgentStatus.WAITING;
557
- }
336
+ const lines = content.trim().split('\n');
337
+ const messages: ConversationMessage[] = [];
558
338
 
559
- return AgentStatus.RUNNING;
560
- }
339
+ for (const line of lines) {
340
+ let entry: CodexEventEntry;
341
+ try {
342
+ entry = JSON.parse(line);
343
+ } catch {
344
+ continue;
345
+ }
346
+
347
+ if (entry.type === 'session_meta') continue;
561
348
 
562
- private generateAgentName(session: CodexSession, existingAgents: AgentInfo[]): string {
563
- const fallback = `codex-${session.sessionId.slice(0, 8)}`;
564
- const baseName = session.projectPath ? path.basename(path.normalize(session.projectPath)) : fallback;
349
+ const payloadType = entry.payload?.type;
350
+ if (!payloadType) continue;
565
351
 
566
- const conflict = existingAgents.some((agent) => agent.name === baseName);
567
- if (!conflict) {
568
- return baseName || fallback;
352
+ let role: ConversationMessage['role'];
353
+ if (payloadType === 'user_message') {
354
+ role = 'user';
355
+ } else if (payloadType === 'agent_message' || payloadType === 'task_complete') {
356
+ role = 'assistant';
357
+ } else if (verbose) {
358
+ role = 'system';
359
+ } else {
360
+ continue;
361
+ }
362
+
363
+ const text = entry.payload?.message?.trim();
364
+ if (!text) continue;
365
+
366
+ messages.push({
367
+ role,
368
+ content: text,
369
+ timestamp: entry.timestamp,
370
+ });
569
371
  }
570
372
 
571
- return `${baseName || fallback} (${session.sessionId.slice(0, 8)})`;
373
+ return messages;
572
374
  }
573
375
  }