@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.
- package/dist/__tests__/adapters/CodexAdapter.test.js +507 -0
- package/dist/__tests__/adapters/CodexAdapter.test.js.map +1 -1
- package/dist/adapters/CodexAdapter.d.ts +12 -4
- package/dist/adapters/CodexAdapter.d.ts.map +1 -1
- package/dist/adapters/CodexAdapter.js +95 -7
- package/dist/adapters/CodexAdapter.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/adapters/CodexAdapter.test.ts +410 -0
- package/src/adapters/CodexAdapter.ts +115 -8
|
@@ -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.
|
|
8
|
-
* 4.
|
|
9
|
-
* 5.
|
|
10
|
-
* 6.
|
|
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
|
|
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
|
|
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;
|