@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.
- package/.swcrc +27 -0
- package/dist/AgentManager.d.ts +4 -0
- package/dist/AgentManager.d.ts.map +1 -1
- package/dist/AgentManager.js +97 -64
- package/dist/AgentManager.js.map +1 -1
- package/dist/__tests__/AgentManager.test.js +564 -0
- package/dist/__tests__/AgentManager.test.js.map +1 -0
- package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js +2157 -0
- package/dist/__tests__/adapters/ClaudeCodeAdapter.test.js.map +1 -0
- package/dist/__tests__/adapters/CodexAdapter.test.js +1354 -0
- package/dist/__tests__/adapters/CodexAdapter.test.js.map +1 -0
- package/dist/__tests__/adapters/CopilotAdapter.test.js +500 -0
- package/dist/__tests__/adapters/CopilotAdapter.test.js.map +1 -0
- package/dist/__tests__/adapters/GeminiCliAdapter.test.js +1229 -0
- package/dist/__tests__/adapters/GeminiCliAdapter.test.js.map +1 -0
- package/dist/__tests__/adapters/OpenCodeAdapter.test.js +626 -0
- package/dist/__tests__/adapters/OpenCodeAdapter.test.js.map +1 -0
- package/dist/__tests__/terminal/TmuxManager.test.js +159 -0
- package/dist/__tests__/terminal/TmuxManager.test.js.map +1 -0
- package/dist/__tests__/terminal/TtyWriter.test.js +162 -0
- package/dist/__tests__/terminal/TtyWriter.test.js.map +1 -0
- package/dist/__tests__/utils/AgentRegistry.test.js +282 -0
- package/dist/__tests__/utils/AgentRegistry.test.js.map +1 -0
- package/dist/__tests__/utils/ClaudeSessionParser.test.js +312 -0
- package/dist/__tests__/utils/ClaudeSessionParser.test.js.map +1 -0
- package/dist/__tests__/utils/matching.test.js +324 -0
- package/dist/__tests__/utils/matching.test.js.map +1 -0
- package/dist/__tests__/utils/process.test.js +177 -0
- package/dist/__tests__/utils/process.test.js.map +1 -0
- package/dist/__tests__/utils/session.test.js +158 -0
- package/dist/__tests__/utils/session.test.js.map +1 -0
- package/dist/adapters/AgentAdapter.d.ts +1 -1
- package/dist/adapters/AgentAdapter.d.ts.map +1 -1
- package/dist/adapters/AgentAdapter.js +8 -7
- package/dist/adapters/AgentAdapter.js.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.js +66 -77
- package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
- package/dist/adapters/CodexAdapter.d.ts +13 -1
- package/dist/adapters/CodexAdapter.d.ts.map +1 -1
- package/dist/adapters/CodexAdapter.js +208 -132
- package/dist/adapters/CodexAdapter.js.map +1 -1
- package/dist/adapters/CopilotAdapter.d.ts +44 -0
- package/dist/adapters/CopilotAdapter.d.ts.map +1 -0
- package/dist/adapters/CopilotAdapter.js +314 -0
- package/dist/adapters/CopilotAdapter.js.map +1 -0
- package/dist/adapters/GeminiCliAdapter.d.ts +6 -1
- package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
- package/dist/adapters/GeminiCliAdapter.js +160 -149
- package/dist/adapters/GeminiCliAdapter.js.map +1 -1
- package/dist/adapters/OpenCodeAdapter.js +60 -69
- package/dist/adapters/OpenCodeAdapter.js.map +1 -1
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +2 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/terminal/TerminalFocusManager.js +62 -49
- package/dist/terminal/TerminalFocusManager.js.map +1 -1
- package/dist/terminal/TmuxManager.d.ts +28 -0
- package/dist/terminal/TmuxManager.d.ts.map +1 -0
- package/dist/terminal/TmuxManager.js +137 -0
- package/dist/terminal/TmuxManager.js.map +1 -0
- package/dist/terminal/TtyWriter.js +37 -16
- package/dist/terminal/TtyWriter.js.map +1 -1
- package/dist/terminal/index.js +1 -0
- package/dist/terminal/index.js.map +1 -1
- package/dist/utils/AgentRegistry.d.ts +35 -0
- package/dist/utils/AgentRegistry.d.ts.map +1 -0
- package/dist/utils/AgentRegistry.js +123 -0
- package/dist/utils/AgentRegistry.js.map +1 -0
- package/dist/utils/ClaudeSessionParser.d.ts.map +1 -1
- package/dist/utils/ClaudeSessionParser.js +62 -93
- package/dist/utils/ClaudeSessionParser.js.map +1 -1
- package/dist/utils/agents.d.ts +15 -0
- package/dist/utils/agents.d.ts.map +1 -0
- package/dist/utils/agents.js +41 -0
- package/dist/utils/agents.js.map +1 -0
- package/dist/utils/applescript.js +3 -6
- package/dist/utils/applescript.js.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/matching.js +21 -29
- package/dist/utils/matching.js.map +1 -1
- package/dist/utils/process.js +77 -56
- package/dist/utils/process.js.map +1 -1
- package/dist/utils/session.js +20 -34
- package/dist/utils/session.js.map +1 -1
- package/dist/utils/sortAgents.js +6 -8
- package/dist/utils/sortAgents.js.map +1 -1
- package/package.json +11 -7
- package/src/AgentManager.ts +40 -0
- package/src/__tests__/AgentManager.test.ts +135 -0
- package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +3 -2
- package/src/__tests__/adapters/CodexAdapter.test.ts +199 -0
- package/src/__tests__/adapters/CopilotAdapter.test.ts +411 -0
- package/src/__tests__/adapters/GeminiCliAdapter.test.ts +185 -1
- package/src/__tests__/terminal/TmuxManager.test.ts +175 -0
- package/src/__tests__/utils/AgentRegistry.test.ts +223 -0
- package/src/__tests__/utils/ClaudeSessionParser.test.ts +71 -0
- package/src/adapters/AgentAdapter.ts +1 -1
- package/src/adapters/CodexAdapter.ts +155 -8
- package/src/adapters/CopilotAdapter.ts +453 -0
- package/src/adapters/GeminiCliAdapter.ts +84 -6
- package/src/adapters/index.ts +1 -0
- package/src/index.ts +7 -0
- package/src/terminal/TmuxManager.ts +118 -0
- package/src/utils/AgentRegistry.ts +139 -0
- package/src/utils/ClaudeSessionParser.ts +21 -10
- package/src/utils/agents.ts +41 -0
package/dist/utils/sortAgents.js
CHANGED
|
@@ -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
|
|
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,"
|
|
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.
|
|
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": "^
|
|
38
|
-
"@typescript-eslint/parser": "^
|
|
39
|
-
"@vitest/coverage-v8": "^
|
|
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": "^
|
|
46
|
+
"vitest": "^4.1.8"
|
|
43
47
|
},
|
|
44
48
|
"engines": {
|
|
45
49
|
"node": ">=20.20.0"
|
package/src/AgentManager.ts
CHANGED
|
@@ -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
|
-
//
|
|
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', () => {
|