@ai-devkit/agent-manager 0.14.0 → 0.16.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/.eslintrc.json +1 -1
- package/dist/AgentManager.d.ts +14 -12
- package/dist/AgentManager.d.ts.map +1 -1
- package/dist/AgentManager.js +40 -33
- package/dist/AgentManager.js.map +1 -1
- package/dist/adapters/AgentAdapter.js +2 -5
- package/dist/adapters/AgentAdapter.js.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.d.ts +1 -1
- package/dist/adapters/ClaudeCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/ClaudeCodeAdapter.js +29 -63
- package/dist/adapters/ClaudeCodeAdapter.js.map +1 -1
- package/dist/adapters/CodexAdapter.d.ts +5 -2
- package/dist/adapters/CodexAdapter.d.ts.map +1 -1
- package/dist/adapters/CodexAdapter.js +66 -73
- package/dist/adapters/CodexAdapter.js.map +1 -1
- package/dist/adapters/GeminiCliAdapter.d.ts +5 -2
- package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
- package/dist/adapters/GeminiCliAdapter.js +63 -70
- package/dist/adapters/GeminiCliAdapter.js.map +1 -1
- package/dist/adapters/OpenCodeAdapter.d.ts +1 -1
- package/dist/adapters/OpenCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/OpenCodeAdapter.js +20 -59
- package/dist/adapters/OpenCodeAdapter.js.map +1 -1
- package/dist/adapters/index.d.ts +6 -6
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/index.js +5 -13
- package/dist/adapters/index.js.map +1 -1
- package/dist/index.d.ts +18 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -22
- package/dist/index.js.map +1 -1
- package/dist/terminal/TerminalFocusManager.js +13 -17
- 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 +112 -0
- package/dist/terminal/TmuxManager.js.map +1 -0
- package/dist/terminal/TtyWriter.d.ts +1 -1
- package/dist/terminal/TtyWriter.d.ts.map +1 -1
- package/dist/terminal/TtyWriter.js +11 -15
- package/dist/terminal/TtyWriter.js.map +1 -1
- package/dist/terminal/index.d.ts +4 -4
- package/dist/terminal/index.d.ts.map +1 -1
- package/dist/terminal/index.js +3 -9
- 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 +114 -0
- package/dist/utils/AgentRegistry.js.map +1 -0
- package/dist/utils/ClaudeSessionParser.d.ts +2 -2
- package/dist/utils/ClaudeSessionParser.d.ts.map +1 -1
- package/dist/utils/ClaudeSessionParser.js +32 -59
- 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 +30 -0
- package/dist/utils/agents.js.map +1 -0
- package/dist/utils/applescript.js +1 -4
- package/dist/utils/applescript.js.map +1 -1
- package/dist/utils/index.d.ts +6 -6
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +4 -15
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/matching.d.ts +2 -2
- package/dist/utils/matching.d.ts.map +1 -1
- package/dist/utils/matching.js +3 -40
- package/dist/utils/matching.js.map +1 -1
- package/dist/utils/process.d.ts +1 -1
- package/dist/utils/process.d.ts.map +1 -1
- package/dist/utils/process.js +12 -52
- package/dist/utils/process.js.map +1 -1
- package/dist/utils/session.js +8 -49
- package/dist/utils/session.js.map +1 -1
- package/dist/utils/sortAgents.d.ts +4 -0
- package/dist/utils/sortAgents.d.ts.map +1 -0
- package/dist/utils/sortAgents.js +48 -0
- package/dist/utils/sortAgents.js.map +1 -0
- package/package.json +9 -8
- package/src/AgentManager.ts +53 -28
- package/src/__tests__/AgentManager.test.ts +140 -5
- package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +45 -44
- package/src/__tests__/adapters/CodexAdapter.test.ts +137 -30
- package/src/__tests__/adapters/GeminiCliAdapter.test.ts +130 -18
- package/src/__tests__/adapters/OpenCodeAdapter.test.ts +23 -21
- package/src/__tests__/terminal/TmuxManager.test.ts +175 -0
- package/src/__tests__/terminal/TtyWriter.test.ts +10 -9
- package/src/__tests__/utils/AgentRegistry.test.ts +223 -0
- package/src/__tests__/utils/ClaudeSessionParser.test.ts +73 -2
- package/src/__tests__/utils/matching.test.ts +4 -4
- package/src/__tests__/utils/process.test.ts +6 -5
- package/src/__tests__/utils/session.test.ts +8 -7
- package/src/adapters/ClaudeCodeAdapter.ts +8 -8
- package/src/adapters/CodexAdapter.ts +51 -12
- package/src/adapters/GeminiCliAdapter.ts +51 -12
- package/src/adapters/OpenCodeAdapter.ts +4 -4
- package/src/adapters/index.ts +6 -6
- package/src/index.ts +19 -11
- package/src/terminal/TerminalFocusManager.ts +2 -2
- package/src/terminal/TmuxManager.ts +118 -0
- package/src/terminal/TtyWriter.ts +3 -3
- package/src/terminal/index.ts +4 -4
- package/src/utils/AgentRegistry.ts +139 -0
- package/src/utils/ClaudeSessionParser.ts +23 -12
- package/src/utils/agents.ts +41 -0
- package/src/utils/index.ts +6 -6
- package/src/utils/matching.ts +2 -2
- package/src/utils/process.ts +1 -1
- package/src/utils/sortAgents.ts +52 -0
- package/tsconfig.json +2 -2
- package/vitest.config.ts +20 -0
- package/jest.config.js +0 -21
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { AgentRegistry, RenameNotFoundError, RenameConflictError, type RegistryEntry } from '../../utils/AgentRegistry.js';
|
|
5
|
+
|
|
6
|
+
function makeEntry(over: Partial<RegistryEntry> = {}): RegistryEntry {
|
|
7
|
+
return {
|
|
8
|
+
name: 'agent1',
|
|
9
|
+
type: 'claude',
|
|
10
|
+
pid: process.pid,
|
|
11
|
+
tmuxSession: 'agent1',
|
|
12
|
+
cwd: '/tmp',
|
|
13
|
+
startedAt: '2026-05-30T00:00:00.000Z',
|
|
14
|
+
sessionId: 'sid-1',
|
|
15
|
+
sessionFilePath: '/tmp/session.jsonl',
|
|
16
|
+
...over,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('AgentRegistry', () => {
|
|
21
|
+
let tmpDir: string;
|
|
22
|
+
let regPath: string;
|
|
23
|
+
let registry: AgentRegistry;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-registry-'));
|
|
27
|
+
regPath = path.join(tmpDir, 'nested', 'agents.json');
|
|
28
|
+
registry = new AgentRegistry(regPath);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('register', () => {
|
|
36
|
+
it('creates the file and parent directory if missing', () => {
|
|
37
|
+
registry.register(makeEntry());
|
|
38
|
+
expect(fs.existsSync(regPath)).toBe(true);
|
|
39
|
+
const parsed = JSON.parse(fs.readFileSync(regPath, 'utf8'));
|
|
40
|
+
expect(parsed.entries).toHaveLength(1);
|
|
41
|
+
expect(parsed.entries[0].name).toBe('agent1');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('appends a new entry when name is unique', () => {
|
|
45
|
+
registry.register(makeEntry({ name: 'a' }));
|
|
46
|
+
registry.register(makeEntry({ name: 'b' }));
|
|
47
|
+
expect(registry.list()).toHaveLength(2);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('upserts in place when name already exists', () => {
|
|
51
|
+
registry.register(makeEntry({ name: 'a', pid: 100 }));
|
|
52
|
+
registry.register(makeEntry({ name: 'a', pid: 200 }));
|
|
53
|
+
const all = registry.list();
|
|
54
|
+
expect(all).toHaveLength(1);
|
|
55
|
+
expect(all[0].pid).toBe(200);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('writes atomically (no leftover .tmp on success)', () => {
|
|
59
|
+
registry.register(makeEntry());
|
|
60
|
+
expect(fs.existsSync(`${regPath}.tmp`)).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('persists session fields', () => {
|
|
64
|
+
registry.register(makeEntry({ sessionId: 'sid-xyz', sessionFilePath: '/foo/bar.jsonl' }));
|
|
65
|
+
const saved = registry.list()[0];
|
|
66
|
+
expect(saved.sessionId).toBe('sid-xyz');
|
|
67
|
+
expect(saved.sessionFilePath).toBe('/foo/bar.jsonl');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('preserves existing tmuxSession when incoming is empty string', () => {
|
|
71
|
+
registry.register(makeEntry({ name: 'a', tmuxSession: 'pinned' }));
|
|
72
|
+
registry.register(makeEntry({ name: 'a', tmuxSession: '', pid: 999 }));
|
|
73
|
+
const saved = registry.lookup('a');
|
|
74
|
+
expect(saved?.tmuxSession).toBe('pinned');
|
|
75
|
+
expect(saved?.pid).toBe(999);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('replaces tmuxSession when incoming is non-empty', () => {
|
|
79
|
+
registry.register(makeEntry({ name: 'a', tmuxSession: 'old' }));
|
|
80
|
+
registry.register(makeEntry({ name: 'a', tmuxSession: 'new' }));
|
|
81
|
+
expect(registry.lookup('a')?.tmuxSession).toBe('new');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('registerBatch', () => {
|
|
86
|
+
it('is a no-op on empty array', () => {
|
|
87
|
+
registry.registerBatch([]);
|
|
88
|
+
expect(fs.existsSync(regPath)).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('upserts multiple entries with a single write', () => {
|
|
92
|
+
const writeSpy = vi.spyOn(fs, 'writeFileSync');
|
|
93
|
+
registry.registerBatch([
|
|
94
|
+
makeEntry({ name: 'a' }),
|
|
95
|
+
makeEntry({ name: 'b' }),
|
|
96
|
+
makeEntry({ name: 'c' }),
|
|
97
|
+
]);
|
|
98
|
+
expect(writeSpy).toHaveBeenCalledTimes(1);
|
|
99
|
+
writeSpy.mockRestore();
|
|
100
|
+
expect(registry.list()).toHaveLength(3);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('applies the tmuxSession merge per entry', () => {
|
|
104
|
+
registry.register(makeEntry({ name: 'a', tmuxSession: 'pinned' }));
|
|
105
|
+
registry.registerBatch([
|
|
106
|
+
makeEntry({ name: 'a', tmuxSession: '', pid: 7 }),
|
|
107
|
+
makeEntry({ name: 'b', tmuxSession: '' }),
|
|
108
|
+
]);
|
|
109
|
+
expect(registry.lookup('a')?.tmuxSession).toBe('pinned');
|
|
110
|
+
expect(registry.lookup('a')?.pid).toBe(7);
|
|
111
|
+
expect(registry.lookup('b')?.tmuxSession).toBe('');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('lookup', () => {
|
|
116
|
+
it('returns null when name not found', () => {
|
|
117
|
+
expect(registry.lookup('missing')).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('returns the entry when name matches', () => {
|
|
121
|
+
registry.register(makeEntry({ name: 'a' }));
|
|
122
|
+
expect(registry.lookup('a')?.name).toBe('a');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('list', () => {
|
|
127
|
+
it('returns empty array when file does not exist', () => {
|
|
128
|
+
expect(registry.list()).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('returns empty array when file is malformed', () => {
|
|
132
|
+
fs.mkdirSync(path.dirname(regPath), { recursive: true });
|
|
133
|
+
fs.writeFileSync(regPath, 'not json', 'utf8');
|
|
134
|
+
expect(registry.list()).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('coerces non-array entries to []', () => {
|
|
138
|
+
fs.mkdirSync(path.dirname(regPath), { recursive: true });
|
|
139
|
+
fs.writeFileSync(regPath, JSON.stringify({ entries: 'oops' }), 'utf8');
|
|
140
|
+
expect(registry.list()).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('isAlive', () => {
|
|
145
|
+
it('returns true for the current process', () => {
|
|
146
|
+
expect(registry.isAlive(makeEntry({ pid: process.pid }))).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('returns false for a PID that does not exist', () => {
|
|
150
|
+
expect(registry.isAlive(makeEntry({ pid: 999999 }))).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('prune', () => {
|
|
155
|
+
it('removes entries whose PIDs are dead', () => {
|
|
156
|
+
registry.register(makeEntry({ name: 'alive', pid: process.pid }));
|
|
157
|
+
registry.register(makeEntry({ name: 'dead', pid: 999999 }));
|
|
158
|
+
registry.prune();
|
|
159
|
+
const remaining = registry.list();
|
|
160
|
+
expect(remaining).toHaveLength(1);
|
|
161
|
+
expect(remaining[0].name).toBe('alive');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('is a no-op when all entries are alive', () => {
|
|
165
|
+
registry.register(makeEntry({ pid: process.pid }));
|
|
166
|
+
const before = fs.readFileSync(regPath, 'utf8');
|
|
167
|
+
registry.prune();
|
|
168
|
+
const after = fs.readFileSync(regPath, 'utf8');
|
|
169
|
+
expect(after).toBe(before);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('does nothing when file is missing', () => {
|
|
173
|
+
expect(() => registry.prune()).not.toThrow();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('default()', () => {
|
|
178
|
+
it('returns a singleton instance', () => {
|
|
179
|
+
expect(AgentRegistry.default()).toBe(AgentRegistry.default());
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('rename', () => {
|
|
184
|
+
it('updates the name of an existing entry', () => {
|
|
185
|
+
registry.register(makeEntry({ name: 'old-name', pid: process.pid }));
|
|
186
|
+
registry.rename('old-name', 'new-name');
|
|
187
|
+
expect(registry.lookup('new-name')?.name).toBe('new-name');
|
|
188
|
+
expect(registry.lookup('old-name')).toBeNull();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('preserves all other fields on the renamed entry', () => {
|
|
192
|
+
registry.register(makeEntry({ name: 'old-name', pid: process.pid, tmuxSession: 'old-name', cwd: '/my/cwd' }));
|
|
193
|
+
registry.rename('old-name', 'new-name');
|
|
194
|
+
const entry = registry.lookup('new-name');
|
|
195
|
+
expect(entry?.tmuxSession).toBe('old-name');
|
|
196
|
+
expect(entry?.cwd).toBe('/my/cwd');
|
|
197
|
+
expect(entry?.pid).toBe(process.pid);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('throws RenameNotFoundError when current name does not exist', () => {
|
|
201
|
+
expect(() => registry.rename('ghost', 'new-name')).toThrow(RenameNotFoundError);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('throws RenameConflictError when new name is already in use by a live entry', () => {
|
|
205
|
+
registry.register(makeEntry({ name: 'agent-a', pid: process.pid }));
|
|
206
|
+
registry.register(makeEntry({ name: 'agent-b', pid: process.pid }));
|
|
207
|
+
expect(() => registry.rename('agent-a', 'agent-b')).toThrow(RenameConflictError);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('succeeds when new name exists only as a stale (dead) entry', () => {
|
|
211
|
+
registry.register(makeEntry({ name: 'agent-a', pid: process.pid }));
|
|
212
|
+
registry.register(makeEntry({ name: 'agent-b', pid: 999999 }));
|
|
213
|
+
expect(() => registry.rename('agent-a', 'agent-b')).not.toThrow();
|
|
214
|
+
expect(registry.lookup('agent-b')?.pid).toBe(process.pid);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('writes atomically (no leftover .tmp on success)', () => {
|
|
218
|
+
registry.register(makeEntry({ name: 'old-name', pid: process.pid }));
|
|
219
|
+
registry.rename('old-name', 'new-name');
|
|
220
|
+
expect(fs.existsSync(`${regPath}.tmp`)).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
* harness-injected XML tags from conversation content.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
|
|
7
7
|
import * as fs from 'fs';
|
|
8
8
|
import * as os from 'os';
|
|
9
9
|
import * as path from 'path';
|
|
10
|
-
import { ClaudeSessionParser } from '../../utils/ClaudeSessionParser';
|
|
10
|
+
import { ClaudeSessionParser } from '../../utils/ClaudeSessionParser.js';
|
|
11
11
|
|
|
12
12
|
interface JsonlEntry {
|
|
13
13
|
type: 'user' | 'assistant' | 'system';
|
|
@@ -193,3 +193,74 @@ describe('ClaudeSessionParser.getConversation — harness tag stripping', () =>
|
|
|
193
193
|
expect(conv[0].content).toBe('before\n\nafter');
|
|
194
194
|
});
|
|
195
195
|
});
|
|
196
|
+
|
|
197
|
+
describe('ClaudeSessionParser.readSession — UI-state entries ignored for status', () => {
|
|
198
|
+
let parser: ClaudeSessionParser;
|
|
199
|
+
const tempFiles: string[] = [];
|
|
200
|
+
|
|
201
|
+
beforeEach(() => {
|
|
202
|
+
parser = new ClaudeSessionParser();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
afterEach(() => {
|
|
206
|
+
for (const f of tempFiles) {
|
|
207
|
+
try {
|
|
208
|
+
fs.rmSync(path.dirname(f), { recursive: true, force: true });
|
|
209
|
+
} catch { /* best effort */ }
|
|
210
|
+
}
|
|
211
|
+
tempFiles.length = 0;
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
function writeRawSession(lines: object[]): string {
|
|
215
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'parser-status-'));
|
|
216
|
+
const filePath = path.join(dir, 'session.jsonl');
|
|
217
|
+
fs.writeFileSync(filePath, lines.map(l => JSON.stringify(l)).join('\n'));
|
|
218
|
+
tempFiles.push(filePath);
|
|
219
|
+
return filePath;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const uiStateTypes = [
|
|
223
|
+
'attachment',
|
|
224
|
+
'permission-mode',
|
|
225
|
+
'ai-title',
|
|
226
|
+
'queued_command',
|
|
227
|
+
'tools_changed',
|
|
228
|
+
'model_changed',
|
|
229
|
+
'hook_progress',
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
for (const uiType of uiStateTypes) {
|
|
233
|
+
it(`keeps lastEntryType from previous conversation turn when trailing entry is ${uiType}`, () => {
|
|
234
|
+
const file = writeRawSession([
|
|
235
|
+
{ type: 'user', timestamp: '2026-05-30T06:17:57.189Z', message: { content: 'hello' } },
|
|
236
|
+
{ type: uiType, timestamp: '2026-05-30T06:17:57.201Z' },
|
|
237
|
+
]);
|
|
238
|
+
|
|
239
|
+
const session = parser.readSession(file, '/test');
|
|
240
|
+
expect(session?.lastEntryType).toBe('user');
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
it('keeps lastEntryType from a user turn even with multiple trailing UI-state entries', () => {
|
|
245
|
+
const file = writeRawSession([
|
|
246
|
+
{ type: 'user', timestamp: '2026-05-30T06:17:57.189Z', message: { content: 'hello' } },
|
|
247
|
+
{ type: 'attachment', timestamp: '2026-05-30T06:17:57.200Z', attachment: { type: 'task_reminder', content: [] } },
|
|
248
|
+
{ type: 'permission-mode', timestamp: '2026-05-30T06:17:57.210Z', permissionMode: 'default' },
|
|
249
|
+
{ type: 'ai-title', timestamp: '2026-05-30T06:17:57.220Z' },
|
|
250
|
+
]);
|
|
251
|
+
|
|
252
|
+
const session = parser.readSession(file, '/test');
|
|
253
|
+
expect(session?.lastEntryType).toBe('user');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('keeps lastEntryType from assistant turn when followed by UI-state events', () => {
|
|
257
|
+
const file = writeRawSession([
|
|
258
|
+
{ type: 'user', timestamp: '2026-05-30T06:17:00.000Z', message: { content: 'go' } },
|
|
259
|
+
{ type: 'assistant', timestamp: '2026-05-30T06:17:01.000Z', message: { content: [{ type: 'text', text: 'done' }] } },
|
|
260
|
+
{ type: 'permission-mode', timestamp: '2026-05-30T06:17:02.000Z', permissionMode: 'default' },
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
const session = parser.readSession(file, '/test');
|
|
264
|
+
expect(session?.lastEntryType).toBe('assistant');
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* Tests for utils/matching.ts
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
import { matchProcessesToSessions, generateAgentName } from '../../utils/matching';
|
|
7
|
-
import type { ProcessInfo } from '../../adapters/AgentAdapter';
|
|
8
|
-
import type { SessionFile } from '../../utils/session';
|
|
5
|
+
|
|
6
|
+
import { matchProcessesToSessions, generateAgentName } from '../../utils/matching.js';
|
|
7
|
+
import type { ProcessInfo } from '../../adapters/AgentAdapter.js';
|
|
8
|
+
import type { SessionFile } from '../../utils/session.js';
|
|
9
9
|
|
|
10
10
|
function makeProcess(overrides: Partial<ProcessInfo> & { pid: number }): ProcessInfo {
|
|
11
11
|
return {
|
|
@@ -2,20 +2,21 @@
|
|
|
2
2
|
* Tests for new functions in utils/process.ts
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import type { MockedFunction } from 'vitest';
|
|
6
|
+
|
|
6
7
|
import { execFileSync } from 'child_process';
|
|
7
8
|
import {
|
|
8
9
|
listAgentProcesses,
|
|
9
10
|
batchGetProcessCwds,
|
|
10
11
|
batchGetProcessStartTimes,
|
|
11
12
|
enrichProcesses,
|
|
12
|
-
} from '../../utils/process';
|
|
13
|
+
} from '../../utils/process.js';
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
execFileSync:
|
|
15
|
+
vi.mock('child_process', () => ({
|
|
16
|
+
execFileSync: vi.fn(),
|
|
16
17
|
}));
|
|
17
18
|
|
|
18
|
-
const mockedExecFileSync = execFileSync as
|
|
19
|
+
const mockedExecFileSync = execFileSync as MockedFunction<typeof execFileSync>;
|
|
19
20
|
|
|
20
21
|
describe('listAgentProcesses', () => {
|
|
21
22
|
beforeEach(() => {
|
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
* Tests for utils/session.ts
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import type { MockedFunction } from 'vitest';
|
|
6
|
+
|
|
6
7
|
import * as fs from 'fs';
|
|
7
|
-
import { batchGetSessionFileBirthtimes } from '../../utils/session';
|
|
8
|
+
import { batchGetSessionFileBirthtimes } from '../../utils/session.js';
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
readdirSync:
|
|
11
|
-
statSync:
|
|
10
|
+
vi.mock('fs', () => ({
|
|
11
|
+
readdirSync: vi.fn(),
|
|
12
|
+
statSync: vi.fn(),
|
|
12
13
|
}));
|
|
13
14
|
|
|
14
|
-
const mockedReaddirSync = fs.readdirSync as
|
|
15
|
-
const mockedStatSync = fs.statSync as
|
|
15
|
+
const mockedReaddirSync = fs.readdirSync as MockedFunction<typeof fs.readdirSync>;
|
|
16
|
+
const mockedStatSync = fs.statSync as MockedFunction<typeof fs.statSync>;
|
|
16
17
|
|
|
17
18
|
describe('batchGetSessionFileBirthtimes', () => {
|
|
18
19
|
beforeEach(() => {
|
|
@@ -7,14 +7,14 @@ import type {
|
|
|
7
7
|
ConversationMessage,
|
|
8
8
|
SessionSummary,
|
|
9
9
|
ListSessionsOptions,
|
|
10
|
-
} from './AgentAdapter';
|
|
11
|
-
import { AgentStatus } from './AgentAdapter';
|
|
12
|
-
import { listAgentProcesses, enrichProcesses } from '../utils/process';
|
|
13
|
-
import { batchGetSessionFileBirthtimes, isDirectory, listJsonl, safeReaddir, safeStat } from '../utils/session';
|
|
14
|
-
import type { SessionFile } from '../utils/session';
|
|
15
|
-
import { matchProcessesToSessions, generateAgentName } from '../utils/matching';
|
|
16
|
-
import { ClaudeSessionParser } from '../utils/ClaudeSessionParser';
|
|
17
|
-
import type { ClaudeSession } from '../utils/ClaudeSessionParser';
|
|
10
|
+
} from './AgentAdapter.js';
|
|
11
|
+
import { AgentStatus } from './AgentAdapter.js';
|
|
12
|
+
import { listAgentProcesses, enrichProcesses } from '../utils/process.js';
|
|
13
|
+
import { batchGetSessionFileBirthtimes, isDirectory, listJsonl, safeReaddir, safeStat } from '../utils/session.js';
|
|
14
|
+
import type { SessionFile } from '../utils/session.js';
|
|
15
|
+
import { matchProcessesToSessions, generateAgentName } from '../utils/matching.js';
|
|
16
|
+
import { ClaudeSessionParser } from '../utils/ClaudeSessionParser.js';
|
|
17
|
+
import type { ClaudeSession } from '../utils/ClaudeSessionParser.js';
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Entry in ~/.claude/sessions/<pid>.json written by Claude Code.
|
|
@@ -19,12 +19,13 @@ import type {
|
|
|
19
19
|
ConversationMessage,
|
|
20
20
|
SessionSummary,
|
|
21
21
|
ListSessionsOptions,
|
|
22
|
-
} from './AgentAdapter';
|
|
23
|
-
import { AgentStatus } from './AgentAdapter';
|
|
24
|
-
import { listAgentProcesses, enrichProcesses } from '../utils/process';
|
|
25
|
-
import { batchGetSessionFileBirthtimes, isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session';
|
|
26
|
-
import type { SessionFile } from '../utils/session';
|
|
27
|
-
import { matchProcessesToSessions, generateAgentName } from '../utils/matching';
|
|
22
|
+
} from './AgentAdapter.js';
|
|
23
|
+
import { AgentStatus } from './AgentAdapter.js';
|
|
24
|
+
import { listAgentProcesses, enrichProcesses } from '../utils/process.js';
|
|
25
|
+
import { batchGetSessionFileBirthtimes, isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session.js';
|
|
26
|
+
import type { SessionFile } from '../utils/session.js';
|
|
27
|
+
import { matchProcessesToSessions, generateAgentName } from '../utils/matching.js';
|
|
28
|
+
import { AgentRegistry } from '../utils/AgentRegistry.js';
|
|
28
29
|
|
|
29
30
|
interface CodexEventEntry {
|
|
30
31
|
timestamp?: string;
|
|
@@ -55,10 +56,12 @@ export class CodexAdapter implements AgentAdapter {
|
|
|
55
56
|
private static readonly PROCESS_START_DAY_WINDOW_DAYS = 1;
|
|
56
57
|
|
|
57
58
|
private codexSessionsDir: string;
|
|
59
|
+
private registry: AgentRegistry;
|
|
58
60
|
|
|
59
|
-
constructor() {
|
|
61
|
+
constructor(registry: AgentRegistry = AgentRegistry.default()) {
|
|
60
62
|
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
61
63
|
this.codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
|
|
64
|
+
this.registry = registry;
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
canHandle(processInfo: ProcessInfo): boolean {
|
|
@@ -72,12 +75,15 @@ export class CodexAdapter implements AgentAdapter {
|
|
|
72
75
|
const processes = enrichProcesses(listAgentProcesses('codex'));
|
|
73
76
|
if (processes.length === 0) return [];
|
|
74
77
|
|
|
75
|
-
const {
|
|
78
|
+
const { cachedAgents, remaining } = this.tryRegistryCache(processes);
|
|
79
|
+
if (remaining.length === 0) return cachedAgents;
|
|
80
|
+
|
|
81
|
+
const { sessions, contentCache } = this.discoverSessions(remaining);
|
|
76
82
|
if (sessions.length === 0) {
|
|
77
|
-
return
|
|
83
|
+
return [...cachedAgents, ...remaining.map((p) => this.mapProcessOnlyAgent(p))];
|
|
78
84
|
}
|
|
79
85
|
|
|
80
|
-
const matches = matchProcessesToSessions(
|
|
86
|
+
const matches = matchProcessesToSessions(remaining, sessions);
|
|
81
87
|
const matchedPids = new Set(matches.map((m) => m.process.pid));
|
|
82
88
|
const agents: AgentInfo[] = [];
|
|
83
89
|
|
|
@@ -91,13 +97,46 @@ export class CodexAdapter implements AgentAdapter {
|
|
|
91
97
|
}
|
|
92
98
|
}
|
|
93
99
|
|
|
94
|
-
for (const proc of
|
|
100
|
+
for (const proc of remaining) {
|
|
95
101
|
if (!matchedPids.has(proc.pid)) {
|
|
96
102
|
agents.push(this.mapProcessOnlyAgent(proc));
|
|
97
103
|
}
|
|
98
104
|
}
|
|
99
105
|
|
|
100
|
-
return agents;
|
|
106
|
+
return [...cachedAgents, ...agents];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private tryRegistryCache(processes: ProcessInfo[]): {
|
|
110
|
+
cachedAgents: AgentInfo[];
|
|
111
|
+
remaining: ProcessInfo[];
|
|
112
|
+
} {
|
|
113
|
+
const cachedAgents: AgentInfo[] = [];
|
|
114
|
+
const remaining: ProcessInfo[] = [];
|
|
115
|
+
const byPid = new Map(this.registry.list().map((e) => [e.pid, e]));
|
|
116
|
+
|
|
117
|
+
for (const proc of processes) {
|
|
118
|
+
const entry = byPid.get(proc.pid);
|
|
119
|
+
if (
|
|
120
|
+
!entry ||
|
|
121
|
+
entry.type !== this.type ||
|
|
122
|
+
!entry.sessionFilePath ||
|
|
123
|
+
!fs.existsSync(entry.sessionFilePath)
|
|
124
|
+
) {
|
|
125
|
+
remaining.push(proc);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const content = safeReadFile(entry.sessionFilePath);
|
|
130
|
+
const sessionData = this.parseSession(content, entry.sessionFilePath);
|
|
131
|
+
if (!sessionData) {
|
|
132
|
+
remaining.push(proc);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
cachedAgents.push(this.mapSessionToAgent(sessionData, proc, entry.sessionFilePath));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { cachedAgents, remaining };
|
|
101
140
|
}
|
|
102
141
|
|
|
103
142
|
/**
|
|
@@ -20,12 +20,13 @@ import type {
|
|
|
20
20
|
ConversationMessage,
|
|
21
21
|
SessionSummary,
|
|
22
22
|
ListSessionsOptions,
|
|
23
|
-
} from './AgentAdapter';
|
|
24
|
-
import { AgentStatus } from './AgentAdapter';
|
|
25
|
-
import { listAgentProcesses, enrichProcesses } from '../utils/process';
|
|
26
|
-
import { isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session';
|
|
27
|
-
import type { SessionFile } from '../utils/session';
|
|
28
|
-
import { matchProcessesToSessions, generateAgentName } from '../utils/matching';
|
|
23
|
+
} from './AgentAdapter.js';
|
|
24
|
+
import { AgentStatus } from './AgentAdapter.js';
|
|
25
|
+
import { listAgentProcesses, enrichProcesses } from '../utils/process.js';
|
|
26
|
+
import { isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session.js';
|
|
27
|
+
import type { SessionFile } from '../utils/session.js';
|
|
28
|
+
import { matchProcessesToSessions, generateAgentName } from '../utils/matching.js';
|
|
29
|
+
import { AgentRegistry } from '../utils/AgentRegistry.js';
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* A single Gemini CLI message content part. Mirrors the `{text?: string}`
|
|
@@ -87,10 +88,12 @@ export class GeminiCliAdapter implements AgentAdapter {
|
|
|
87
88
|
private static readonly TMP_DIR_NAME = 'tmp';
|
|
88
89
|
|
|
89
90
|
private geminiTmpDir: string;
|
|
91
|
+
private registry: AgentRegistry;
|
|
90
92
|
|
|
91
|
-
constructor() {
|
|
93
|
+
constructor(registry: AgentRegistry = AgentRegistry.default()) {
|
|
92
94
|
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
|
93
95
|
this.geminiTmpDir = path.join(homeDir, '.gemini', GeminiCliAdapter.TMP_DIR_NAME);
|
|
96
|
+
this.registry = registry;
|
|
94
97
|
}
|
|
95
98
|
|
|
96
99
|
canHandle(processInfo: ProcessInfo): boolean {
|
|
@@ -114,12 +117,15 @@ export class GeminiCliAdapter implements AgentAdapter {
|
|
|
114
117
|
const processes = nodeProcesses.filter((proc) => this.isGeminiExecutable(proc.command));
|
|
115
118
|
if (processes.length === 0) return [];
|
|
116
119
|
|
|
117
|
-
const {
|
|
120
|
+
const { cachedAgents, remaining } = this.tryRegistryCache(processes);
|
|
121
|
+
if (remaining.length === 0) return cachedAgents;
|
|
122
|
+
|
|
123
|
+
const { sessions, contentCache } = this.discoverSessions(remaining);
|
|
118
124
|
if (sessions.length === 0) {
|
|
119
|
-
return
|
|
125
|
+
return [...cachedAgents, ...remaining.map((p) => this.mapProcessOnlyAgent(p))];
|
|
120
126
|
}
|
|
121
127
|
|
|
122
|
-
const matches = matchProcessesToSessions(
|
|
128
|
+
const matches = matchProcessesToSessions(remaining, sessions);
|
|
123
129
|
const matchedPids = new Set(matches.map((m) => m.process.pid));
|
|
124
130
|
const agents: AgentInfo[] = [];
|
|
125
131
|
|
|
@@ -133,13 +139,46 @@ export class GeminiCliAdapter implements AgentAdapter {
|
|
|
133
139
|
}
|
|
134
140
|
}
|
|
135
141
|
|
|
136
|
-
for (const proc of
|
|
142
|
+
for (const proc of remaining) {
|
|
137
143
|
if (!matchedPids.has(proc.pid)) {
|
|
138
144
|
agents.push(this.mapProcessOnlyAgent(proc));
|
|
139
145
|
}
|
|
140
146
|
}
|
|
141
147
|
|
|
142
|
-
return agents;
|
|
148
|
+
return [...cachedAgents, ...agents];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private tryRegistryCache(processes: ProcessInfo[]): {
|
|
152
|
+
cachedAgents: AgentInfo[];
|
|
153
|
+
remaining: ProcessInfo[];
|
|
154
|
+
} {
|
|
155
|
+
const cachedAgents: AgentInfo[] = [];
|
|
156
|
+
const remaining: ProcessInfo[] = [];
|
|
157
|
+
const byPid = new Map(this.registry.list().map((e) => [e.pid, e]));
|
|
158
|
+
|
|
159
|
+
for (const proc of processes) {
|
|
160
|
+
const entry = byPid.get(proc.pid);
|
|
161
|
+
if (
|
|
162
|
+
!entry ||
|
|
163
|
+
entry.type !== this.type ||
|
|
164
|
+
!entry.sessionFilePath ||
|
|
165
|
+
!fs.existsSync(entry.sessionFilePath)
|
|
166
|
+
) {
|
|
167
|
+
remaining.push(proc);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const content = safeReadFile(entry.sessionFilePath);
|
|
172
|
+
const sessionData = this.parseSession(content, entry.sessionFilePath);
|
|
173
|
+
if (!sessionData) {
|
|
174
|
+
remaining.push(proc);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
cachedAgents.push(this.mapSessionToAgent(sessionData, proc, entry.sessionFilePath));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { cachedAgents, remaining };
|
|
143
182
|
}
|
|
144
183
|
|
|
145
184
|
/**
|
|
@@ -21,10 +21,10 @@ import type {
|
|
|
21
21
|
ConversationMessage,
|
|
22
22
|
SessionSummary,
|
|
23
23
|
ListSessionsOptions,
|
|
24
|
-
} from './AgentAdapter';
|
|
25
|
-
import { AgentStatus } from './AgentAdapter';
|
|
26
|
-
import { listAgentProcesses, enrichProcesses } from '../utils/process';
|
|
27
|
-
import { generateAgentName } from '../utils/matching';
|
|
24
|
+
} from './AgentAdapter.js';
|
|
25
|
+
import { AgentStatus } from './AgentAdapter.js';
|
|
26
|
+
import { listAgentProcesses, enrichProcesses } from '../utils/process.js';
|
|
27
|
+
import { generateAgentName } from '../utils/matching.js';
|
|
28
28
|
|
|
29
29
|
const SESSION_REF_SEP = '::';
|
|
30
30
|
|
package/src/adapters/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export { ClaudeCodeAdapter } from './ClaudeCodeAdapter';
|
|
2
|
-
export { CodexAdapter } from './CodexAdapter';
|
|
3
|
-
export { GeminiCliAdapter } from './GeminiCliAdapter';
|
|
4
|
-
export { OpenCodeAdapter } from './OpenCodeAdapter';
|
|
5
|
-
export { AgentStatus } from './AgentAdapter';
|
|
6
|
-
export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo } from './AgentAdapter';
|
|
1
|
+
export { ClaudeCodeAdapter } from './ClaudeCodeAdapter.js';
|
|
2
|
+
export { CodexAdapter } from './CodexAdapter.js';
|
|
3
|
+
export { GeminiCliAdapter } from './GeminiCliAdapter.js';
|
|
4
|
+
export { OpenCodeAdapter } from './OpenCodeAdapter.js';
|
|
5
|
+
export { AgentStatus } from './AgentAdapter.js';
|
|
6
|
+
export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo } from './AgentAdapter.js';
|