@ai-devkit/agent-manager 0.15.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 (112) hide show
  1. package/.swcrc +27 -0
  2. package/dist/AgentManager.d.ts +4 -0
  3. package/dist/AgentManager.d.ts.map +1 -1
  4. package/dist/AgentManager.js +97 -64
  5. package/dist/AgentManager.js.map +1 -1
  6. package/dist/__tests__/AgentManager.test.js +564 -0
  7. package/dist/__tests__/AgentManager.test.js.map +1 -0
  8. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js +2157 -0
  9. package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js.map +1 -0
  10. package/dist/__tests__/adapters/CodexAdapter.test.js +1354 -0
  11. package/dist/__tests__/adapters/CodexAdapter.test.js.map +1 -0
  12. package/dist/__tests__/adapters/CopilotAdapter.test.js +500 -0
  13. package/dist/__tests__/adapters/CopilotAdapter.test.js.map +1 -0
  14. package/dist/__tests__/adapters/GeminiCliAdapter.test.js +1229 -0
  15. package/dist/__tests__/adapters/GeminiCliAdapter.test.js.map +1 -0
  16. package/dist/__tests__/adapters/OpenCodeAdapter.test.js +626 -0
  17. package/dist/__tests__/adapters/OpenCodeAdapter.test.js.map +1 -0
  18. package/dist/__tests__/terminal/TmuxManager.test.js +159 -0
  19. package/dist/__tests__/terminal/TmuxManager.test.js.map +1 -0
  20. package/dist/__tests__/terminal/TtyWriter.test.js +162 -0
  21. package/dist/__tests__/terminal/TtyWriter.test.js.map +1 -0
  22. package/dist/__tests__/utils/AgentRegistry.test.js +282 -0
  23. package/dist/__tests__/utils/AgentRegistry.test.js.map +1 -0
  24. package/dist/__tests__/utils/ClaudeSessionParser.test.js +312 -0
  25. package/dist/__tests__/utils/ClaudeSessionParser.test.js.map +1 -0
  26. package/dist/__tests__/utils/matching.test.js +324 -0
  27. package/dist/__tests__/utils/matching.test.js.map +1 -0
  28. package/dist/__tests__/utils/process.test.js +177 -0
  29. package/dist/__tests__/utils/process.test.js.map +1 -0
  30. package/dist/__tests__/utils/session.test.js +158 -0
  31. package/dist/__tests__/utils/session.test.js.map +1 -0
  32. package/dist/adapters/AgentAdapter.d.ts +1 -1
  33. package/dist/adapters/AgentAdapter.d.ts.map +1 -1
  34. package/dist/adapters/AgentAdapter.js +8 -7
  35. package/dist/adapters/AgentAdapter.js.map +1 -1
  36. package/dist/adapters/ClaudeCodeAdapter.js +66 -77
  37. package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
  38. package/dist/adapters/CodexAdapter.d.ts +13 -1
  39. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  40. package/dist/adapters/CodexAdapter.js +208 -132
  41. package/dist/adapters/CodexAdapter.js.map +1 -1
  42. package/dist/adapters/CopilotAdapter.d.ts +44 -0
  43. package/dist/adapters/CopilotAdapter.d.ts.map +1 -0
  44. package/dist/adapters/CopilotAdapter.js +314 -0
  45. package/dist/adapters/CopilotAdapter.js.map +1 -0
  46. package/dist/adapters/GeminiCliAdapter.d.ts +6 -1
  47. package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
  48. package/dist/adapters/GeminiCliAdapter.js +160 -149
  49. package/dist/adapters/GeminiCliAdapter.js.map +1 -1
  50. package/dist/adapters/OpenCodeAdapter.js +60 -69
  51. package/dist/adapters/OpenCodeAdapter.js.map +1 -1
  52. package/dist/adapters/index.d.ts +1 -0
  53. package/dist/adapters/index.d.ts.map +1 -1
  54. package/dist/adapters/index.js +2 -0
  55. package/dist/adapters/index.js.map +1 -1
  56. package/dist/index.d.ts +6 -0
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +5 -0
  59. package/dist/index.js.map +1 -1
  60. package/dist/terminal/TerminalFocusManager.js +62 -49
  61. package/dist/terminal/TerminalFocusManager.js.map +1 -1
  62. package/dist/terminal/TmuxManager.d.ts +28 -0
  63. package/dist/terminal/TmuxManager.d.ts.map +1 -0
  64. package/dist/terminal/TmuxManager.js +137 -0
  65. package/dist/terminal/TmuxManager.js.map +1 -0
  66. package/dist/terminal/TtyWriter.js +37 -16
  67. package/dist/terminal/TtyWriter.js.map +1 -1
  68. package/dist/terminal/index.js +1 -0
  69. package/dist/terminal/index.js.map +1 -1
  70. package/dist/utils/AgentRegistry.d.ts +35 -0
  71. package/dist/utils/AgentRegistry.d.ts.map +1 -0
  72. package/dist/utils/AgentRegistry.js +123 -0
  73. package/dist/utils/AgentRegistry.js.map +1 -0
  74. package/dist/utils/ClaudeSessionParser.d.ts.map +1 -1
  75. package/dist/utils/ClaudeSessionParser.js +62 -93
  76. package/dist/utils/ClaudeSessionParser.js.map +1 -1
  77. package/dist/utils/agents.d.ts +15 -0
  78. package/dist/utils/agents.d.ts.map +1 -0
  79. package/dist/utils/agents.js +41 -0
  80. package/dist/utils/agents.js.map +1 -0
  81. package/dist/utils/applescript.js +3 -6
  82. package/dist/utils/applescript.js.map +1 -1
  83. package/dist/utils/index.js +1 -0
  84. package/dist/utils/index.js.map +1 -1
  85. package/dist/utils/matching.js +21 -29
  86. package/dist/utils/matching.js.map +1 -1
  87. package/dist/utils/process.js +77 -56
  88. package/dist/utils/process.js.map +1 -1
  89. package/dist/utils/session.js +20 -34
  90. package/dist/utils/session.js.map +1 -1
  91. package/dist/utils/sortAgents.js +6 -8
  92. package/dist/utils/sortAgents.js.map +1 -1
  93. package/package.json +11 -7
  94. package/src/AgentManager.ts +40 -0
  95. package/src/__tests__/AgentManager.test.ts +135 -0
  96. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +3 -2
  97. package/src/__tests__/adapters/CodexAdapter.test.ts +199 -0
  98. package/src/__tests__/adapters/CopilotAdapter.test.ts +411 -0
  99. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +185 -1
  100. package/src/__tests__/terminal/TmuxManager.test.ts +175 -0
  101. package/src/__tests__/utils/AgentRegistry.test.ts +223 -0
  102. package/src/__tests__/utils/ClaudeSessionParser.test.ts +71 -0
  103. package/src/adapters/AgentAdapter.ts +1 -1
  104. package/src/adapters/CodexAdapter.ts +155 -8
  105. package/src/adapters/CopilotAdapter.ts +453 -0
  106. package/src/adapters/GeminiCliAdapter.ts +84 -6
  107. package/src/adapters/index.ts +1 -0
  108. package/src/index.ts +7 -0
  109. package/src/terminal/TmuxManager.ts +118 -0
  110. package/src/utils/AgentRegistry.ts +139 -0
  111. package/src/utils/ClaudeSessionParser.ts +21 -10
  112. package/src/utils/agents.ts +41 -0
