@ai-devkit/agent-manager 0.16.0 → 0.17.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 (97) hide show
  1. package/.swcrc +27 -0
  2. package/dist/AgentManager.js +72 -74
  3. package/dist/AgentManager.js.map +1 -1
  4. package/dist/__tests__/AgentManager.test.js +564 -0
  5. package/dist/__tests__/AgentManager.test.js.map +1 -0
  6. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js +2157 -0
  7. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js.map +1 -0
  8. package/dist/__tests__/adapters/CodexAdapter.test.js +1354 -0
  9. package/dist/__tests__/adapters/CodexAdapter.test.js.map +1 -0
  10. package/dist/__tests__/adapters/CopilotAdapter.test.js +500 -0
  11. package/dist/__tests__/adapters/CopilotAdapter.test.js.map +1 -0
  12. package/dist/__tests__/adapters/GeminiCliAdapter.test.js +1229 -0
  13. package/dist/__tests__/adapters/GeminiCliAdapter.test.js.map +1 -0
  14. package/dist/__tests__/adapters/OpenCodeAdapter.test.js +626 -0
  15. package/dist/__tests__/adapters/OpenCodeAdapter.test.js.map +1 -0
  16. package/dist/__tests__/terminal/TmuxManager.test.js +159 -0
  17. package/dist/__tests__/terminal/TmuxManager.test.js.map +1 -0
  18. package/dist/__tests__/terminal/TtyWriter.test.js +162 -0
  19. package/dist/__tests__/terminal/TtyWriter.test.js.map +1 -0
  20. package/dist/__tests__/utils/AgentRegistry.test.js +282 -0
  21. package/dist/__tests__/utils/AgentRegistry.test.js.map +1 -0
  22. package/dist/__tests__/utils/ClaudeSessionParser.test.js +312 -0
  23. package/dist/__tests__/utils/ClaudeSessionParser.test.js.map +1 -0
  24. package/dist/__tests__/utils/matching.test.js +324 -0
  25. package/dist/__tests__/utils/matching.test.js.map +1 -0
  26. package/dist/__tests__/utils/process.test.js +177 -0
  27. package/dist/__tests__/utils/process.test.js.map +1 -0
  28. package/dist/__tests__/utils/session.test.js +158 -0
  29. package/dist/__tests__/utils/session.test.js.map +1 -0
  30. package/dist/adapters/AgentAdapter.d.ts +1 -1
  31. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  32. package/dist/adapters/AgentAdapter.js +8 -7
  33. package/dist/adapters/AgentAdapter.js.map +1 -1
  34. package/dist/adapters/ClaudeCodeAdapter.js +66 -77
  35. package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
  36. package/dist/adapters/CodexAdapter.d.ts +9 -0
  37. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  38. package/dist/adapters/CodexAdapter.js +188 -141
  39. package/dist/adapters/CodexAdapter.js.map +1 -1
  40. package/dist/adapters/CopilotAdapter.d.ts +44 -0
  41. package/dist/adapters/CopilotAdapter.d.ts.map +1 -0
  42. package/dist/adapters/CopilotAdapter.js +314 -0
  43. package/dist/adapters/CopilotAdapter.js.map +1 -0
  44. package/dist/adapters/GeminiCliAdapter.d.ts +2 -0
  45. package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
  46. package/dist/adapters/GeminiCliAdapter.js +138 -156
  47. package/dist/adapters/GeminiCliAdapter.js.map +1 -1
  48. package/dist/adapters/OpenCodeAdapter.js +60 -69
  49. package/dist/adapters/OpenCodeAdapter.js.map +1 -1
  50. package/dist/adapters/index.d.ts +1 -0
  51. package/dist/adapters/index.d.ts.map +1 -1
  52. package/dist/adapters/index.js +2 -0
  53. package/dist/adapters/index.js.map +1 -1
  54. package/dist/index.d.ts +1 -0
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +2 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/terminal/TerminalFocusManager.js +62 -49
  59. package/dist/terminal/TerminalFocusManager.js.map +1 -1
  60. package/dist/terminal/TmuxManager.js +59 -34
  61. package/dist/terminal/TmuxManager.js.map +1 -1
  62. package/dist/terminal/TtyWriter.js +37 -16
  63. package/dist/terminal/TtyWriter.js.map +1 -1
  64. package/dist/terminal/index.js +1 -0
  65. package/dist/terminal/index.js.map +1 -1
  66. package/dist/utils/AgentRegistry.js +41 -32
  67. package/dist/utils/AgentRegistry.js.map +1 -1
  68. package/dist/utils/ClaudeSessionParser.js +46 -87
  69. package/dist/utils/ClaudeSessionParser.js.map +1 -1
  70. package/dist/utils/agents.d.ts +1 -1
  71. package/dist/utils/agents.d.ts.map +1 -1
  72. package/dist/utils/agents.js +22 -11
  73. package/dist/utils/agents.js.map +1 -1
  74. package/dist/utils/applescript.js +3 -6
  75. package/dist/utils/applescript.js.map +1 -1
  76. package/dist/utils/index.js +1 -0
  77. package/dist/utils/index.js.map +1 -1
  78. package/dist/utils/matching.js +21 -29
  79. package/dist/utils/matching.js.map +1 -1
  80. package/dist/utils/process.js +77 -56
  81. package/dist/utils/process.js.map +1 -1
  82. package/dist/utils/session.js +20 -34
  83. package/dist/utils/session.js.map +1 -1
  84. package/dist/utils/sortAgents.js +6 -8
  85. package/dist/utils/sortAgents.js.map +1 -1
  86. package/package.json +11 -7
  87. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +2 -0
  88. package/src/__tests__/adapters/CodexAdapter.test.ts +94 -0
  89. package/src/__tests__/adapters/CopilotAdapter.test.ts +411 -0
  90. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +74 -1
  91. package/src/adapters/AgentAdapter.ts +1 -1
  92. package/src/adapters/CodexAdapter.ts +114 -6
  93. package/src/adapters/CopilotAdapter.ts +453 -0
  94. package/src/adapters/GeminiCliAdapter.ts +42 -3
  95. package/src/adapters/index.ts +1 -0
  96. package/src/index.ts +1 -0
  97. package/src/utils/agents.ts +1 -1
