@ai-devkit/agent-manager 0.19.0 → 0.20.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 +344 -0
- package/dist/__tests__/adapters/CodexAdapter.test.js.map +1 -1
- package/dist/__tests__/adapters/CopilotAdapter.test.js +127 -2
- package/dist/__tests__/adapters/CopilotAdapter.test.js.map +1 -1
- package/dist/__tests__/adapters/GeminiCliAdapter.test.js +144 -2
- package/dist/__tests__/adapters/GeminiCliAdapter.test.js.map +1 -1
- package/dist/__tests__/utils/process.test.js +54 -5
- package/dist/__tests__/utils/process.test.js.map +1 -1
- package/dist/adapters/AgentAdapter.d.ts +2 -0
- package/dist/adapters/AgentAdapter.d.ts.map +1 -1
- package/dist/adapters/AgentAdapter.js.map +1 -1
- package/dist/adapters/CodexAdapter.d.ts +1 -0
- package/dist/adapters/CodexAdapter.d.ts.map +1 -1
- package/dist/adapters/CodexAdapter.js +14 -2
- package/dist/adapters/CodexAdapter.js.map +1 -1
- package/dist/adapters/CopilotAdapter.d.ts +4 -2
- package/dist/adapters/CopilotAdapter.d.ts.map +1 -1
- package/dist/adapters/CopilotAdapter.js +23 -12
- package/dist/adapters/CopilotAdapter.js.map +1 -1
- package/dist/adapters/GeminiCliAdapter.d.ts +1 -0
- package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
- package/dist/adapters/GeminiCliAdapter.js +36 -11
- package/dist/adapters/GeminiCliAdapter.js.map +1 -1
- package/dist/utils/process.d.ts +11 -2
- package/dist/utils/process.d.ts.map +1 -1
- package/dist/utils/process.js +44 -9
- package/dist/utils/process.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/adapters/CodexAdapter.test.ts +289 -0
- package/src/__tests__/adapters/CopilotAdapter.test.ts +97 -5
- package/src/__tests__/adapters/GeminiCliAdapter.test.ts +139 -4
- package/src/__tests__/utils/process.test.ts +31 -7
- package/src/adapters/AgentAdapter.ts +3 -0
- package/src/adapters/CodexAdapter.ts +17 -2
- package/src/adapters/CopilotAdapter.ts +25 -14
- package/src/adapters/GeminiCliAdapter.ts +42 -11
- package/src/utils/process.ts +64 -9
|
@@ -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() },
|
|
@@ -602,6 +764,133 @@ describe('CodexAdapter', () => {
|
|
|
602
764
|
const { sessions } = discoverSessions(processes);
|
|
603
765
|
expect(sessions[0].resolvedCwd).toBe('');
|
|
604
766
|
});
|
|
767
|
+
|
|
768
|
+
it('should use session_meta timestamp as the matching birthtime when valid', () => {
|
|
769
|
+
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
770
|
+
(adapter as any).codexSessionsDir = sessionsDir;
|
|
771
|
+
const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
|
|
772
|
+
|
|
773
|
+
const dateDir = path.join(sessionsDir, '2026', '03', '18');
|
|
774
|
+
fs.mkdirSync(dateDir, { recursive: true });
|
|
775
|
+
|
|
776
|
+
const sessionFile = path.join(dateDir, 'sess-meta-time.jsonl');
|
|
777
|
+
const metaTimestamp = '2026-03-18T15:00:05.000Z';
|
|
778
|
+
fs.writeFileSync(sessionFile,
|
|
779
|
+
JSON.stringify({ type: 'session_meta', payload: { id: 'sess-meta-time', timestamp: metaTimestamp, cwd: '/repo-a' } }),
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue([
|
|
783
|
+
{
|
|
784
|
+
sessionId: 'sess-meta-time',
|
|
785
|
+
filePath: sessionFile,
|
|
786
|
+
projectDir: dateDir,
|
|
787
|
+
birthtimeMs: new Date('2026-03-18T15:05:30.000Z').getTime(),
|
|
788
|
+
resolvedCwd: '',
|
|
789
|
+
},
|
|
790
|
+
]);
|
|
791
|
+
|
|
792
|
+
const { sessions } = discoverSessions([
|
|
793
|
+
{ pid: 1, command: 'codex', cwd: '/repo-a', tty: '', startTime: new Date('2026-03-18T15:00:00Z') },
|
|
794
|
+
]);
|
|
795
|
+
|
|
796
|
+
expect(sessions[0].resolvedCwd).toBe('/repo-a');
|
|
797
|
+
expect(sessions[0].birthtimeMs).toBe(new Date(metaTimestamp).getTime());
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it('should tolerate malformed session_meta and unreadable files', () => {
|
|
801
|
+
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
802
|
+
(adapter as any).codexSessionsDir = sessionsDir;
|
|
803
|
+
const discoverSessions = (adapter as any).discoverSessions.bind(adapter);
|
|
804
|
+
|
|
805
|
+
const dateDir = path.join(sessionsDir, '2026', '03', '18');
|
|
806
|
+
fs.mkdirSync(dateDir, { recursive: true });
|
|
807
|
+
|
|
808
|
+
const malformedFile = path.join(dateDir, 'malformed.jsonl');
|
|
809
|
+
const missingFile = path.join(dateDir, 'missing.jsonl');
|
|
810
|
+
fs.writeFileSync(malformedFile, '{not valid json');
|
|
811
|
+
|
|
812
|
+
mockedBatchGetSessionFileBirthtimes.mockReturnValue([
|
|
813
|
+
{
|
|
814
|
+
sessionId: 'malformed',
|
|
815
|
+
filePath: malformedFile,
|
|
816
|
+
projectDir: dateDir,
|
|
817
|
+
birthtimeMs: 1710800324000,
|
|
818
|
+
resolvedCwd: '',
|
|
819
|
+
},
|
|
820
|
+
{
|
|
821
|
+
sessionId: 'missing',
|
|
822
|
+
filePath: missingFile,
|
|
823
|
+
projectDir: dateDir,
|
|
824
|
+
birthtimeMs: 1710800325000,
|
|
825
|
+
resolvedCwd: '',
|
|
826
|
+
},
|
|
827
|
+
]);
|
|
828
|
+
|
|
829
|
+
const result = discoverSessions([
|
|
830
|
+
{ pid: 1, command: 'codex', cwd: '/repo', tty: '', startTime: new Date('2026-03-18T15:00:00Z') },
|
|
831
|
+
]);
|
|
832
|
+
|
|
833
|
+
expect(result.sessions).toHaveLength(2);
|
|
834
|
+
expect(result.sessions[0].resolvedCwd).toBe('');
|
|
835
|
+
expect(result.sessions[1].resolvedCwd).toBe('');
|
|
836
|
+
expect(result.contentCache.has(malformedFile)).toBe(true);
|
|
837
|
+
expect(result.contentCache.has(missingFile)).toBe(false);
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
describe('findSessionFileById', () => {
|
|
842
|
+
let tmpDir: string;
|
|
843
|
+
let sessionsDir: string;
|
|
844
|
+
const sessionId = 'aaaaaaaa-bbbb-4ccc-dddd-eeeeeeeeeeee';
|
|
845
|
+
|
|
846
|
+
beforeEach(() => {
|
|
847
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-resume-find-'));
|
|
848
|
+
sessionsDir = path.join(tmpDir, 'sessions');
|
|
849
|
+
fs.mkdirSync(path.join(sessionsDir, '2026', '03', '18'), { recursive: true });
|
|
850
|
+
(adapter as any).codexSessionsDir = sessionsDir;
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
afterEach(() => {
|
|
854
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
function writeResumeSession(timestamp?: string): string {
|
|
858
|
+
const filePath = path.join(sessionsDir, '2026', '03', '18', `${sessionId}.jsonl`);
|
|
859
|
+
const payload: { id: string; cwd: string; timestamp?: string } = { id: sessionId, cwd: '/repo-a' };
|
|
860
|
+
if (timestamp !== undefined) {
|
|
861
|
+
payload.timestamp = timestamp;
|
|
862
|
+
}
|
|
863
|
+
fs.writeFileSync(filePath, JSON.stringify({ type: 'session_meta', payload }));
|
|
864
|
+
return filePath;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
it('should return session_meta timestamp as birthtimeMs when valid', () => {
|
|
868
|
+
const metaTimestamp = '2026-03-18T15:00:05.000Z';
|
|
869
|
+
writeResumeSession(metaTimestamp);
|
|
870
|
+
const findSessionFileById = (adapter as any).findSessionFileById.bind(adapter);
|
|
871
|
+
|
|
872
|
+
const session = findSessionFileById(sessionId);
|
|
873
|
+
|
|
874
|
+
expect(session).toMatchObject({
|
|
875
|
+
sessionId,
|
|
876
|
+
resolvedCwd: '/repo-a',
|
|
877
|
+
birthtimeMs: new Date(metaTimestamp).getTime(),
|
|
878
|
+
});
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it.each<[string, string | undefined]>([
|
|
882
|
+
['missing', undefined],
|
|
883
|
+
['invalid', 'not-a-date'],
|
|
884
|
+
])('should fall back to stat birthtimeMs when session_meta timestamp is %s', (_label, metaTimestamp) => {
|
|
885
|
+
const sessionFile = writeResumeSession(metaTimestamp);
|
|
886
|
+
const stat = fs.statSync(sessionFile);
|
|
887
|
+
const findSessionFileById = (adapter as any).findSessionFileById.bind(adapter);
|
|
888
|
+
|
|
889
|
+
const session = findSessionFileById(sessionId);
|
|
890
|
+
|
|
891
|
+
expect(session).not.toBeNull();
|
|
892
|
+
expect(session.birthtimeMs).toBe(stat.birthtimeMs);
|
|
893
|
+
});
|
|
605
894
|
});
|
|
606
895
|
|
|
607
896
|
describe('helper methods', () => {
|
|
@@ -12,11 +12,16 @@ import type { ProcessInfo } from '../../adapters/AgentAdapter.js';
|
|
|
12
12
|
import { AgentStatus } from '../../adapters/AgentAdapter.js';
|
|
13
13
|
import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
|
|
14
14
|
import { generateAgentName } from '../../utils/matching.js';
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
import { AgentRegistry } from '../../utils/AgentRegistry.js';
|
|
16
|
+
|
|
17
|
+
vi.mock('../../utils/process.js', async (importOriginal) => {
|
|
18
|
+
const actual = await importOriginal() as typeof import('../../utils/process.js');
|
|
19
|
+
return {
|
|
20
|
+
...actual,
|
|
21
|
+
listAgentProcesses: vi.fn(),
|
|
22
|
+
enrichProcesses: vi.fn(),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
20
25
|
|
|
21
26
|
vi.mock('../../utils/matching.js', () => ({
|
|
22
27
|
generateAgentName: vi.fn(),
|
|
@@ -220,6 +225,55 @@ describe('CopilotAdapter', () => {
|
|
|
220
225
|
});
|
|
221
226
|
});
|
|
222
227
|
|
|
228
|
+
it('suppresses wrapper process-only agents before the session lock exists', async () => {
|
|
229
|
+
const processes: ProcessInfo[] = [
|
|
230
|
+
{ pid: 86800, command: 'copilot', cwd: '/repo', tty: 'ttys001', ppid: 84174 },
|
|
231
|
+
{ pid: 86810, command: '/custom/install/copilot', cwd: '/repo', tty: 'ttys001', ppid: 86800 },
|
|
232
|
+
];
|
|
233
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
234
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
235
|
+
|
|
236
|
+
const agents = await adapter.detectAgents();
|
|
237
|
+
|
|
238
|
+
expect(agents).toHaveLength(1);
|
|
239
|
+
expect(agents[0]).toMatchObject({
|
|
240
|
+
pid: 86810,
|
|
241
|
+
sessionId: 'pid-86810',
|
|
242
|
+
summary: 'Copilot process running',
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('carries the managed wrapper name to a process-only child before the session lock exists', async () => {
|
|
247
|
+
const registry = new AgentRegistry(path.join(tmpDir, 'agents.json'));
|
|
248
|
+
adapter = new CopilotAdapter(registry);
|
|
249
|
+
(adapter as any).sessionStateDir = sessionStateDir;
|
|
250
|
+
const processes: ProcessInfo[] = [
|
|
251
|
+
{ pid: 86800, command: 'copilot', cwd: '/repo', tty: 'ttys001', ppid: 84174 },
|
|
252
|
+
{ pid: 86810, command: '/custom/install/copilot', cwd: '/repo', tty: 'ttys001', ppid: 86800 },
|
|
253
|
+
];
|
|
254
|
+
registry.register({
|
|
255
|
+
name: 'copilot-started',
|
|
256
|
+
type: 'copilot',
|
|
257
|
+
pid: 86800,
|
|
258
|
+
tmuxSession: 'copilot-started',
|
|
259
|
+
cwd: '/repo',
|
|
260
|
+
startedAt: '2026-06-13T19:15:16.211Z',
|
|
261
|
+
sessionId: 'pid-86800',
|
|
262
|
+
sessionFilePath: '',
|
|
263
|
+
});
|
|
264
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
265
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
266
|
+
|
|
267
|
+
const agents = await adapter.detectAgents();
|
|
268
|
+
|
|
269
|
+
expect(agents).toHaveLength(1);
|
|
270
|
+
expect(agents[0]).toMatchObject({
|
|
271
|
+
name: 'copilot-started',
|
|
272
|
+
pid: 86810,
|
|
273
|
+
sessionId: 'pid-86810',
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
223
277
|
it('does not add duplicate process-only agent for wrapper process in the same terminal', async () => {
|
|
224
278
|
const processes: ProcessInfo[] = [
|
|
225
279
|
{ pid: 14095, command: 'copilot', cwd: '/repo', tty: 'ttys001' },
|
|
@@ -244,6 +298,44 @@ describe('CopilotAdapter', () => {
|
|
|
244
298
|
});
|
|
245
299
|
});
|
|
246
300
|
|
|
301
|
+
it('carries the managed wrapper name to a lock-backed child process', async () => {
|
|
302
|
+
const registry = new AgentRegistry(path.join(tmpDir, 'agents.json'));
|
|
303
|
+
adapter = new CopilotAdapter(registry);
|
|
304
|
+
(adapter as any).sessionStateDir = sessionStateDir;
|
|
305
|
+
const processes: ProcessInfo[] = [
|
|
306
|
+
{ pid: 14095, command: 'copilot', cwd: '/repo', tty: 'ttys001', ppid: 84174 },
|
|
307
|
+
{ pid: 14096, command: '/opt/homebrew/Caskroom/copilot-cli/1.0.60/copilot', cwd: '/repo', tty: 'ttys001', ppid: 14095 },
|
|
308
|
+
];
|
|
309
|
+
registry.register({
|
|
310
|
+
name: 'copilot-started',
|
|
311
|
+
type: 'copilot',
|
|
312
|
+
pid: 14095,
|
|
313
|
+
tmuxSession: 'copilot-started',
|
|
314
|
+
cwd: '/repo',
|
|
315
|
+
startedAt: '2026-06-13T19:15:16.211Z',
|
|
316
|
+
sessionId: 'pid-14095',
|
|
317
|
+
sessionFilePath: '',
|
|
318
|
+
});
|
|
319
|
+
mockedListAgentProcesses.mockReturnValue(processes);
|
|
320
|
+
mockedEnrichProcesses.mockReturnValue(processes);
|
|
321
|
+
writeSession('sess-wrapper', {
|
|
322
|
+
lockPid: 14096,
|
|
323
|
+
events: [
|
|
324
|
+
sessionStart('sess-wrapper', '/repo', '2026-06-09T09:50:00.000Z'),
|
|
325
|
+
{ type: 'user.message', data: { content: 'hello' }, timestamp: new Date().toISOString() },
|
|
326
|
+
],
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const agents = await adapter.detectAgents();
|
|
330
|
+
|
|
331
|
+
expect(agents).toHaveLength(1);
|
|
332
|
+
expect(agents[0]).toMatchObject({
|
|
333
|
+
name: 'copilot-started',
|
|
334
|
+
pid: 14096,
|
|
335
|
+
sessionId: 'sess-wrapper',
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
247
339
|
it('uses workspace metadata when events are missing', async () => {
|
|
248
340
|
const processes: ProcessInfo[] = [
|
|
249
341
|
{ pid: 300, command: 'copilot', cwd: '/proc-cwd', tty: 'ttys003' },
|
|
@@ -15,10 +15,14 @@ import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
|
|
|
15
15
|
import { matchProcessesToSessions, generateAgentName } from '../../utils/matching.js';
|
|
16
16
|
import * as crypto from 'crypto';
|
|
17
17
|
|
|
18
|
-
vi.mock('../../utils/process.js', () =>
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
vi.mock('../../utils/process.js', async (importOriginal) => {
|
|
19
|
+
const actual = await importOriginal() as typeof import('../../utils/process.js');
|
|
20
|
+
return {
|
|
21
|
+
...actual,
|
|
22
|
+
listAgentProcesses: vi.fn(),
|
|
23
|
+
enrichProcesses: vi.fn(),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
22
26
|
|
|
23
27
|
vi.mock('../../utils/matching.js', () => ({
|
|
24
28
|
matchProcessesToSessions: vi.fn(),
|
|
@@ -158,6 +162,77 @@ describe('GeminiCliAdapter', () => {
|
|
|
158
162
|
});
|
|
159
163
|
});
|
|
160
164
|
|
|
165
|
+
it('should suppress Gemini wrapper process-only agents before a session file exists', async () => {
|
|
166
|
+
const wrapperProc: ProcessInfo = {
|
|
167
|
+
pid: 20452,
|
|
168
|
+
ppid: 17530,
|
|
169
|
+
command: '/opt/homebrew/opt/node/bin/node /opt/homebrew/bin/gemini',
|
|
170
|
+
cwd: '/repo',
|
|
171
|
+
tty: 'ttys007',
|
|
172
|
+
startTime: new Date('2026-06-13T08:25:21Z'),
|
|
173
|
+
};
|
|
174
|
+
const childProc: ProcessInfo = {
|
|
175
|
+
pid: 21373,
|
|
176
|
+
ppid: 20452,
|
|
177
|
+
command: '/opt/homebrew/Cellar/node/26.0.0/bin/node --max-old-space-size=8192 /opt/homebrew/bin/gemini',
|
|
178
|
+
cwd: '/repo',
|
|
179
|
+
tty: 'ttys007',
|
|
180
|
+
startTime: new Date('2026-06-13T08:25:26Z'),
|
|
181
|
+
};
|
|
182
|
+
mockedListAgentProcesses.mockReturnValue([wrapperProc, childProc]);
|
|
183
|
+
|
|
184
|
+
const agents = await adapter.detectAgents();
|
|
185
|
+
|
|
186
|
+
expect(agents).toHaveLength(1);
|
|
187
|
+
expect(agents[0]).toMatchObject({
|
|
188
|
+
pid: 21373,
|
|
189
|
+
sessionId: 'pid-21373',
|
|
190
|
+
summary: 'Gemini CLI process running',
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should carry the managed wrapper name to a process-only child before a session file exists', async () => {
|
|
195
|
+
const regPath = path.join(tmpHome, 'agents.json');
|
|
196
|
+
const registry = new AgentRegistry(regPath);
|
|
197
|
+
const namedAdapter = new GeminiCliAdapter(registry);
|
|
198
|
+
const wrapperProc: ProcessInfo = {
|
|
199
|
+
pid: 35792,
|
|
200
|
+
ppid: 33068,
|
|
201
|
+
command: '/opt/homebrew/opt/node/bin/node /opt/homebrew/bin/gemini',
|
|
202
|
+
cwd: '/repo',
|
|
203
|
+
tty: 'ttys002',
|
|
204
|
+
startTime: new Date('2026-06-13T19:15:16Z'),
|
|
205
|
+
};
|
|
206
|
+
const childProc: ProcessInfo = {
|
|
207
|
+
pid: 36514,
|
|
208
|
+
ppid: 35792,
|
|
209
|
+
command: '/opt/homebrew/Cellar/node/26.0.0/bin/node --max-old-space-size=8192 /opt/homebrew/bin/gemini',
|
|
210
|
+
cwd: '/repo',
|
|
211
|
+
tty: 'ttys002',
|
|
212
|
+
startTime: new Date('2026-06-13T19:15:18Z'),
|
|
213
|
+
};
|
|
214
|
+
registry.register({
|
|
215
|
+
name: 'cli-mqcqj469',
|
|
216
|
+
type: 'gemini_cli',
|
|
217
|
+
pid: wrapperProc.pid,
|
|
218
|
+
tmuxSession: 'cli-mqcqj469',
|
|
219
|
+
cwd: wrapperProc.cwd,
|
|
220
|
+
startedAt: '2026-06-13T19:15:16.211Z',
|
|
221
|
+
sessionId: `pid-${wrapperProc.pid}`,
|
|
222
|
+
sessionFilePath: '',
|
|
223
|
+
});
|
|
224
|
+
mockedListAgentProcesses.mockReturnValue([wrapperProc, childProc]);
|
|
225
|
+
|
|
226
|
+
const agents = await namedAdapter.detectAgents();
|
|
227
|
+
|
|
228
|
+
expect(agents).toHaveLength(1);
|
|
229
|
+
expect(agents[0]).toMatchObject({
|
|
230
|
+
name: 'cli-mqcqj469',
|
|
231
|
+
pid: childProc.pid,
|
|
232
|
+
sessionId: `pid-${childProc.pid}`,
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
161
236
|
it('should map a process to its matching session file via projectHash', async () => {
|
|
162
237
|
const cwd = '/repo/project-a';
|
|
163
238
|
const projectHash = hashProjectRoot(cwd);
|
|
@@ -433,6 +508,66 @@ describe('GeminiCliAdapter', () => {
|
|
|
433
508
|
|
|
434
509
|
expect(agents[0].sessionId).toBe('pid-100');
|
|
435
510
|
});
|
|
511
|
+
|
|
512
|
+
it('carries the managed wrapper name to the detected child process', async () => {
|
|
513
|
+
const wrapperProc: ProcessInfo = {
|
|
514
|
+
pid: 20339,
|
|
515
|
+
ppid: 17570,
|
|
516
|
+
command: '/opt/homebrew/opt/node/bin/node /opt/homebrew/bin/gemini',
|
|
517
|
+
cwd: '/repo-a',
|
|
518
|
+
tty: 'ttys002',
|
|
519
|
+
startTime: new Date('2026-06-13T19:00:53Z'),
|
|
520
|
+
};
|
|
521
|
+
const childProc: ProcessInfo = {
|
|
522
|
+
pid: 21038,
|
|
523
|
+
ppid: 20339,
|
|
524
|
+
command: '/opt/homebrew/Cellar/node/26.0.0/bin/node --max-old-space-size=8192 /opt/homebrew/bin/gemini',
|
|
525
|
+
cwd: '/repo-a',
|
|
526
|
+
tty: 'ttys002',
|
|
527
|
+
startTime: new Date('2026-06-13T19:00:57Z'),
|
|
528
|
+
};
|
|
529
|
+
const now = new Date().toISOString();
|
|
530
|
+
sessionFilePath = writeSession(tmpHome, 'cli-2', 'session-2026-06-13T19-00-s-cached', {
|
|
531
|
+
sessionId: 's-cached',
|
|
532
|
+
projectHash: hashProjectRoot('/repo-a'),
|
|
533
|
+
startTime: now,
|
|
534
|
+
lastUpdated: now,
|
|
535
|
+
directories: ['/repo-a'],
|
|
536
|
+
messages: [
|
|
537
|
+
{ id: 'm1', timestamp: now, type: 'user', content: 'Hello from child process' },
|
|
538
|
+
],
|
|
539
|
+
});
|
|
540
|
+
registerEntry({
|
|
541
|
+
name: 'cli-mqcq0mg5',
|
|
542
|
+
pid: wrapperProc.pid,
|
|
543
|
+
tmuxSession: 'cli-mqcq0mg5',
|
|
544
|
+
sessionId: 's-cached',
|
|
545
|
+
sessionFilePath,
|
|
546
|
+
});
|
|
547
|
+
mockedListAgentProcesses.mockReturnValue([wrapperProc, childProc]);
|
|
548
|
+
mockedMatchProcessesToSessions.mockReturnValue([
|
|
549
|
+
{
|
|
550
|
+
process: childProc,
|
|
551
|
+
session: {
|
|
552
|
+
sessionId: 's-cached',
|
|
553
|
+
filePath: sessionFilePath,
|
|
554
|
+
projectDir: path.dirname(sessionFilePath),
|
|
555
|
+
birthtimeMs: Date.now(),
|
|
556
|
+
resolvedCwd: '/repo-a',
|
|
557
|
+
},
|
|
558
|
+
deltaMs: 0,
|
|
559
|
+
},
|
|
560
|
+
]);
|
|
561
|
+
|
|
562
|
+
const agents = await cachedAdapter.detectAgents();
|
|
563
|
+
|
|
564
|
+
expect(agents).toHaveLength(1);
|
|
565
|
+
expect(agents[0]).toMatchObject({
|
|
566
|
+
name: 'cli-mqcq0mg5',
|
|
567
|
+
pid: childProc.pid,
|
|
568
|
+
sessionId: 's-cached',
|
|
569
|
+
});
|
|
570
|
+
});
|
|
436
571
|
});
|
|
437
572
|
|
|
438
573
|
describe('discoverSessions', () => {
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
batchGetProcessCwds,
|
|
11
11
|
batchGetProcessStartTimes,
|
|
12
12
|
enrichProcesses,
|
|
13
|
+
findWrapperProcess,
|
|
14
|
+
findWrapperProcessPids,
|
|
13
15
|
} from '../../utils/process.js';
|
|
14
16
|
|
|
15
17
|
vi.mock('child_process', () => ({
|
|
@@ -23,26 +25,28 @@ describe('listAgentProcesses', () => {
|
|
|
23
25
|
mockedExecFileSync.mockReset();
|
|
24
26
|
});
|
|
25
27
|
|
|
26
|
-
it('should parse ps
|
|
28
|
+
it('should parse ps output and post-filter by executable name', () => {
|
|
27
29
|
mockedExecFileSync.mockReturnValue(
|
|
28
|
-
'
|
|
29
|
-
'
|
|
30
|
+
'78070 1 s018 claude\n' +
|
|
31
|
+
'55106 55100 s015 claude\n',
|
|
30
32
|
);
|
|
31
33
|
|
|
32
34
|
const processes = listAgentProcesses('claude');
|
|
33
35
|
expect(processes).toHaveLength(2);
|
|
34
36
|
expect(processes[0].pid).toBe(78070);
|
|
37
|
+
expect(processes[0].ppid).toBe(1);
|
|
35
38
|
expect(processes[0].command).toBe('claude');
|
|
36
39
|
expect(processes[0].tty).toBe('s018');
|
|
37
40
|
expect(processes[0].cwd).toBe(''); // not populated yet
|
|
38
41
|
expect(processes[1].pid).toBe(55106);
|
|
42
|
+
expect(processes[1].ppid).toBe(55100);
|
|
39
43
|
});
|
|
40
44
|
|
|
41
45
|
it('should filter out non-matching executables', () => {
|
|
42
46
|
mockedExecFileSync.mockReturnValue(
|
|
43
|
-
'
|
|
44
|
-
'
|
|
45
|
-
'
|
|
47
|
+
'100 1 s001 claude\n' +
|
|
48
|
+
'200 1 s002 claude-helper --pid 100\n' +
|
|
49
|
+
'300 1 s003 /usr/bin/claude\n',
|
|
46
50
|
);
|
|
47
51
|
|
|
48
52
|
const processes = listAgentProcesses('claude');
|
|
@@ -75,7 +79,11 @@ describe('listAgentProcesses', () => {
|
|
|
75
79
|
it('should accept valid patterns with dashes and underscores', () => {
|
|
76
80
|
mockedExecFileSync.mockReturnValue('');
|
|
77
81
|
listAgentProcesses('claude-code');
|
|
78
|
-
expect(mockedExecFileSync).
|
|
82
|
+
expect(mockedExecFileSync).toHaveBeenCalledWith(
|
|
83
|
+
'ps',
|
|
84
|
+
['-axo', 'pid=,ppid=,tty=,command='],
|
|
85
|
+
{ encoding: 'utf-8' },
|
|
86
|
+
);
|
|
79
87
|
|
|
80
88
|
mockedExecFileSync.mockReset();
|
|
81
89
|
mockedExecFileSync.mockReturnValue('');
|
|
@@ -201,3 +209,19 @@ describe('enrichProcesses', () => {
|
|
|
201
209
|
expect(enriched[0].startTime).toBeUndefined();
|
|
202
210
|
});
|
|
203
211
|
});
|
|
212
|
+
|
|
213
|
+
describe('wrapper process detection', () => {
|
|
214
|
+
it('finds the parent wrapper process for a child in the same terminal and cwd', () => {
|
|
215
|
+
const wrapper = { pid: 100, ppid: 1, command: 'node /bin/gemini', cwd: '/repo', tty: 'ttys001' };
|
|
216
|
+
const child = { pid: 200, ppid: 100, command: 'node --max-old-space-size=8192 /bin/gemini', cwd: '/repo', tty: 'ttys001' };
|
|
217
|
+
|
|
218
|
+
expect(findWrapperProcess([wrapper, child], child)).toBe(wrapper);
|
|
219
|
+
expect(findWrapperProcessPids([wrapper, child])).toEqual(new Set([100]));
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('does not mark a matched child process as its own wrapper', () => {
|
|
223
|
+
const child = { pid: 200, ppid: 100, command: 'node --max-old-space-size=8192 /bin/gemini', cwd: '/repo', tty: 'ttys001' };
|
|
224
|
+
|
|
225
|
+
expect(findWrapperProcessPids([child], [child])).toEqual(new Set());
|
|
226
|
+
});
|
|
227
|
+
});
|