@@ -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.15.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"
@@ -12,6 +12,7 @@ import type {
12
12
  ListSessionsOptions,
13
13
  } from './adapters/AgentAdapter.js';
14
14
  import { sortAgents, type AgentSortKey } from './utils/sortAgents.js';
15
+ import { AgentRegistry, type RegistryEntry } from './utils/AgentRegistry.js';
15
16
 
16
17
  export interface ListAgentsOptions {
17
18
  /**
@@ -38,6 +39,11 @@ export interface ListAgentsOptions {
38
39
  */
39
40
  export class AgentManager {
40
41
  private adapters: Map<string, AgentAdapter> = new Map();
42
+ private registry: AgentRegistry;
43
+
44
+ constructor(registry: AgentRegistry = AgentRegistry.default()) {
45
+ this.registry = registry;
46
+ }
41
47
 
42
48
  /**
43
49
  * Register an adapter for a specific agent type
@@ -150,10 +156,37 @@ export class AgentManager {
150
156
  });
151
157
  }
152
158
 
159
+ const preExistingByPid = new Map(this.registry.list().map((e) => [e.pid, e]));
160
+ const entries = allAgents.map((agent) =>
161
+ this.toRegistryEntry(agent, preExistingByPid.get(agent.pid)),
162
+ );
163
+ if (entries.length > 0) this.registry.registerBatch(entries);
164
+ this.registry.prune();
165
+
166
+ for (const agent of allAgents) {
167
+ const entry = preExistingByPid.get(agent.pid);
168
+ if (entry) {
169
+ agent.name = entry.name;
170
+ }
171
+ }
172
+
153
173
  const sortKey: AgentSortKey = options?.sortBy ?? 'status';
154
174
  return sortAgents(allAgents, sortKey);
155
175
  }
156
176
 
177
+ private toRegistryEntry(agent: AgentInfo, existing?: RegistryEntry): RegistryEntry {
178
+ return {
179
+ name: existing?.name ?? agent.name,
180
+ type: agent.type,
181
+ pid: agent.pid,
182
+ tmuxSession: existing?.tmuxSession ?? '',
183
+ cwd: agent.projectPath,
184
+ startedAt: existing?.startedAt ?? new Date().toISOString(),
185
+ sessionId: agent.sessionId,
186
+ sessionFilePath: agent.sessionFilePath ?? '',
187
+ };
188
+ }
189
+
157
190
  /**
158
191
  * List historical sessions across every registered adapter.
159
192
  *
@@ -228,6 +261,13 @@ export class AgentManager {
228
261
  resolveAgent(input: string, agents: AgentInfo[]): AgentInfo | AgentInfo[] | null {
229
262
  if (!input || agents.length === 0) return null;
230
263
 
264
+ // Registry-first: if name is in registry and its PID is in the agent list, return it
265
+ const registryEntry = this.registry.lookup(input);
266
+ if (registryEntry) {
267
+ const registryAgent = agents.find((a) => a.pid === registryEntry.pid);
268
+ if (registryAgent) return registryAgent;
269
+ }
270
+
231
271
  const lowerInput = input.toLowerCase();
232
272
 
233
273
  // 1. Exact match (case-insensitive)
@@ -3,6 +3,9 @@
3
3
  */
4
4
 
5
5
 
6
+ import fs from 'fs';
7
+ import os from 'os';
8
+ import path from 'path';
6
9
  import { AgentManager } from '../AgentManager.js';
7
10
  import type {
8
11
  AgentAdapter,
@@ -12,6 +15,7 @@ import type {
12
15
  SessionSummary,
13
16
  } from '../adapters/AgentAdapter.js';
14
17
  import { AgentStatus } from '../adapters/AgentAdapter.js';
18
+ import { AgentRegistry, type RegistryEntry } from '../utils/AgentRegistry.js';
15
19
 
16
20
  // Mock adapter for testing
17
21
  class MockAdapter implements AgentAdapter {
@@ -240,6 +244,137 @@ describe('AgentManager', () => {
240
244
  });
241
245
  });
242
246
 
247
+ describe('listAgents — registry persistence', () => {
248
+ let tmpDir: string;
249
+ let regPath: string;
250
+ let registry: AgentRegistry;
251
+ let scopedManager: AgentManager;
252
+
253
+ beforeEach(() => {
254
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-manager-'));
255
+ regPath = path.join(tmpDir, 'agents.json');
256
+ registry = new AgentRegistry(regPath);
257
+ scopedManager = new AgentManager(registry);
258
+ });
259
+
260
+ afterEach(() => {
261
+ fs.rmSync(tmpDir, { recursive: true, force: true });
262
+ });
263
+
264
+ it('persists every detected agent to the registry', async () => {
265
+ scopedManager.registerAdapter(new MockAdapter('claude', [
266
+ createMockAgent({
267
+ name: 'a',
268
+ pid: process.pid,
269
+ sessionId: 'sid-a',
270
+ sessionFilePath: '/path/a.jsonl',
271
+ projectPath: '/cwd/a',
272
+ }),
273
+ ]));
274
+
275
+ await scopedManager.listAgents();
276
+
277
+ const entries = registry.list();
278
+ expect(entries).toHaveLength(1);
279
+ expect(entries[0]).toMatchObject({
280
+ name: 'a',
281
+ type: 'claude',
282
+ pid: process.pid,
283
+ cwd: '/cwd/a',
284
+ sessionId: 'sid-a',
285
+ sessionFilePath: '/path/a.jsonl',
286
+ tmuxSession: '',
287
+ });
288
+ expect(entries[0].startedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
289
+ });
290
+
291
+ it('prunes entries for dead pids', async () => {
292
+ registry.register({
293
+ name: 'dead',
294
+ type: 'claude',
295
+ pid: 999999,
296
+ tmuxSession: '',
297
+ cwd: '/cwd/dead',
298
+ startedAt: '2026-05-30T00:00:00.000Z',
299
+ sessionId: 'sid-dead',
300
+ sessionFilePath: '/path/dead.jsonl',
301
+ });
302
+
303
+ scopedManager.registerAdapter(new MockAdapter('claude', [
304
+ createMockAgent({ name: 'live', pid: process.pid }),
305
+ ]));
306
+
307
+ await scopedManager.listAgents();
308
+
309
+ const entries = registry.list();
310
+ expect(entries.map((e) => e.name)).toEqual(['live']);
311
+ });
312
+
313
+ it('preserves an existing name (e.g. user-set "merry") across cycles', async () => {
314
+ registry.register({
315
+ name: 'merry',
316
+ type: 'claude',
317
+ pid: process.pid,
318
+ tmuxSession: 'merry',
319
+ cwd: '/cwd/merry',
320
+ startedAt: '2026-05-30T00:00:00.000Z',
321
+ sessionId: 'sid-merry',
322
+ sessionFilePath: '/path/merry.jsonl',
323
+ });
324
+
325
+ scopedManager.registerAdapter(new MockAdapter('claude', [
326
+ createMockAgent({ name: 'default-name', pid: process.pid }),
327
+ ]));
328
+
329
+ const agents = await scopedManager.listAgents();
330
+
331
+ expect(agents[0].name).toBe('merry');
332
+ expect(registry.list()[0].name).toBe('merry');
333
+ expect(registry.list()[0].tmuxSession).toBe('merry');
334
+ expect(registry.list()[0].startedAt).toBe('2026-05-30T00:00:00.000Z');
335
+ });
336
+
337
+ it('writes a fresh startedAt for new entries', async () => {
338
+ const before = new Date().toISOString();
339
+ scopedManager.registerAdapter(new MockAdapter('claude', [
340
+ createMockAgent({ name: 'new', pid: process.pid }),
341
+ ]));
342
+
343
+ await scopedManager.listAgents();
344
+
345
+ const entry = registry.list()[0];
346
+ expect(entry.startedAt >= before).toBe(true);
347
+ });
348
+
349
+ it('batches the write — a single registerBatch call per listAgents', async () => {
350
+ const spy = vi.spyOn(registry, 'registerBatch');
351
+
352
+ scopedManager.registerAdapter(new MockAdapter('claude', [
353
+ createMockAgent({ name: 'a', pid: process.pid }),
354
+ ]));
355
+ scopedManager.registerAdapter(new MockAdapter('codex', [
356
+ createMockAgent({ name: 'b', type: 'codex', pid: process.pid + 1 }),
357
+ ]));
358
+
359
+ await scopedManager.listAgents();
360
+
361
+ expect(spy).toHaveBeenCalledTimes(1);
362
+ expect((spy.mock.calls[0][0] as RegistryEntry[]).map((e) => e.name).sort())
363
+ .toEqual(['a', 'b']);
364
+ });
365
+
366
+ it('skips registerBatch when no agents detected (still calls prune)', async () => {
367
+ const writeSpy = vi.spyOn(registry, 'registerBatch');
368
+ const pruneSpy = vi.spyOn(registry, 'prune');
369
+
370
+ scopedManager.registerAdapter(new MockAdapter('claude', []));
371
+ await scopedManager.listAgents();
372
+
373
+ expect(writeSpy).not.toHaveBeenCalled();
374
+ expect(pruneSpy).toHaveBeenCalledTimes(1);
375
+ });
376
+ });
377
+
243
378
  describe('clear', () => {
244
379
  it('should remove all adapters', () => {
245
380
  manager.registerAdapter(new MockAdapter('claude'));
@@ -427,8 +427,7 @@ describe('ClaudeCodeAdapter', () => {
427
427
 
428
428
  const sessionId = 'wait-session';
429
429
  const jsonlPath = path.join(projDir, `${sessionId}.jsonl`);
430
- // JSONL trails with permission-mode parser would resolve to UNKNOWN.
431
- // PID file's live status must win.
430
+ // PID file's live status must win over JSONL-derived status.
432
431
  fs.writeFileSync(jsonlPath, [
433
432
  JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: '/project/wait', message: { content: '/reddit-commenter' } }),
434
433
  JSON.stringify({ type: 'permission-mode', timestamp: new Date().toISOString(), permissionMode: 'default' }),
@@ -759,6 +758,7 @@ describe('ClaudeCodeAdapter', () => {
759
758
 
760
759
  beforeEach(() => {
761
760
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-test-'));
761
+ (adapter as any).projectsDir = path.join(tmpDir, 'projects');
762
762
  });
763
763
 
764
764
  afterEach(() => {
@@ -1074,6 +1074,7 @@ describe('ClaudeCodeAdapter', () => {
1074
1074
 
1075
1075
  beforeEach(() => {
1076
1076
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-test-'));
1077
+ (adapter as any).projectsDir = path.join(tmpDir, 'projects');
1077
1078
  });
1078
1079
 
1079
1080
  afterEach(() => {
@@ -9,6 +9,7 @@ import * as path from 'path';
9
9
  import { CodexAdapter } from '../../adapters/CodexAdapter.js';
10
10
  import type { ProcessInfo } from '../../adapters/AgentAdapter.js';
11
11
  import { AgentStatus } from '../../adapters/AgentAdapter.js';
12
+ import { AgentRegistry, type RegistryEntry } from '../../utils/AgentRegistry.js';
12
13
  import { listAgentProcesses, enrichProcesses } from '../../utils/process.js';
13
14
  import { batchGetSessionFileBirthtimes } from '../../utils/session.js';
14
15
  import type { SessionFile } from '../../utils/session.js';
@@ -247,6 +248,204 @@ describe('CodexAdapter', () => {
247
248
 
248
249
  fs.rmSync(tmpDir, { recursive: true, force: true });
249
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
+ });
345
+ });
346
+
347
+ describe('detectAgents — registry cache short-circuit', () => {
348
+ let tmpDir: string;
349
+ let regPath: string;
350
+ let registry: AgentRegistry;
351
+ let cachedAdapter: CodexAdapter;
352
+ let sessionFilePath: string;
353
+
354
+ function registerEntry(over: Partial<RegistryEntry> = {}): void {
355
+ registry.register({
356
+ name: 'codex-100',
357
+ type: 'codex',
358
+ pid: 100,
359
+ tmuxSession: '',
360
+ cwd: '/repo-a',
361
+ startedAt: '2026-05-30T00:00:00.000Z',
362
+ sessionId: 'sess-cached',
363
+ sessionFilePath,
364
+ ...over,
365
+ });
366
+ }
367
+
368
+ beforeEach(() => {
369
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-cache-'));
370
+ regPath = path.join(tmpDir, 'agents.json');
371
+ registry = new AgentRegistry(regPath);
372
+ cachedAdapter = new CodexAdapter(registry);
373
+
374
+ const recentTs = new Date().toISOString();
375
+ sessionFilePath = path.join(tmpDir, 'sess-cached.jsonl');
376
+ fs.writeFileSync(sessionFilePath, [
377
+ JSON.stringify({ type: 'session_meta', payload: { id: 'sess-cached', timestamp: recentTs, cwd: '/repo-a' } }),
378
+ JSON.stringify({ type: 'event', timestamp: recentTs, payload: { type: 'token_count', message: 'Hello from cache' } }),
379
+ ].join('\n'));
380
+ });
381
+
382
+ afterEach(() => {
383
+ fs.rmSync(tmpDir, { recursive: true, force: true });
384
+ });
385
+
386
+ it('short-circuits matching when registry has a valid entry', async () => {
387
+ registerEntry();
388
+ const processes: ProcessInfo[] = [
389
+ { pid: 100, command: 'codex', cwd: '/repo-a', tty: 'ttys001', startTime: new Date() },
390
+ ];
391
+ mockedListAgentProcesses.mockReturnValue(processes);
392
+ mockedEnrichProcesses.mockReturnValue(processes);
393
+
394
+ const agents = await cachedAdapter.detectAgents();
395
+
396
+ expect(agents).toHaveLength(1);
397
+ expect(agents[0]).toMatchObject({
398
+ type: 'codex',
399
+ pid: 100,
400
+ sessionId: 'sess-cached',
401
+ sessionFilePath,
402
+ summary: 'Hello from cache',
403
+ });
404
+ expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled();
405
+ expect(mockedBatchGetSessionFileBirthtimes).not.toHaveBeenCalled();
406
+ });
407
+
408
+ it('falls through when no registry entry exists for the pid', async () => {
409
+ const processes: ProcessInfo[] = [
410
+ { pid: 100, command: 'codex', cwd: '/repo-a', tty: 'ttys001', startTime: new Date() },
411
+ ];
412
+ mockedListAgentProcesses.mockReturnValue(processes);
413
+ mockedEnrichProcesses.mockReturnValue(processes);
414
+ (cachedAdapter as any).codexSessionsDir = '/nonexistent';
415
+
416
+ const agents = await cachedAdapter.detectAgents();
417
+
418
+ expect(agents[0].sessionId).toBe('pid-100');
419
+ });
420
+
421
+ it('falls through when registry entry type does not match', async () => {
422
+ registerEntry({ type: 'claude' });
423
+ const processes: ProcessInfo[] = [
424
+ { pid: 100, command: 'codex', cwd: '/repo-a', tty: 'ttys001', startTime: new Date() },
425
+ ];
426
+ mockedListAgentProcesses.mockReturnValue(processes);
427
+ mockedEnrichProcesses.mockReturnValue(processes);
428
+ (cachedAdapter as any).codexSessionsDir = '/nonexistent';
429
+
430
+ const agents = await cachedAdapter.detectAgents();
431
+
432
+ expect(agents[0].sessionId).toBe('pid-100');
433
+ expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled();
434
+ });
435
+
436
+ it('falls through when the cached session file no longer exists', async () => {
437
+ registerEntry({ sessionFilePath: path.join(tmpDir, 'deleted.jsonl') });
438
+ const processes: ProcessInfo[] = [
439
+ { pid: 100, command: 'codex', cwd: '/repo-a', tty: 'ttys001', startTime: new Date() },
440
+ ];
441
+ mockedListAgentProcesses.mockReturnValue(processes);
442
+ mockedEnrichProcesses.mockReturnValue(processes);
443
+ (cachedAdapter as any).codexSessionsDir = '/nonexistent';
444
+
445
+ const agents = await cachedAdapter.detectAgents();
446
+
447
+ expect(agents[0].sessionId).toBe('pid-100');
448
+ });
250
449
  });
251
450
 
252
451
  describe('discoverSessions', () => {