@@ -3,7 +3,7 @@ const STATUS_PRIORITY = {
3
3
  [AgentStatus.WAITING]: 0,
4
4
  [AgentStatus.RUNNING]: 1,
5
5
  [AgentStatus.IDLE]: 2,
6
- [AgentStatus.UNKNOWN]: 3,
6
+ [AgentStatus.UNKNOWN]: 3
7
7
  };
8
8
  function compareByName(a, b) {
9
9
  return a.name.localeCompare(b.name);
@@ -11,25 +11,22 @@ function compareByName(a, b) {
11
11
  function compareByStatus(a, b) {
12
12
  const pa = STATUS_PRIORITY[a.status] ?? 999;
13
13
  const pb = STATUS_PRIORITY[b.status] ?? 999;
14
- if (pa !== pb)
15
- return pa - pb;
14
+ if (pa !== pb) return pa - pb;
16
15
  return compareByName(a, b);
17
16
  }
18
17
  function compareByPid(a, b) {
19
- if (a.pid !== b.pid)
20
- return a.pid - b.pid;
18
+ if (a.pid !== b.pid) return a.pid - b.pid;
21
19
  return compareByName(a, b);
22
20
  }
23
21
  function compareByLastActive(a, b) {
24
22
  const ta = a.lastActive instanceof Date ? a.lastActive.getTime() : new Date(a.lastActive).getTime();
25
23
  const tb = b.lastActive instanceof Date ? b.lastActive.getTime() : new Date(b.lastActive).getTime();
26
- if (tb !== ta)
27
- return tb - ta; // most recent first
24
+ if (tb !== ta) return tb - ta; // most recent first
28
25
  return compareByName(a, b);
29
26
  }
30
27
  export function sortAgents(agents, by) {
31
28
  const copy = agents.slice();
32
- switch (by) {
29
+ switch(by){
33
30
  case 'name':
34
31
  copy.sort(compareByName);
35
32
  break;
@@ -45,4 +42,5 @@ export function sortAgents(agents, by) {
45
42
  }
46
43
  return copy;
47
44
  }
45
+
48
46
  //# sourceMappingURL=sortAgents.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"sortAgents.js","sourceRoot":"","sources":["../../src/utils/sortAgents.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAkB,MAAM,6BAA6B,CAAC;AAI1E,MAAM,eAAe,GAAgC;IACjD,CAAC,WAAW,CAAC,OAAO,CAAC,EAAE,CAAC;IACxB,CAAC,WAAW,CAAC,OAAO,CAAC,EAAE,CAAC;IACxB,CAAC,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC;IACrB,CAAC,WAAW,CAAC,OAAO,CAAC,EAAE,CAAC;CAC3B,CAAC;AAEF,SAAS,aAAa,CAAC,CAAY,EAAE,CAAY;IAC7C,OAAO,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;AACxC,CAAC;AAED,SAAS,eAAe,CAAC,CAAY,EAAE,CAAY;IAC/C,MAAM,EAAE,GAAG,eAAe,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,GAAG,CAAC;IAC5C,MAAM,EAAE,GAAG,eAAe,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,GAAG,CAAC;IAC5C,IAAI,EAAE,KAAK,EAAE;QAAE,OAAO,EAAE,GAAG,EAAE,CAAC;IAC9B,OAAO,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAC/B,CAAC;AAED,SAAS,YAAY,CAAC,CAAY,EAAE,CAAY;IAC5C,IAAI,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,GAAG;QAAE,OAAO,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,GAAG,CAAC;IAC1C,OAAO,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAC/B,CAAC;AAED,SAAS,mBAAmB,CAAC,CAAY,EAAE,CAAY;IACnD,MAAM,EAAE,GAAG,CAAC,CAAC,UAAU,YAAY,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,OAAO,EAAE,CAAC;IACpG,MAAM,EAAE,GAAG,CAAC,CAAC,UAAU,YAAY,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,OAAO,EAAE,CAAC;IACpG,IAAI,EAAE,KAAK,EAAE;QAAE,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC,oBAAoB;IACnD,OAAO,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAC/B,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,MAAmB,EAAE,EAAgB;IAC5D,MAAM,IAAI,GAAG,MAAM,CAAC,KAAK,EAAE,CAAC;IAC5B,QAAQ,EAAE,EAAE,CAAC;QACT,KAAK,MAAM;YACP,IAAI,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;YACzB,MAAM;QACV,KAAK,QAAQ;YACT,IAAI,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC;YAC3B,MAAM;QACV,KAAK,KAAK;YACN,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;YACxB,MAAM;QACV,KAAK,YAAY;YACb,IAAI,CAAC,IAAI,CAAC,mBAAmB,CAAC,CAAC;YAC/B,MAAM;IACd,CAAC;IACD,OAAO,IAAI,CAAC;AAChB,CAAC"}
1
+ {"version":3,"sources":["../../src/utils/sortAgents.ts"],"sourcesContent":["import { AgentStatus, type AgentInfo } from '../adapters/AgentAdapter.js';\n\nexport type AgentSortKey = 'name' | 'status' | 'pid' | 'lastActive';\n\nconst STATUS_PRIORITY: Record<AgentStatus, number> = {\n [AgentStatus.WAITING]: 0,\n [AgentStatus.RUNNING]: 1,\n [AgentStatus.IDLE]: 2,\n [AgentStatus.UNKNOWN]: 3,\n};\n\nfunction compareByName(a: AgentInfo, b: AgentInfo): number {\n return a.name.localeCompare(b.name);\n}\n\nfunction compareByStatus(a: AgentInfo, b: AgentInfo): number {\n const pa = STATUS_PRIORITY[a.status] ?? 999;\n const pb = STATUS_PRIORITY[b.status] ?? 999;\n if (pa !== pb) return pa - pb;\n return compareByName(a, b);\n}\n\nfunction compareByPid(a: AgentInfo, b: AgentInfo): number {\n if (a.pid !== b.pid) return a.pid - b.pid;\n return compareByName(a, b);\n}\n\nfunction compareByLastActive(a: AgentInfo, b: AgentInfo): number {\n const ta = a.lastActive instanceof Date ? a.lastActive.getTime() : new Date(a.lastActive).getTime();\n const tb = b.lastActive instanceof Date ? b.lastActive.getTime() : new Date(b.lastActive).getTime();\n if (tb !== ta) return tb - ta; // most recent first\n return compareByName(a, b);\n}\n\nexport function sortAgents(agents: AgentInfo[], by: AgentSortKey): AgentInfo[] {\n const copy = agents.slice();\n switch (by) {\n case 'name':\n copy.sort(compareByName);\n break;\n case 'status':\n copy.sort(compareByStatus);\n break;\n case 'pid':\n copy.sort(compareByPid);\n break;\n case 'lastActive':\n copy.sort(compareByLastActive);\n break;\n }\n return copy;\n}\n"],"names":["AgentStatus","STATUS_PRIORITY","WAITING","RUNNING","IDLE","UNKNOWN","compareByName","a","b","name","localeCompare","compareByStatus","pa","status","pb","compareByPid","pid","compareByLastActive","ta","lastActive","Date","getTime","tb","sortAgents","agents","by","copy","slice","sort"],"mappings":"AAAA,SAASA,WAAW,QAAwB,8BAA8B;AAI1E,MAAMC,kBAA+C;IACjD,CAACD,YAAYE,OAAO,CAAC,EAAE;IACvB,CAACF,YAAYG,OAAO,CAAC,EAAE;IACvB,CAACH,YAAYI,IAAI,CAAC,EAAE;IACpB,CAACJ,YAAYK,OAAO,CAAC,EAAE;AAC3B;AAEA,SAASC,cAAcC,CAAY,EAAEC,CAAY;IAC7C,OAAOD,EAAEE,IAAI,CAACC,aAAa,CAACF,EAAEC,IAAI;AACtC;AAEA,SAASE,gBAAgBJ,CAAY,EAAEC,CAAY;IAC/C,MAAMI,KAAKX,eAAe,CAACM,EAAEM,MAAM,CAAC,IAAI;IACxC,MAAMC,KAAKb,eAAe,CAACO,EAAEK,MAAM,CAAC,IAAI;IACxC,IAAID,OAAOE,IAAI,OAAOF,KAAKE;IAC3B,OAAOR,cAAcC,GAAGC;AAC5B;AAEA,SAASO,aAAaR,CAAY,EAAEC,CAAY;IAC5C,IAAID,EAAES,GAAG,KAAKR,EAAEQ,GAAG,EAAE,OAAOT,EAAES,GAAG,GAAGR,EAAEQ,GAAG;IACzC,OAAOV,cAAcC,GAAGC;AAC5B;AAEA,SAASS,oBAAoBV,CAAY,EAAEC,CAAY;IACnD,MAAMU,KAAKX,EAAEY,UAAU,YAAYC,OAAOb,EAAEY,UAAU,CAACE,OAAO,KAAK,IAAID,KAAKb,EAAEY,UAAU,EAAEE,OAAO;IACjG,MAAMC,KAAKd,EAAEW,UAAU,YAAYC,OAAOZ,EAAEW,UAAU,CAACE,OAAO,KAAK,IAAID,KAAKZ,EAAEW,UAAU,EAAEE,OAAO;IACjG,IAAIC,OAAOJ,IAAI,OAAOI,KAAKJ,IAAI,oBAAoB;IACnD,OAAOZ,cAAcC,GAAGC;AAC5B;AAEA,OAAO,SAASe,WAAWC,MAAmB,EAAEC,EAAgB;IAC5D,MAAMC,OAAOF,OAAOG,KAAK;IACzB,OAAQF;QACJ,KAAK;YACDC,KAAKE,IAAI,CAACtB;YACV;QACJ,KAAK;YACDoB,KAAKE,IAAI,CAACjB;YACV;QACJ,KAAK;YACDe,KAAKE,IAAI,CAACb;YACV;QACJ,KAAK;YACDW,KAAKE,IAAI,CAACX;YACV;IACR;IACA,OAAOS;AACX"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-devkit/agent-manager",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "description": "Standalone agent detection and management utilities for AI DevKit",
6
6
  "main": "dist/index.js",
@@ -12,7 +12,8 @@
12
12
  }
13
13
  },
14
14
  "scripts": {
15
- "build": "tsc",
15
+ "build": "swc src -d dist --strip-leading-paths && tsc --emitDeclarationOnly",
16
+ "dev": "swc src -d dist --strip-leading-paths --watch",
16
17
  "test": "vitest run",
17
18
  "test:watch": "vitest",
18
19
  "test:coverage": "vitest run --coverage",
@@ -29,17 +30,20 @@
29
30
  "author": "",
30
31
  "license": "MIT",
31
32
  "dependencies": {
32
- "better-sqlite3": "^12.6.2"
33
+ "better-sqlite3": "^12.6.2",
34
+ "uuid": "14.0.0"
33
35
  },
34
36
  "devDependencies": {
37
+ "@swc/cli": "^0.8.1",
38
+ "@swc/core": "^1.10.0",
35
39
  "@types/better-sqlite3": "^7.6.11",
36
40
  "@types/node": "^20.11.5",
37
- "@typescript-eslint/eslint-plugin": "^6.19.1",
38
- "@typescript-eslint/parser": "^6.19.1",
39
- "@vitest/coverage-v8": "^2.1.0",
41
+ "@typescript-eslint/eslint-plugin": "^8.60.1",
42
+ "@typescript-eslint/parser": "^8.60.1",
43
+ "@vitest/coverage-v8": "^4.1.8",
40
44
  "eslint": "^8.56.0",
41
45
  "typescript": "^5.5.0",
42
- "vitest": "^2.1.0"
46
+ "vitest": "^4.1.8"
43
47
  },
44
48
  "engines": {
45
49
  "node": ">=20.20.0"
@@ -758,6 +758,7 @@ describe('ClaudeCodeAdapter', () => {
758
758
 
759
759
  beforeEach(() => {
760
760
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-test-'));
761
+ (adapter as any).projectsDir = path.join(tmpDir, 'projects');
761
762
  });
762
763
 
763
764
  afterEach(() => {
@@ -1073,6 +1074,7 @@ describe('ClaudeCodeAdapter', () => {
1073
1074
 
1074
1075
  beforeEach(() => {
1075
1076
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-test-'));
1077
+ (adapter as any).projectsDir = path.join(tmpDir, 'projects');
1076
1078
  });
1077
1079
 
1078
1080
  afterEach(() => {
@@ -248,6 +248,100 @@ describe('CodexAdapter', () => {
248
248
 
249
249
  fs.rmSync(tmpDir, { recursive: true, force: true });
250
250
  });
251
+
252
+ it('should match resumed sessions by id even when the JSONL predates the process', async () => {
253
+ const sessionId = '019eabed-4079-7071-9531-b853ddd9914e';
254
+ const processes: ProcessInfo[] = [
255
+ {
256
+ pid: 88018,
257
+ command: `codex resume ${sessionId}`,
258
+ cwd: '/repo-a',
259
+ tty: 'ttys001',
260
+ startTime: new Date('2026-06-10T12:00:00.000Z'),
261
+ },
262
+ ];
263
+ mockedListAgentProcesses.mockReturnValue(processes);
264
+ mockedEnrichProcesses.mockReturnValue(processes);
265
+
266
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-resume-'));
267
+ const sessionsDir = path.join(tmpDir, 'sessions');
268
+ const dateDir = path.join(sessionsDir, '2026', '06', '09');
269
+ fs.mkdirSync(dateDir, { recursive: true });
270
+
271
+ const recentTs = new Date().toISOString();
272
+ const sessionFile = path.join(dateDir, `rollout-2026-06-09T12-28-33-${sessionId}.jsonl`);
273
+ fs.writeFileSync(sessionFile, [
274
+ JSON.stringify({ type: 'session_meta', payload: { id: sessionId, timestamp: recentTs, cwd: '/repo-a' } }),
275
+ JSON.stringify({ type: 'event', timestamp: recentTs, payload: { type: 'agent_message', message: 'resumed codex conversation' } }),
276
+ ].join('\n'));
277
+
278
+ (adapter as any).codexSessionsDir = sessionsDir;
279
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue([]);
280
+
281
+ const agents = await adapter.detectAgents();
282
+
283
+ expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled();
284
+ expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled();
285
+ expect(agents).toHaveLength(1);
286
+ expect(agents[0]).toMatchObject({
287
+ type: 'codex',
288
+ pid: 88018,
289
+ sessionId,
290
+ projectPath: '/repo-a',
291
+ sessionFilePath: sessionFile,
292
+ });
293
+ expect(agents[0].summary).toBe('resumed codex conversation');
294
+
295
+ fs.rmSync(tmpDir, { recursive: true, force: true });
296
+ });
297
+
298
+ it('should fall back to process-only when a resumed session becomes unreadable after direct matching', async () => {
299
+ const sessionId = '019eabed-4079-7071-9531-b853ddd9914e';
300
+ const processes: ProcessInfo[] = [
301
+ {
302
+ pid: 88019,
303
+ command: `codex resume ${sessionId}`,
304
+ cwd: '/repo-a',
305
+ tty: 'ttys001',
306
+ startTime: new Date('2026-06-10T12:00:00.000Z'),
307
+ },
308
+ ];
309
+ mockedListAgentProcesses.mockReturnValue(processes);
310
+ mockedEnrichProcesses.mockReturnValue(processes);
311
+
312
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-resume-gone-'));
313
+ const sessionsDir = path.join(tmpDir, 'sessions');
314
+ const dateDir = path.join(sessionsDir, '2026', '06', '09');
315
+ fs.mkdirSync(dateDir, { recursive: true });
316
+
317
+ const sessionFile = path.join(dateDir, `rollout-2026-06-09T12-28-33-${sessionId}.jsonl`);
318
+
319
+ (adapter as any).codexSessionsDir = sessionsDir;
320
+ const findSpy = vi.spyOn(adapter as any, 'findSessionFileById').mockReturnValue({
321
+ sessionId,
322
+ filePath: sessionFile,
323
+ projectDir: dateDir,
324
+ birthtimeMs: Date.now(),
325
+ resolvedCwd: '/repo-a',
326
+ });
327
+ mockedBatchGetSessionFileBirthtimes.mockReturnValue([]);
328
+
329
+ try {
330
+ const agents = await adapter.detectAgents();
331
+
332
+ expect(agents).toHaveLength(1);
333
+ expect(agents[0]).toMatchObject({
334
+ type: 'codex',
335
+ pid: 88019,
336
+ sessionId: 'pid-88019',
337
+ projectPath: '/repo-a',
338
+ });
339
+ expect(agents[0].sessionFilePath).toBeUndefined();
340
+ } finally {
341
+ findSpy.mockRestore();
342
+ fs.rmSync(tmpDir, { recursive: true, force: true });
343
+ }
344
+ });
251
345
  });
252
346
 
253
347
  describe('detectAgents — registry cache short-circuit', () => {
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Tests for CopilotAdapter
3
+ */
4
+
5
+ import type { MockedFunction } from 'vitest';
6
+ import * as fs from 'fs';
7
+ import * as os from 'os';
8
+ import * as path from 'path';
9
+
10
+ import { CopilotAdapter } from '../../adapters/CopilotAdapter.js';
11
+ import type { ProcessInfo } from '../../adapters/AgentAdapter.js';
12
+ import { AgentStatus } from '../../adapters/AgentAdapter.js';
13
+ import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
14
+ import { generateAgentName } from '../../utils/matching.js';
15
+
16
+ vi.mock('../../utils/process.js', () => ({
17
+ listAgentProcesses: vi.fn(),
18
+ enrichProcesses: vi.fn(),
19
+ }));
20
+
21
+ vi.mock('../../utils/matching.js', () => ({
22
+ generateAgentName: vi.fn(),
23
+ }));
24
+
25
+ const mockedListAgentProcesses = listAgentProcesses as MockedFunction<typeof listAgentProcesses>;
26
+ const mockedEnrichProcesses = enrichProcesses as MockedFunction<typeof enrichProcesses>;
27
+ const mockedGenerateAgentName = generateAgentName as MockedFunction<typeof generateAgentName>;
28
+
29
+ describe('CopilotAdapter', () => {
30
+ let adapter: CopilotAdapter;
31
+ let tmpDir: string;
32
+ let sessionStateDir: string;
33
+
34
+ beforeEach(() => {
35
+ adapter = new CopilotAdapter();
36
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'copilot-test-'));
37
+ sessionStateDir = path.join(tmpDir, 'session-state');
38
+ fs.mkdirSync(sessionStateDir, { recursive: true });
39
+ (adapter as any).sessionStateDir = sessionStateDir;
40
+
41
+ mockedListAgentProcesses.mockReset();
42
+ mockedEnrichProcesses.mockReset();
43
+ mockedGenerateAgentName.mockReset();
44
+ mockedEnrichProcesses.mockImplementation((procs) => procs);
45
+ mockedGenerateAgentName.mockImplementation((cwd, pid) => `${path.basename(cwd) || 'unknown'} (${pid})`);
46
+ });
47
+
48
+ afterEach(() => {
49
+ fs.rmSync(tmpDir, { recursive: true, force: true });
50
+ });
51
+
52
+ function writeSession(
53
+ sessionId: string,
54
+ options: {
55
+ events?: Array<object | string>;
56
+ workspace?: Record<string, string>;
57
+ lockPid?: number | string;
58
+ },
59
+ ): string {
60
+ const sessionDir = path.join(sessionStateDir, sessionId);
61
+ fs.mkdirSync(sessionDir, { recursive: true });
62
+
63
+ if (options.events) {
64
+ const lines = options.events.map((entry) => (
65
+ typeof entry === 'string' ? entry : JSON.stringify(entry)
66
+ ));
67
+ fs.writeFileSync(path.join(sessionDir, 'events.jsonl'), lines.join('\n'));
68
+ }
69
+
70
+ if (options.workspace) {
71
+ const lines = Object.entries(options.workspace).map(([key, value]) => `${key}: ${value}`);
72
+ fs.writeFileSync(path.join(sessionDir, 'workspace.yaml'), lines.join('\n'));
73
+ }
74
+
75
+ if (options.lockPid !== undefined) {
76
+ fs.writeFileSync(path.join(sessionDir, `inuse.${options.lockPid}.lock`), '');
77
+ }
78
+
79
+ return sessionDir;
80
+ }
81
+
82
+ function sessionStart(sessionId: string, cwd: string, startTime = '2026-06-09T09:50:00.000Z'): object {
83
+ return {
84
+ type: 'session.start',
85
+ data: {
86
+ sessionId,
87
+ startTime,
88
+ context: {
89
+ cwd,
90
+ gitRoot: cwd,
91
+ branch: 'main',
92
+ },
93
+ },
94
+ timestamp: startTime,
95
+ };
96
+ }
97
+
98
+ describe('initialization', () => {
99
+ it('exposes copilot type', () => {
100
+ expect(adapter.type).toBe('copilot');
101
+ });
102
+ });
103
+
104
+ describe('canHandle', () => {
105
+ it('returns true for copilot commands', () => {
106
+ expect(adapter.canHandle({ pid: 1, command: 'copilot', cwd: '/repo', tty: 'ttys001' })).toBe(true);
107
+ });
108
+
109
+ it('returns true for full-path Homebrew copilot commands', () => {
110
+ expect(adapter.canHandle({
111
+ pid: 2,
112
+ command: '/opt/homebrew/Caskroom/copilot-cli/1.0.60/copilot',
113
+ cwd: '/repo',
114
+ tty: 'ttys002',
115
+ })).toBe(true);
116
+ });
117
+
118
+ it('returns true for copilot.exe commands', () => {
119
+ expect(adapter.canHandle({ pid: 3, command: '/usr/bin/copilot.exe', cwd: '/repo', tty: 'ttys003' })).toBe(true);
120
+ });
121
+
122
+ it('returns false when copilot appears only in an argument', () => {
123
+ expect(adapter.canHandle({
124
+ pid: 4,
125
+ command: 'node /repo/copilot-plugin/index.js',
126
+ cwd: '/repo',
127
+ tty: 'ttys004',
128
+ })).toBe(false);
129
+ });
130
+ });
131
+
132
+ describe('detectAgents', () => {
133
+ it('returns empty list when no copilot process is running', async () => {
134
+ mockedListAgentProcesses.mockReturnValue([]);
135
+
136
+ const agents = await adapter.detectAgents();
137
+
138
+ expect(agents).toEqual([]);
139
+ expect(mockedListAgentProcesses).toHaveBeenCalledWith('copilot');
140
+ });
141
+
142
+ it('maps matching inuse lock and events to an active agent', async () => {
143
+ const processes: ProcessInfo[] = [
144
+ { pid: 14096, command: '/opt/homebrew/Caskroom/copilot-cli/1.0.60/copilot', cwd: '/repo', tty: 'ttys001' },
145
+ ];
146
+ mockedListAgentProcesses.mockReturnValue(processes);
147
+ mockedEnrichProcesses.mockReturnValue(processes);
148
+ writeSession('sess-a', {
149
+ lockPid: 14096,
150
+ events: [
151
+ sessionStart('sess-a', '/repo', '2026-06-09T09:50:00.000Z'),
152
+ { type: 'user.message', data: { content: 'Build the Copilot adapter' }, timestamp: new Date().toISOString() },
153
+ { type: 'assistant.message', data: { content: 'I will inspect the code.' }, timestamp: new Date().toISOString() },
154
+ ],
155
+ workspace: {
156
+ id: 'sess-a',
157
+ cwd: '/fallback',
158
+ name: 'Fallback Name',
159
+ created_at: '2026-06-09T09:49:00.000Z',
160
+ updated_at: '2026-06-09T09:51:00.000Z',
161
+ },
162
+ });
163
+
164
+ const agents = await adapter.detectAgents();
165
+
166
+ expect(agents).toHaveLength(1);
167
+ expect(agents[0]).toMatchObject({
168
+ type: 'copilot',
169
+ status: AgentStatus.WAITING,
170
+ pid: 14096,
171
+ projectPath: '/repo',
172
+ sessionId: 'sess-a',
173
+ summary: 'Build the Copilot adapter',
174
+ sessionFilePath: path.join(sessionStateDir, 'sess-a', 'events.jsonl'),
175
+ });
176
+ });
177
+
178
+ it('ignores invalid and unmatched lock PIDs', async () => {
179
+ const processes: ProcessInfo[] = [
180
+ { pid: 100, command: 'copilot', cwd: '/repo', tty: 'ttys001' },
181
+ ];
182
+ mockedListAgentProcesses.mockReturnValue(processes);
183
+ mockedEnrichProcesses.mockReturnValue(processes);
184
+ writeSession('invalid', {
185
+ lockPid: 'not-a-pid',
186
+ events: [sessionStart('invalid', '/invalid')],
187
+ });
188
+ writeSession('unmatched', {
189
+ lockPid: 999999,
190
+ events: [sessionStart('unmatched', '/unmatched')],
191
+ });
192
+
193
+ const agents = await adapter.detectAgents();
194
+
195
+ expect(agents).toHaveLength(1);
196
+ expect(agents[0]).toMatchObject({
197
+ pid: 100,
198
+ sessionId: 'pid-100',
199
+ summary: 'Copilot process running',
200
+ });
201
+ });
202
+
203
+ it('falls back to process-only agent when no session lock matches', async () => {
204
+ const processes: ProcessInfo[] = [
205
+ { pid: 200, command: 'copilot', cwd: '/repo-b', tty: 'ttys002' },
206
+ ];
207
+ mockedListAgentProcesses.mockReturnValue(processes);
208
+ mockedEnrichProcesses.mockReturnValue(processes);
209
+
210
+ const agents = await adapter.detectAgents();
211
+
212
+ expect(agents).toHaveLength(1);
213
+ expect(agents[0]).toMatchObject({
214
+ type: 'copilot',
215
+ status: AgentStatus.RUNNING,
216
+ pid: 200,
217
+ projectPath: '/repo-b',
218
+ sessionId: 'pid-200',
219
+ summary: 'Copilot process running',
220
+ });
221
+ });
222
+
223
+ it('does not add duplicate process-only agent for wrapper process in the same terminal', async () => {
224
+ const processes: ProcessInfo[] = [
225
+ { pid: 14095, command: 'copilot', cwd: '/repo', tty: 'ttys001' },
226
+ { pid: 14096, command: '/opt/homebrew/Caskroom/copilot-cli/1.0.60/copilot', cwd: '/repo', tty: 'ttys001' },
227
+ ];
228
+ mockedListAgentProcesses.mockReturnValue(processes);
229
+ mockedEnrichProcesses.mockReturnValue(processes);
230
+ writeSession('sess-wrapper', {
231
+ lockPid: 14096,
232
+ events: [
233
+ sessionStart('sess-wrapper', '/repo', '2026-06-09T09:50:00.000Z'),
234
+ { type: 'user.message', data: { content: 'hello' }, timestamp: new Date().toISOString() },
235
+ ],
236
+ });
237
+
238
+ const agents = await adapter.detectAgents();
239
+
240
+ expect(agents).toHaveLength(1);
241
+ expect(agents[0]).toMatchObject({
242
+ pid: 14096,
243
+ sessionId: 'sess-wrapper',
244
+ });
245
+ });
246
+
247
+ it('uses workspace metadata when events are missing', async () => {
248
+ const processes: ProcessInfo[] = [
249
+ { pid: 300, command: 'copilot', cwd: '/proc-cwd', tty: 'ttys003' },
250
+ ];
251
+ mockedListAgentProcesses.mockReturnValue(processes);
252
+ mockedEnrichProcesses.mockReturnValue(processes);
253
+ writeSession('workspace-only', {
254
+ lockPid: 300,
255
+ workspace: {
256
+ id: 'workspace-only',
257
+ cwd: '/workspace-cwd',
258
+ name: 'Workspace Session',
259
+ created_at: '2026-06-09T09:00:00.000Z',
260
+ updated_at: '2026-06-09T09:10:00.000Z',
261
+ },
262
+ });
263
+
264
+ const agents = await adapter.detectAgents();
265
+
266
+ expect(agents).toHaveLength(1);
267
+ expect(agents[0]).toMatchObject({
268
+ pid: 300,
269
+ projectPath: '/workspace-cwd',
270
+ sessionId: 'workspace-only',
271
+ summary: 'Workspace Session',
272
+ });
273
+ });
274
+ });
275
+
276
+ describe('getConversation', () => {
277
+ it('parses user and assistant message events', () => {
278
+ const sessionDir = writeSession('conv', {
279
+ events: [
280
+ sessionStart('conv', '/repo'),
281
+ { type: 'user.message', data: { content: 'hello' }, timestamp: '2026-06-09T09:50:01.000Z' },
282
+ { type: 'assistant.message', data: { content: 'Hi there' }, timestamp: '2026-06-09T09:50:02.000Z' },
283
+ ],
284
+ });
285
+
286
+ const messages = adapter.getConversation(path.join(sessionDir, 'events.jsonl'));
287
+
288
+ expect(messages).toEqual([
289
+ { role: 'user', content: 'hello', timestamp: '2026-06-09T09:50:01.000Z' },
290
+ { role: 'assistant', content: 'Hi there', timestamp: '2026-06-09T09:50:02.000Z' },
291
+ ]);
292
+ });
293
+
294
+ it('includes system/info/warning messages only in verbose mode', () => {
295
+ const sessionDir = writeSession('verbose', {
296
+ events: [
297
+ { type: 'session.warning', data: { message: 'MCP failed' }, timestamp: '2026-06-09T09:50:01.000Z' },
298
+ { type: 'tool.execution_complete', data: { result: { content: 'Tool result' } }, timestamp: '2026-06-09T09:50:02.000Z' },
299
+ ],
300
+ });
301
+ const filePath = path.join(sessionDir, 'events.jsonl');
302
+
303
+ expect(adapter.getConversation(filePath)).toEqual([]);
304
+ expect(adapter.getConversation(filePath, { verbose: true })).toEqual([
305
+ { role: 'system', content: 'MCP failed', timestamp: '2026-06-09T09:50:01.000Z' },
306
+ { role: 'system', content: 'Tool result', timestamp: '2026-06-09T09:50:02.000Z' },
307
+ ]);
308
+ });
309
+
310
+ it('skips malformed JSONL lines and missing text', () => {
311
+ const sessionDir = writeSession('malformed', {
312
+ events: [
313
+ 'not json',
314
+ { type: 'user.message', data: { content: 'valid' }, timestamp: '2026-06-09T09:50:01.000Z' },
315
+ { type: 'assistant.message', data: {}, timestamp: '2026-06-09T09:50:02.000Z' },
316
+ ],
317
+ });
318
+
319
+ const messages = adapter.getConversation(path.join(sessionDir, 'events.jsonl'));
320
+
321
+ expect(messages).toEqual([
322
+ { role: 'user', content: 'valid', timestamp: '2026-06-09T09:50:01.000Z' },
323
+ ]);
324
+ });
325
+
326
+ it('returns empty array for missing file', () => {
327
+ expect(adapter.getConversation(path.join(tmpDir, 'missing.jsonl'))).toEqual([]);
328
+ });
329
+ });
330
+
331
+ describe('listSessions', () => {
332
+ it('returns historical sessions without active locks', async () => {
333
+ writeSession('history-a', {
334
+ events: [
335
+ sessionStart('history-a', '/repo-a', '2026-06-09T09:00:00.000Z'),
336
+ { type: 'user.message', data: { content: 'first user' }, timestamp: '2026-06-09T09:00:01.000Z' },
337
+ { type: 'assistant.message', data: { content: 'answer' }, timestamp: '2026-06-09T09:00:02.000Z' },
338
+ ],
339
+ });
340
+ writeSession('history-b', {
341
+ events: [
342
+ sessionStart('history-b', '/repo-b', '2026-06-09T10:00:00.000Z'),
343
+ { type: 'user.message', data: { content: 'other user' }, timestamp: '2026-06-09T10:00:01.000Z' },
344
+ ],
345
+ });
346
+
347
+ const sessions = await adapter.listSessions();
348
+
349
+ expect(sessions).toHaveLength(2);
350
+ const byId = Object.fromEntries(sessions.map((session) => [session.sessionId, session]));
351
+ expect(byId['history-a']).toMatchObject({
352
+ type: 'copilot',
353
+ cwd: '/repo-a',
354
+ firstUserMessage: 'first user',
355
+ sessionFilePath: path.join(sessionStateDir, 'history-a', 'events.jsonl'),
356
+ });
357
+ expect(byId['history-b']).toMatchObject({
358
+ type: 'copilot',
359
+ cwd: '/repo-b',
360
+ firstUserMessage: 'other user',
361
+ });
362
+ });
363
+
364
+ it('applies strict cwd filter', async () => {
365
+ writeSession('keep', { events: [sessionStart('keep', '/repo')] });
366
+ writeSession('drop', { events: [sessionStart('drop', '/other')] });
367
+
368
+ const sessions = await adapter.listSessions({ cwd: '/repo' });
369
+
370
+ expect(sessions).toHaveLength(1);
371
+ expect(sessions[0].sessionId).toBe('keep');
372
+ });
373
+
374
+ it('uses workspace fallback for historical sessions without events', async () => {
375
+ writeSession('workspace-history', {
376
+ workspace: {
377
+ id: 'workspace-history',
378
+ cwd: '/workspace-repo',
379
+ name: 'Workspace History',
380
+ created_at: '2026-06-09T08:00:00.000Z',
381
+ updated_at: '2026-06-09T08:10:00.000Z',
382
+ },
383
+ });
384
+
385
+ const sessions = await adapter.listSessions();
386
+
387
+ expect(sessions).toHaveLength(1);
388
+ expect(sessions[0]).toMatchObject({
389
+ type: 'copilot',
390
+ sessionId: 'workspace-history',
391
+ cwd: '/workspace-repo',
392
+ firstUserMessage: '',
393
+ sessionFilePath: path.join(sessionStateDir, 'workspace-history', 'events.jsonl'),
394
+ });
395
+ });
396
+
397
+ it('skips session directories without events or workspace metadata', async () => {
398
+ fs.mkdirSync(path.join(sessionStateDir, 'empty'), { recursive: true });
399
+
400
+ const sessions = await adapter.listSessions();
401
+
402
+ expect(sessions).toEqual([]);
403
+ });
404
+
405
+ it('returns empty when session-state directory does not exist', async () => {
406
+ (adapter as any).sessionStateDir = path.join(tmpDir, 'missing');
407
+
408
+ await expect(adapter.listSessions()).resolves.toEqual([]);
409
+ });
410
+ });
411
+ });