@ai-devkit/agent-manager 0.19.1 → 0.21.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.
@@ -95,6 +95,11 @@ describe('CodexAdapter', () => {
95
95
  });
96
96
 
97
97
  describe('detectAgents', () => {
98
+ async function useRealSessionMatcher(): Promise<void> {
99
+ const actualMatching = await vi.importActual<typeof import('../../utils/matching.js')>('../../utils/matching.js');
100
+ mockedMatchProcessesToSessions.mockImplementation(actualMatching.matchProcessesToSessions);
101
+ }
102
+
98
103
  it('should return empty list when no codex process is running', async () => {
99
104
  mockedListAgentProcesses.mockReturnValue([]);
100
105
 
@@ -190,6 +195,163 @@ describe('CodexAdapter', () => {
190
195
  fs.rmSync(tmpDir, { recursive: true, force: true });
191
196
  });
192
197
 
198
+ it('should match when session_meta timestamp and file birthtime both align with process start', async () => {
199
+ await useRealSessionMatcher();
200
+ const processStart = new Date('2026-03-18T15:00:00.000Z');
201
+ const sessionTimestamp = '2026-03-18T15:00:05.000Z';
202
+ const processes: ProcessInfo[] = [
203
+ {
204
+ pid: 101,
205
+ command: 'codex',
206
+ cwd: '/repo-a',
207
+ tty: 'ttys001',
208
+ startTime: processStart,
209
+ },
210
+ ];
211
+ mockedListAgentProcesses.mockReturnValue(processes);
212
+ mockedEnrichProcesses.mockReturnValue(processes);
213
+
214
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-test-'));
215
+ const sessionsDir = path.join(tmpDir, 'sessions');
216
+ const dateDir = path.join(sessionsDir, '2026', '03', '18');
217
+ fs.mkdirSync(dateDir, { recursive: true });
218
+
219
+ const sessionFile = path.join(dateDir, 'sess-aligned.jsonl');
220
+ fs.writeFileSync(sessionFile, [
221
+ JSON.stringify({ type: 'session_meta', payload: { id: 'sess-aligned', timestamp: sessionTimestamp, cwd: '/repo-a' } }),
222
+ JSON.stringify({ type: 'event', timestamp: sessionTimestamp, payload: { type: 'token_count', message: 'Aligned session' } }),
223
+ ].join('\n'));
224
+
225
+ (adapter as any).codexSessionsDir = sessionsDir;
226
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue([
227
+ {
228
+ sessionId: 'sess-aligned',
229
+ filePath: sessionFile,
230
+ projectDir: dateDir,
231
+ birthtimeMs: new Date(sessionTimestamp).getTime(),
232
+ resolvedCwd: '',
233
+ },
234
+ ]);
235
+
236
+ const agents = await adapter.detectAgents();
237
+
238
+ expect(agents).toHaveLength(1);
239
+ expect(agents[0]).toMatchObject({
240
+ pid: 101,
241
+ sessionId: 'sess-aligned',
242
+ summary: 'Aligned session',
243
+ });
244
+
245
+ fs.rmSync(tmpDir, { recursive: true, force: true });
246
+ });
247
+
248
+ it('should match late-created session files using session_meta timestamp', async () => {
249
+ await useRealSessionMatcher();
250
+ const processStart = new Date('2026-03-18T15:00:00.000Z');
251
+ const sessionTimestamp = '2026-03-18T15:00:10.000Z';
252
+ const lateBirthtime = new Date('2026-03-18T15:05:30.000Z').getTime();
253
+ const processes: ProcessInfo[] = [
254
+ {
255
+ pid: 102,
256
+ command: 'codex',
257
+ cwd: '/repo-a',
258
+ tty: 'ttys001',
259
+ startTime: processStart,
260
+ },
261
+ ];
262
+ mockedListAgentProcesses.mockReturnValue(processes);
263
+ mockedEnrichProcesses.mockReturnValue(processes);
264
+
265
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-test-'));
266
+ const sessionsDir = path.join(tmpDir, 'sessions');
267
+ const dateDir = path.join(sessionsDir, '2026', '03', '18');
268
+ fs.mkdirSync(dateDir, { recursive: true });
269
+
270
+ const sessionFile = path.join(dateDir, 'sess-late.jsonl');
271
+ fs.writeFileSync(sessionFile, [
272
+ JSON.stringify({ type: 'session_meta', payload: { id: 'sess-late', timestamp: sessionTimestamp, cwd: '/repo-a' } }),
273
+ JSON.stringify({ type: 'event', timestamp: sessionTimestamp, payload: { type: 'token_count', message: 'Late file session' } }),
274
+ ].join('\n'));
275
+
276
+ (adapter as any).codexSessionsDir = sessionsDir;
277
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue([
278
+ {
279
+ sessionId: 'sess-late',
280
+ filePath: sessionFile,
281
+ projectDir: dateDir,
282
+ birthtimeMs: lateBirthtime,
283
+ resolvedCwd: '',
284
+ },
285
+ ]);
286
+
287
+ const agents = await adapter.detectAgents();
288
+
289
+ expect(agents).toHaveLength(1);
290
+ expect(agents[0]).toMatchObject({
291
+ pid: 102,
292
+ sessionId: 'sess-late',
293
+ summary: 'Late file session',
294
+ });
295
+ expect(mockedMatchProcessesToSessions.mock.calls[0][1][0].birthtimeMs).toBe(new Date(sessionTimestamp).getTime());
296
+
297
+ fs.rmSync(tmpDir, { recursive: true, force: true });
298
+ });
299
+
300
+ it.each<[string, string | undefined]>([
301
+ ['missing', undefined],
302
+ ['invalid', 'not-a-date'],
303
+ ])('should fall back to file birthtime when session_meta timestamp is %s', async (_label, metaTimestamp) => {
304
+ await useRealSessionMatcher();
305
+ const processStart = new Date('2026-03-18T15:00:00.000Z');
306
+ const fileBirthtime = new Date('2026-03-18T15:00:20.000Z').getTime();
307
+ const processes: ProcessInfo[] = [
308
+ {
309
+ pid: 103,
310
+ command: 'codex',
311
+ cwd: '/repo-a',
312
+ tty: 'ttys001',
313
+ startTime: processStart,
314
+ },
315
+ ];
316
+ mockedListAgentProcesses.mockReturnValue(processes);
317
+ mockedEnrichProcesses.mockReturnValue(processes);
318
+
319
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-test-'));
320
+ const sessionsDir = path.join(tmpDir, 'sessions');
321
+ const dateDir = path.join(sessionsDir, '2026', '03', '18');
322
+ fs.mkdirSync(dateDir, { recursive: true });
323
+
324
+ const payload: { id: string; cwd: string; timestamp?: string } = { id: `sess-${_label}`, cwd: '/repo-a' };
325
+ if (metaTimestamp !== undefined) {
326
+ payload.timestamp = metaTimestamp;
327
+ }
328
+
329
+ const sessionFile = path.join(dateDir, `sess-${_label}.jsonl`);
330
+ fs.writeFileSync(sessionFile, [
331
+ JSON.stringify({ type: 'session_meta', payload }),
332
+ JSON.stringify({ type: 'event', timestamp: '2026-03-18T15:00:30.000Z', payload: { type: 'token_count', message: `${_label} timestamp session` } }),
333
+ ].join('\n'));
334
+
335
+ (adapter as any).codexSessionsDir = sessionsDir;
336
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue([
337
+ {
338
+ sessionId: `sess-${_label}`,
339
+ filePath: sessionFile,
340
+ projectDir: dateDir,
341
+ birthtimeMs: fileBirthtime,
342
+ resolvedCwd: '',
343
+ },
344
+ ]);
345
+
346
+ const agents = await adapter.detectAgents();
347
+
348
+ expect(agents).toHaveLength(1);
349
+ expect(agents[0].sessionId).toBe(`sess-${_label}`);
350
+ expect(mockedMatchProcessesToSessions.mock.calls[0][1][0].birthtimeMs).toBe(fileBirthtime);
351
+
352
+ fs.rmSync(tmpDir, { recursive: true, force: true });
353
+ });
354
+
193
355
  it('should fall back to process-only for unmatched processes', async () => {
194
356
  const processes: ProcessInfo[] = [
195
357
  { pid: 100, command: 'codex', cwd: '/repo-a', tty: 'ttys001', startTime: new Date() },
@@ -393,6 +555,127 @@ describe('CodexAdapter', () => {
393
555
  fs.rmSync(tmpDir, { recursive: true, force: true });
394
556
  }
395
557
  });
558
+
559
+ it('should map a running Codex process from the hook session mapping', async () => {
560
+ const originalHome = process.env.HOME;
561
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-mapping-'));
562
+ process.env.HOME = tmpHome;
563
+
564
+ try {
565
+ const mappedAdapter = new CodexAdapter(new AgentRegistry(path.join(tmpHome, 'agents.json')));
566
+ const sessionsDir = path.join(tmpHome, '.codex', 'sessions');
567
+ const dateDir = path.join(sessionsDir, '2026', '06', '26');
568
+ const mappingPath = path.join(tmpHome, '.codex', 'ai-devkit', 'sessions.json');
569
+ const sessionFile = path.join(dateDir, 'rollout-2026-06-26T09-56-12-sess-mapped.jsonl');
570
+ const recentTs = new Date().toISOString();
571
+
572
+ fs.mkdirSync(dateDir, { recursive: true });
573
+ fs.mkdirSync(path.dirname(mappingPath), { recursive: true });
574
+ fs.writeFileSync(sessionFile, [
575
+ JSON.stringify({ type: 'session_meta', payload: { id: 'sess-mapped', timestamp: recentTs, cwd: '/repo-mapped' } }),
576
+ JSON.stringify({ type: 'event', timestamp: recentTs, payload: { type: 'agent_message', message: 'mapped conversation' } }),
577
+ ].join('\n'));
578
+ fs.writeFileSync(mappingPath, JSON.stringify({ 5151: sessionFile }));
579
+
580
+ const processes: ProcessInfo[] = [
581
+ {
582
+ pid: 5151,
583
+ command: 'codex',
584
+ cwd: '/repo-mapped',
585
+ tty: 'ttys001',
586
+ startTime: new Date('2026-06-26T09:56:12.000Z'),
587
+ },
588
+ ];
589
+ mockedListAgentProcesses.mockReturnValue(processes);
590
+ mockedEnrichProcesses.mockReturnValue(processes);
591
+
592
+ const agents = await mappedAdapter.detectAgents();
593
+
594
+ expect(agents).toHaveLength(1);
595
+ expect(agents[0]).toMatchObject({
596
+ type: 'codex',
597
+ pid: 5151,
598
+ sessionId: 'sess-mapped',
599
+ projectPath: '/repo-mapped',
600
+ summary: 'mapped conversation',
601
+ sessionFilePath: sessionFile,
602
+ });
603
+ expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled();
604
+ expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled();
605
+ } finally {
606
+ process.env.HOME = originalHome;
607
+ fs.rmSync(tmpHome, { recursive: true, force: true });
608
+ }
609
+ });
610
+
611
+ it('should fall back to legacy matching when mapped session file cannot be parsed', async () => {
612
+ const originalHome = process.env.HOME;
613
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-mapping-fallback-'));
614
+ process.env.HOME = tmpHome;
615
+
616
+ try {
617
+ const mappedAdapter = new CodexAdapter(new AgentRegistry(path.join(tmpHome, 'agents.json')));
618
+ const sessionsDir = path.join(tmpHome, '.codex', 'sessions');
619
+ const dateDir = path.join(sessionsDir, '2026', '06', '26');
620
+ const mappingPath = path.join(tmpHome, '.codex', 'ai-devkit', 'sessions.json');
621
+ const badSessionFile = path.join(dateDir, 'bad.jsonl');
622
+ const fallbackSessionFile = path.join(dateDir, 'fallback.jsonl');
623
+ const recentTs = new Date().toISOString();
624
+
625
+ fs.mkdirSync(dateDir, { recursive: true });
626
+ fs.mkdirSync(path.dirname(mappingPath), { recursive: true });
627
+ fs.writeFileSync(badSessionFile, '{not-json');
628
+ fs.writeFileSync(fallbackSessionFile, [
629
+ JSON.stringify({ type: 'session_meta', payload: { id: 'sess-fallback', timestamp: recentTs, cwd: '/repo-fallback' } }),
630
+ JSON.stringify({ type: 'event', timestamp: recentTs, payload: { type: 'token_count', message: 'fallback conversation' } }),
631
+ ].join('\n'));
632
+ fs.writeFileSync(mappingPath, JSON.stringify({ 6161: badSessionFile }));
633
+
634
+ const processes: ProcessInfo[] = [
635
+ {
636
+ pid: 6161,
637
+ command: 'codex',
638
+ cwd: '/repo-fallback',
639
+ tty: 'ttys001',
640
+ startTime: new Date('2026-06-26T09:56:12.000Z'),
641
+ },
642
+ ];
643
+ const fallbackSession: SessionFile = {
644
+ sessionId: 'sess-fallback',
645
+ filePath: fallbackSessionFile,
646
+ projectDir: dateDir,
647
+ birthtimeMs: new Date('2026-06-26T09:56:15.000Z').getTime(),
648
+ resolvedCwd: '',
649
+ };
650
+ mockedListAgentProcesses.mockReturnValue(processes);
651
+ mockedEnrichProcesses.mockReturnValue(processes);
652
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue([fallbackSession]);
653
+ mockedMatchProcessesToSessions.mockReturnValue([
654
+ {
655
+ process: processes[0],
656
+ session: { ...fallbackSession, resolvedCwd: '/repo-fallback' },
657
+ deltaMs: 3000,
658
+ },
659
+ ]);
660
+
661
+ const agents = await mappedAdapter.detectAgents();
662
+
663
+ expect(agents).toHaveLength(1);
664
+ expect(agents[0]).toMatchObject({
665
+ pid: 6161,
666
+ sessionId: 'sess-fallback',
667
+ summary: 'fallback conversation',
668
+ sessionFilePath: fallbackSessionFile,
669
+ });
670
+ expect(mockedMatchProcessesToSessions).toHaveBeenCalledWith(
671
+ [processes[0]],
672
+ expect.arrayContaining([expect.objectContaining({ filePath: fallbackSessionFile })]),
673
+ );
674
+ } finally {
675
+ process.env.HOME = originalHome;
676
+ fs.rmSync(tmpHome, { recursive: true, force: true });
677
+ }
678
+ });
396
679
  });
397
680
 
398
681
  describe('detectAgents — registry cache short-circuit', () => {
@@ -602,6 +885,133 @@ describe('CodexAdapter', () => {
602
885
  const { sessions } = discoverSessions(processes);
603
886
  expect(sessions[0].resolvedCwd).toBe('');
604
887
  });
888
+
889
+ it('should use session_meta timestamp as the matching birthtime when valid', () => {
890
+ const sessionsDir = path.join(tmpDir, 'sessions');
891
+ (adapter as any).codexSessionsDir = sessionsDir;
892
+ const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
893
+
894
+ const dateDir = path.join(sessionsDir, '2026', '03', '18');
895
+ fs.mkdirSync(dateDir, { recursive: true });
896
+
897
+ const sessionFile = path.join(dateDir, 'sess-meta-time.jsonl');
898
+ const metaTimestamp = '2026-03-18T15:00:05.000Z';
899
+ fs.writeFileSync(sessionFile,
900
+ JSON.stringify({ type: 'session_meta', payload: { id: 'sess-meta-time', timestamp: metaTimestamp, cwd: '/repo-a' } }),
901
+ );
902
+
903
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue([
904
+ {
905
+ sessionId: 'sess-meta-time',
906
+ filePath: sessionFile,
907
+ projectDir: dateDir,
908
+ birthtimeMs: new Date('2026-03-18T15:05:30.000Z').getTime(),
909
+ resolvedCwd: '',
910
+ },
911
+ ]);
912
+
913
+ const { sessions } = discoverSessions([
914
+ { pid: 1, command: 'codex', cwd: '/repo-a', tty: '', startTime: new Date('2026-03-18T15:00:00Z') },
915
+ ]);
916
+
917
+ expect(sessions[0].resolvedCwd).toBe('/repo-a');
918
+ expect(sessions[0].birthtimeMs).toBe(new Date(metaTimestamp).getTime());
919
+ });
920
+
921
+ it('should tolerate malformed session_meta and unreadable files', () => {
922
+ const sessionsDir = path.join(tmpDir, 'sessions');
923
+ (adapter as any).codexSessionsDir = sessionsDir;
924
+ const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
925
+
926
+ const dateDir = path.join(sessionsDir, '2026', '03', '18');
927
+ fs.mkdirSync(dateDir, { recursive: true });
928
+
929
+ const malformedFile = path.join(dateDir, 'malformed.jsonl');
930
+ const missingFile = path.join(dateDir, 'missing.jsonl');
931
+ fs.writeFileSync(malformedFile, '{not valid json');
932
+
933
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue([
934
+ {
935
+ sessionId: 'malformed',
936
+ filePath: malformedFile,
937
+ projectDir: dateDir,
938
+ birthtimeMs: 1710800324000,
939
+ resolvedCwd: '',
940
+ },
941
+ {
942
+ sessionId: 'missing',
943
+ filePath: missingFile,
944
+ projectDir: dateDir,
945
+ birthtimeMs: 1710800325000,
946
+ resolvedCwd: '',
947
+ },
948
+ ]);
949
+
950
+ const result = discoverSessions([
951
+ { pid: 1, command: 'codex', cwd: '/repo', tty: '', startTime: new Date('2026-03-18T15:00:00Z') },
952
+ ]);
953
+
954
+ expect(result.sessions).toHaveLength(2);
955
+ expect(result.sessions[0].resolvedCwd).toBe('');
956
+ expect(result.sessions[1].resolvedCwd).toBe('');
957
+ expect(result.contentCache.has(malformedFile)).toBe(true);
958
+ expect(result.contentCache.has(missingFile)).toBe(false);
959
+ });
960
+ });
961
+
962
+ describe('findSessionFileById', () => {
963
+ let tmpDir: string;
964
+ let sessionsDir: string;
965
+ const sessionId = 'aaaaaaaa-bbbb-4ccc-dddd-eeeeeeeeeeee';
966
+
967
+ beforeEach(() => {
968
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-resume-find-'));
969
+ sessionsDir = path.join(tmpDir, 'sessions');
970
+ fs.mkdirSync(path.join(sessionsDir, '2026', '03', '18'), { recursive: true });
971
+ (adapter as any).codexSessionsDir = sessionsDir;
972
+ });
973
+
974
+ afterEach(() => {
975
+ fs.rmSync(tmpDir, { recursive: true, force: true });
976
+ });
977
+
978
+ function writeResumeSession(timestamp?: string): string {
979
+ const filePath = path.join(sessionsDir, '2026', '03', '18', `${sessionId}.jsonl`);
980
+ const payload: { id: string; cwd: string; timestamp?: string } = { id: sessionId, cwd: '/repo-a' };
981
+ if (timestamp !== undefined) {
982
+ payload.timestamp = timestamp;
983
+ }
984
+ fs.writeFileSync(filePath, JSON.stringify({ type: 'session_meta', payload }));
985
+ return filePath;
986
+ }
987
+
988
+ it('should return session_meta timestamp as birthtimeMs when valid', () => {
989
+ const metaTimestamp = '2026-03-18T15:00:05.000Z';
990
+ writeResumeSession(metaTimestamp);
991
+ const findSessionFileById = (adapter as any).findSessionFileById.bind(adapter);
992
+
993
+ const session = findSessionFileById(sessionId);
994
+
995
+ expect(session).toMatchObject({
996
+ sessionId,
997
+ resolvedCwd: '/repo-a',
998
+ birthtimeMs: new Date(metaTimestamp).getTime(),
999
+ });
1000
+ });
1001
+
1002
+ it.each<[string, string | undefined]>([
1003
+ ['missing', undefined],
1004
+ ['invalid', 'not-a-date'],
1005
+ ])('should fall back to stat birthtimeMs when session_meta timestamp is %s', (_label, metaTimestamp) => {
1006
+ const sessionFile = writeResumeSession(metaTimestamp);
1007
+ const stat = fs.statSync(sessionFile);
1008
+ const findSessionFileById = (adapter as any).findSessionFileById.bind(adapter);
1009
+
1010
+ const session = findSessionFileById(sessionId);
1011
+
1012
+ expect(session).not.toBeNull();
1013
+ expect(session.birthtimeMs).toBe(stat.birthtimeMs);
1014
+ });
605
1015
  });
606
1016
 
607
1017
  describe('helper methods', () => {
@@ -4,10 +4,11 @@
4
4
  * Detects running Codex agents by:
5
5
  * 1. Finding running codex processes via shared listAgentProcesses()
6
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
+ * 3. Matching exact PID-to-session metadata from ~/.codex/ai-devkit/sessions.json
8
+ * 4. Discovering session files from ~/.codex/sessions/YYYY/MM/DD/ via shared batchGetSessionFileBirthtimes()
9
+ * 5. Setting resolvedCwd from session_meta first line
10
+ * 6. Matching sessions to processes via shared matchProcessesToSessions()
11
+ * 7. Extracting summary from last event entry in session JSONL
11
12
  */
12
13
 
13
14
  import * as fs from 'fs';
@@ -58,6 +59,16 @@ interface DirectMatchResult {
58
59
  failedProcesses: ProcessInfo[];
59
60
  }
60
61
 
62
+ interface MappingMatch {
63
+ process: ProcessInfo;
64
+ filePath: string;
65
+ }
66
+
67
+ interface MappingMatchResult {
68
+ agents: AgentInfo[];
69
+ fallback: ProcessInfo[];
70
+ }
71
+
61
72
  export class CodexAdapter implements AgentAdapter {
62
73
  readonly type = 'codex' as const;
63
74
 
@@ -66,11 +77,13 @@ export class CodexAdapter implements AgentAdapter {
66
77
  private static readonly PROCESS_START_DAY_WINDOW_DAYS = 1;
67
78
 
68
79
  private codexSessionsDir: string;
80
+ private sessionMappingPath: string;
69
81
  private registry: AgentRegistry;
70
82
 
71
83
  constructor(registry: AgentRegistry = AgentRegistry.default()) {
72
84
  const homeDir = process.env.HOME || process.env.USERPROFILE || '';
73
85
  this.codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
86
+ this.sessionMappingPath = path.join(homeDir, '.codex', 'ai-devkit', 'sessions.json');
74
87
  this.registry = registry;
75
88
  }
76
89
 
@@ -88,12 +101,14 @@ export class CodexAdapter implements AgentAdapter {
88
101
  const { cachedAgents, remaining } = this.tryRegistryCache(processes);
89
102
  if (remaining.length === 0) return cachedAgents;
90
103
 
91
- const { direct, fallback } = this.tryResumeMatching(remaining);
104
+ const mappingResult = this.mapSessionMappingMatches(remaining);
105
+ const { direct, fallback } = this.tryResumeMatching(mappingResult.fallback);
92
106
  const directResult = this.mapDirectMatches(direct);
93
107
  const { sessions, contentCache } = this.discoverSessions(fallback);
94
108
  if (sessions.length === 0) {
95
109
  return [
96
110
  ...cachedAgents,
111
+ ...mappingResult.agents,
97
112
  ...directResult.agents,
98
113
  ...directResult.failedProcesses.map((p) => this.mapProcessOnlyAgent(p)),
99
114
  ...fallback.map((p) => this.mapProcessOnlyAgent(p)),
@@ -127,7 +142,84 @@ export class CodexAdapter implements AgentAdapter {
127
142
  agents.push(this.mapProcessOnlyAgent(proc));
128
143
  }
129
144
 
130
- return [...cachedAgents, ...agents];
145
+ return [...cachedAgents, ...mappingResult.agents, ...agents];
146
+ }
147
+
148
+ private mapSessionMappingMatches(processes: ProcessInfo[]): MappingMatchResult {
149
+ const { matches, fallback } = this.matchFromSessionMapping(processes);
150
+ const agents: AgentInfo[] = [];
151
+
152
+ for (const match of matches) {
153
+ const sessionData = this.parseSession(undefined, match.filePath);
154
+ if (sessionData) {
155
+ agents.push(this.mapSessionToAgent(sessionData, match.process, match.filePath));
156
+ } else {
157
+ fallback.push(match.process);
158
+ }
159
+ }
160
+
161
+ return { agents, fallback };
162
+ }
163
+
164
+ private matchFromSessionMapping(processes: ProcessInfo[]): {
165
+ matches: MappingMatch[];
166
+ fallback: ProcessInfo[];
167
+ } {
168
+ const mapping = this.readSessionMapping();
169
+ if (mapping.size === 0) return { matches: [], fallback: processes };
170
+
171
+ const matches: MappingMatch[] = [];
172
+ const fallback: ProcessInfo[] = [];
173
+
174
+ for (const proc of processes) {
175
+ const filePath = mapping.get(proc.pid);
176
+ if (!filePath || !this.isTrustedSessionPath(filePath) || !fs.existsSync(filePath)) {
177
+ fallback.push(proc);
178
+ continue;
179
+ }
180
+
181
+ matches.push({ process: proc, filePath });
182
+ }
183
+
184
+ return { matches, fallback };
185
+ }
186
+
187
+ private readSessionMapping(): Map<number, string> {
188
+ const content = safeReadFile(this.sessionMappingPath);
189
+ if (content === undefined) return new Map();
190
+
191
+ let parsed: unknown;
192
+ try {
193
+ parsed = JSON.parse(content);
194
+ } catch {
195
+ return new Map();
196
+ }
197
+
198
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return new Map();
199
+
200
+ const map = new Map<number, string>();
201
+ for (const [key, value] of Object.entries(parsed)) {
202
+ const pid = this.toPid(key);
203
+ if (pid !== null && typeof value === 'string' && value) {
204
+ map.set(pid, value);
205
+ }
206
+ }
207
+
208
+ return map;
209
+ }
210
+
211
+ private toPid(value: unknown): number | null {
212
+ if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value;
213
+ if (typeof value !== 'string' || !/^\d+$/.test(value)) return null;
214
+
215
+ const parsed = Number(value);
216
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
217
+ }
218
+
219
+ private isTrustedSessionPath(filePath: string): boolean {
220
+ const resolvedRoot = path.resolve(this.codexSessionsDir);
221
+ const resolvedPath = path.resolve(filePath);
222
+ return resolvedPath === resolvedRoot || resolvedPath.startsWith(`${resolvedRoot}${path.sep}`);
131
223
  }
132
224
 
133
225
  private tryRegistryCache(processes: ProcessInfo[]): {
@@ -215,13 +307,14 @@ export class CodexAdapter implements AgentAdapter {
215
307
 
216
308
  const stat = safeStat(filePath);
217
309
  if (!stat) continue;
310
+ const metaTimestampMs = this.parseMetaTimestampMs(parsed.payload?.timestamp);
218
311
 
219
312
  return {
220
313
  sessionId,
221
314
  filePath,
222
315
  projectDir: path.dirname(filePath),
223
- birthtimeMs: stat.birthtimeMs,
224
- resolvedCwd: parsed.payload.cwd || '',
316
+ birthtimeMs: metaTimestampMs ?? stat.birthtimeMs,
317
+ resolvedCwd: parsed.payload?.cwd || '',
225
318
  };
226
319
  } catch {
227
320
  continue;
@@ -290,6 +383,10 @@ export class CodexAdapter implements AgentAdapter {
290
383
  const parsed = JSON.parse(firstLine);
291
384
  if (parsed.type === 'session_meta') {
292
385
  file.resolvedCwd = parsed.payload?.cwd || '';
386
+ const metaTimestampMs = this.parseMetaTimestampMs(parsed.payload?.timestamp);
387
+ if (metaTimestampMs !== null) {
388
+ file.birthtimeMs = metaTimestampMs;
389
+ }
293
390
  }
294
391
  }
295
392
  } catch {
@@ -468,6 +565,16 @@ export class CodexAdapter implements AgentAdapter {
468
565
  return Number.isNaN(timestamp.getTime()) ? null : timestamp;
469
566
  }
470
567
 
568
+ private parseMetaTimestampMs(value?: string): number | null {
569
+ if (typeof value !== 'string') return null;
570
+
571
+ const timestamp = this.parseTimestamp(value);
572
+ if (!timestamp) return null;
573
+
574
+ const timestampMs = timestamp.getTime();
575
+ return Number.isFinite(timestampMs) ? timestampMs : null;
576
+ }
577
+
471
578
  private determineStatus(session: CodexSession): AgentStatus {
472
579
  const diffMs = Date.now() - session.lastActive.getTime();
473
580
  const diffMinutes = diffMs / 60000;