@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
@@ -0,0 +1,282 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { AgentRegistry, RenameNotFoundError, RenameConflictError } from '../../utils/AgentRegistry.js';
5
+ function makeEntry(over = {}) {
6
+ return {
7
+ name: 'agent1',
8
+ type: 'claude',
9
+ pid: process.pid,
10
+ tmuxSession: 'agent1',
11
+ cwd: '/tmp',
12
+ startedAt: '2026-05-30T00:00:00.000Z',
13
+ sessionId: 'sid-1',
14
+ sessionFilePath: '/tmp/session.jsonl',
15
+ ...over
16
+ };
17
+ }
18
+ describe('AgentRegistry', ()=>{
19
+ let tmpDir;
20
+ let regPath;
21
+ let registry;
22
+ beforeEach(()=>{
23
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-registry-'));
24
+ regPath = path.join(tmpDir, 'nested', 'agents.json');
25
+ registry = new AgentRegistry(regPath);
26
+ });
27
+ afterEach(()=>{
28
+ fs.rmSync(tmpDir, {
29
+ recursive: true,
30
+ force: true
31
+ });
32
+ });
33
+ describe('register', ()=>{
34
+ it('creates the file and parent directory if missing', ()=>{
35
+ registry.register(makeEntry());
36
+ expect(fs.existsSync(regPath)).toBe(true);
37
+ const parsed = JSON.parse(fs.readFileSync(regPath, 'utf8'));
38
+ expect(parsed.entries).toHaveLength(1);
39
+ expect(parsed.entries[0].name).toBe('agent1');
40
+ });
41
+ it('appends a new entry when name is unique', ()=>{
42
+ registry.register(makeEntry({
43
+ name: 'a'
44
+ }));
45
+ registry.register(makeEntry({
46
+ name: 'b'
47
+ }));
48
+ expect(registry.list()).toHaveLength(2);
49
+ });
50
+ it('upserts in place when name already exists', ()=>{
51
+ registry.register(makeEntry({
52
+ name: 'a',
53
+ pid: 100
54
+ }));
55
+ registry.register(makeEntry({
56
+ name: 'a',
57
+ pid: 200
58
+ }));
59
+ const all = registry.list();
60
+ expect(all).toHaveLength(1);
61
+ expect(all[0].pid).toBe(200);
62
+ });
63
+ it('writes atomically (no leftover .tmp on success)', ()=>{
64
+ registry.register(makeEntry());
65
+ expect(fs.existsSync(`${regPath}.tmp`)).toBe(false);
66
+ });
67
+ it('persists session fields', ()=>{
68
+ registry.register(makeEntry({
69
+ sessionId: 'sid-xyz',
70
+ sessionFilePath: '/foo/bar.jsonl'
71
+ }));
72
+ const saved = registry.list()[0];
73
+ expect(saved.sessionId).toBe('sid-xyz');
74
+ expect(saved.sessionFilePath).toBe('/foo/bar.jsonl');
75
+ });
76
+ it('preserves existing tmuxSession when incoming is empty string', ()=>{
77
+ registry.register(makeEntry({
78
+ name: 'a',
79
+ tmuxSession: 'pinned'
80
+ }));
81
+ registry.register(makeEntry({
82
+ name: 'a',
83
+ tmuxSession: '',
84
+ pid: 999
85
+ }));
86
+ const saved = registry.lookup('a');
87
+ expect(saved?.tmuxSession).toBe('pinned');
88
+ expect(saved?.pid).toBe(999);
89
+ });
90
+ it('replaces tmuxSession when incoming is non-empty', ()=>{
91
+ registry.register(makeEntry({
92
+ name: 'a',
93
+ tmuxSession: 'old'
94
+ }));
95
+ registry.register(makeEntry({
96
+ name: 'a',
97
+ tmuxSession: 'new'
98
+ }));
99
+ expect(registry.lookup('a')?.tmuxSession).toBe('new');
100
+ });
101
+ });
102
+ describe('registerBatch', ()=>{
103
+ it('is a no-op on empty array', ()=>{
104
+ registry.registerBatch([]);
105
+ expect(fs.existsSync(regPath)).toBe(false);
106
+ });
107
+ it('upserts multiple entries with a single write', ()=>{
108
+ const writeSpy = vi.spyOn(fs, 'writeFileSync');
109
+ registry.registerBatch([
110
+ makeEntry({
111
+ name: 'a'
112
+ }),
113
+ makeEntry({
114
+ name: 'b'
115
+ }),
116
+ makeEntry({
117
+ name: 'c'
118
+ })
119
+ ]);
120
+ expect(writeSpy).toHaveBeenCalledTimes(1);
121
+ writeSpy.mockRestore();
122
+ expect(registry.list()).toHaveLength(3);
123
+ });
124
+ it('applies the tmuxSession merge per entry', ()=>{
125
+ registry.register(makeEntry({
126
+ name: 'a',
127
+ tmuxSession: 'pinned'
128
+ }));
129
+ registry.registerBatch([
130
+ makeEntry({
131
+ name: 'a',
132
+ tmuxSession: '',
133
+ pid: 7
134
+ }),
135
+ makeEntry({
136
+ name: 'b',
137
+ tmuxSession: ''
138
+ })
139
+ ]);
140
+ expect(registry.lookup('a')?.tmuxSession).toBe('pinned');
141
+ expect(registry.lookup('a')?.pid).toBe(7);
142
+ expect(registry.lookup('b')?.tmuxSession).toBe('');
143
+ });
144
+ });
145
+ describe('lookup', ()=>{
146
+ it('returns null when name not found', ()=>{
147
+ expect(registry.lookup('missing')).toBeNull();
148
+ });
149
+ it('returns the entry when name matches', ()=>{
150
+ registry.register(makeEntry({
151
+ name: 'a'
152
+ }));
153
+ expect(registry.lookup('a')?.name).toBe('a');
154
+ });
155
+ });
156
+ describe('list', ()=>{
157
+ it('returns empty array when file does not exist', ()=>{
158
+ expect(registry.list()).toEqual([]);
159
+ });
160
+ it('returns empty array when file is malformed', ()=>{
161
+ fs.mkdirSync(path.dirname(regPath), {
162
+ recursive: true
163
+ });
164
+ fs.writeFileSync(regPath, 'not json', 'utf8');
165
+ expect(registry.list()).toEqual([]);
166
+ });
167
+ it('coerces non-array entries to []', ()=>{
168
+ fs.mkdirSync(path.dirname(regPath), {
169
+ recursive: true
170
+ });
171
+ fs.writeFileSync(regPath, JSON.stringify({
172
+ entries: 'oops'
173
+ }), 'utf8');
174
+ expect(registry.list()).toEqual([]);
175
+ });
176
+ });
177
+ describe('isAlive', ()=>{
178
+ it('returns true for the current process', ()=>{
179
+ expect(registry.isAlive(makeEntry({
180
+ pid: process.pid
181
+ }))).toBe(true);
182
+ });
183
+ it('returns false for a PID that does not exist', ()=>{
184
+ expect(registry.isAlive(makeEntry({
185
+ pid: 999999
186
+ }))).toBe(false);
187
+ });
188
+ });
189
+ describe('prune', ()=>{
190
+ it('removes entries whose PIDs are dead', ()=>{
191
+ registry.register(makeEntry({
192
+ name: 'alive',
193
+ pid: process.pid
194
+ }));
195
+ registry.register(makeEntry({
196
+ name: 'dead',
197
+ pid: 999999
198
+ }));
199
+ registry.prune();
200
+ const remaining = registry.list();
201
+ expect(remaining).toHaveLength(1);
202
+ expect(remaining[0].name).toBe('alive');
203
+ });
204
+ it('is a no-op when all entries are alive', ()=>{
205
+ registry.register(makeEntry({
206
+ pid: process.pid
207
+ }));
208
+ const before = fs.readFileSync(regPath, 'utf8');
209
+ registry.prune();
210
+ const after = fs.readFileSync(regPath, 'utf8');
211
+ expect(after).toBe(before);
212
+ });
213
+ it('does nothing when file is missing', ()=>{
214
+ expect(()=>registry.prune()).not.toThrow();
215
+ });
216
+ });
217
+ describe('default()', ()=>{
218
+ it('returns a singleton instance', ()=>{
219
+ expect(AgentRegistry.default()).toBe(AgentRegistry.default());
220
+ });
221
+ });
222
+ describe('rename', ()=>{
223
+ it('updates the name of an existing entry', ()=>{
224
+ registry.register(makeEntry({
225
+ name: 'old-name',
226
+ pid: process.pid
227
+ }));
228
+ registry.rename('old-name', 'new-name');
229
+ expect(registry.lookup('new-name')?.name).toBe('new-name');
230
+ expect(registry.lookup('old-name')).toBeNull();
231
+ });
232
+ it('preserves all other fields on the renamed entry', ()=>{
233
+ registry.register(makeEntry({
234
+ name: 'old-name',
235
+ pid: process.pid,
236
+ tmuxSession: 'old-name',
237
+ cwd: '/my/cwd'
238
+ }));
239
+ registry.rename('old-name', 'new-name');
240
+ const entry = registry.lookup('new-name');
241
+ expect(entry?.tmuxSession).toBe('old-name');
242
+ expect(entry?.cwd).toBe('/my/cwd');
243
+ expect(entry?.pid).toBe(process.pid);
244
+ });
245
+ it('throws RenameNotFoundError when current name does not exist', ()=>{
246
+ expect(()=>registry.rename('ghost', 'new-name')).toThrow(RenameNotFoundError);
247
+ });
248
+ it('throws RenameConflictError when new name is already in use by a live entry', ()=>{
249
+ registry.register(makeEntry({
250
+ name: 'agent-a',
251
+ pid: process.pid
252
+ }));
253
+ registry.register(makeEntry({
254
+ name: 'agent-b',
255
+ pid: process.pid
256
+ }));
257
+ expect(()=>registry.rename('agent-a', 'agent-b')).toThrow(RenameConflictError);
258
+ });
259
+ it('succeeds when new name exists only as a stale (dead) entry', ()=>{
260
+ registry.register(makeEntry({
261
+ name: 'agent-a',
262
+ pid: process.pid
263
+ }));
264
+ registry.register(makeEntry({
265
+ name: 'agent-b',
266
+ pid: 999999
267
+ }));
268
+ expect(()=>registry.rename('agent-a', 'agent-b')).not.toThrow();
269
+ expect(registry.lookup('agent-b')?.pid).toBe(process.pid);
270
+ });
271
+ it('writes atomically (no leftover .tmp on success)', ()=>{
272
+ registry.register(makeEntry({
273
+ name: 'old-name',
274
+ pid: process.pid
275
+ }));
276
+ registry.rename('old-name', 'new-name');
277
+ expect(fs.existsSync(`${regPath}.tmp`)).toBe(false);
278
+ });
279
+ });
280
+ });
281
+
282
+ //# sourceMappingURL=AgentRegistry.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/__tests__/utils/AgentRegistry.test.ts"],"sourcesContent":["import fs from 'fs';\nimport os from 'os';\nimport path from 'path';\nimport { AgentRegistry, RenameNotFoundError, RenameConflictError, type RegistryEntry } from '../../utils/AgentRegistry.js';\n\nfunction makeEntry(over: Partial<RegistryEntry> = {}): RegistryEntry {\n return {\n name: 'agent1',\n type: 'claude',\n pid: process.pid,\n tmuxSession: 'agent1',\n cwd: '/tmp',\n startedAt: '2026-05-30T00:00:00.000Z',\n sessionId: 'sid-1',\n sessionFilePath: '/tmp/session.jsonl',\n ...over,\n };\n}\n\ndescribe('AgentRegistry', () => {\n let tmpDir: string;\n let regPath: string;\n let registry: AgentRegistry;\n\n beforeEach(() => {\n tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-registry-'));\n regPath = path.join(tmpDir, 'nested', 'agents.json');\n registry = new AgentRegistry(regPath);\n });\n\n afterEach(() => {\n fs.rmSync(tmpDir, { recursive: true, force: true });\n });\n\n describe('register', () => {\n it('creates the file and parent directory if missing', () => {\n registry.register(makeEntry());\n expect(fs.existsSync(regPath)).toBe(true);\n const parsed = JSON.parse(fs.readFileSync(regPath, 'utf8'));\n expect(parsed.entries).toHaveLength(1);\n expect(parsed.entries[0].name).toBe('agent1');\n });\n\n it('appends a new entry when name is unique', () => {\n registry.register(makeEntry({ name: 'a' }));\n registry.register(makeEntry({ name: 'b' }));\n expect(registry.list()).toHaveLength(2);\n });\n\n it('upserts in place when name already exists', () => {\n registry.register(makeEntry({ name: 'a', pid: 100 }));\n registry.register(makeEntry({ name: 'a', pid: 200 }));\n const all = registry.list();\n expect(all).toHaveLength(1);\n expect(all[0].pid).toBe(200);\n });\n\n it('writes atomically (no leftover .tmp on success)', () => {\n registry.register(makeEntry());\n expect(fs.existsSync(`${regPath}.tmp`)).toBe(false);\n });\n\n it('persists session fields', () => {\n registry.register(makeEntry({ sessionId: 'sid-xyz', sessionFilePath: '/foo/bar.jsonl' }));\n const saved = registry.list()[0];\n expect(saved.sessionId).toBe('sid-xyz');\n expect(saved.sessionFilePath).toBe('/foo/bar.jsonl');\n });\n\n it('preserves existing tmuxSession when incoming is empty string', () => {\n registry.register(makeEntry({ name: 'a', tmuxSession: 'pinned' }));\n registry.register(makeEntry({ name: 'a', tmuxSession: '', pid: 999 }));\n const saved = registry.lookup('a');\n expect(saved?.tmuxSession).toBe('pinned');\n expect(saved?.pid).toBe(999);\n });\n\n it('replaces tmuxSession when incoming is non-empty', () => {\n registry.register(makeEntry({ name: 'a', tmuxSession: 'old' }));\n registry.register(makeEntry({ name: 'a', tmuxSession: 'new' }));\n expect(registry.lookup('a')?.tmuxSession).toBe('new');\n });\n });\n\n describe('registerBatch', () => {\n it('is a no-op on empty array', () => {\n registry.registerBatch([]);\n expect(fs.existsSync(regPath)).toBe(false);\n });\n\n it('upserts multiple entries with a single write', () => {\n const writeSpy = vi.spyOn(fs, 'writeFileSync');\n registry.registerBatch([\n makeEntry({ name: 'a' }),\n makeEntry({ name: 'b' }),\n makeEntry({ name: 'c' }),\n ]);\n expect(writeSpy).toHaveBeenCalledTimes(1);\n writeSpy.mockRestore();\n expect(registry.list()).toHaveLength(3);\n });\n\n it('applies the tmuxSession merge per entry', () => {\n registry.register(makeEntry({ name: 'a', tmuxSession: 'pinned' }));\n registry.registerBatch([\n makeEntry({ name: 'a', tmuxSession: '', pid: 7 }),\n makeEntry({ name: 'b', tmuxSession: '' }),\n ]);\n expect(registry.lookup('a')?.tmuxSession).toBe('pinned');\n expect(registry.lookup('a')?.pid).toBe(7);\n expect(registry.lookup('b')?.tmuxSession).toBe('');\n });\n });\n\n describe('lookup', () => {\n it('returns null when name not found', () => {\n expect(registry.lookup('missing')).toBeNull();\n });\n\n it('returns the entry when name matches', () => {\n registry.register(makeEntry({ name: 'a' }));\n expect(registry.lookup('a')?.name).toBe('a');\n });\n });\n\n describe('list', () => {\n it('returns empty array when file does not exist', () => {\n expect(registry.list()).toEqual([]);\n });\n\n it('returns empty array when file is malformed', () => {\n fs.mkdirSync(path.dirname(regPath), { recursive: true });\n fs.writeFileSync(regPath, 'not json', 'utf8');\n expect(registry.list()).toEqual([]);\n });\n\n it('coerces non-array entries to []', () => {\n fs.mkdirSync(path.dirname(regPath), { recursive: true });\n fs.writeFileSync(regPath, JSON.stringify({ entries: 'oops' }), 'utf8');\n expect(registry.list()).toEqual([]);\n });\n });\n\n describe('isAlive', () => {\n it('returns true for the current process', () => {\n expect(registry.isAlive(makeEntry({ pid: process.pid }))).toBe(true);\n });\n\n it('returns false for a PID that does not exist', () => {\n expect(registry.isAlive(makeEntry({ pid: 999999 }))).toBe(false);\n });\n });\n\n describe('prune', () => {\n it('removes entries whose PIDs are dead', () => {\n registry.register(makeEntry({ name: 'alive', pid: process.pid }));\n registry.register(makeEntry({ name: 'dead', pid: 999999 }));\n registry.prune();\n const remaining = registry.list();\n expect(remaining).toHaveLength(1);\n expect(remaining[0].name).toBe('alive');\n });\n\n it('is a no-op when all entries are alive', () => {\n registry.register(makeEntry({ pid: process.pid }));\n const before = fs.readFileSync(regPath, 'utf8');\n registry.prune();\n const after = fs.readFileSync(regPath, 'utf8');\n expect(after).toBe(before);\n });\n\n it('does nothing when file is missing', () => {\n expect(() => registry.prune()).not.toThrow();\n });\n });\n\n describe('default()', () => {\n it('returns a singleton instance', () => {\n expect(AgentRegistry.default()).toBe(AgentRegistry.default());\n });\n });\n\n describe('rename', () => {\n it('updates the name of an existing entry', () => {\n registry.register(makeEntry({ name: 'old-name', pid: process.pid }));\n registry.rename('old-name', 'new-name');\n expect(registry.lookup('new-name')?.name).toBe('new-name');\n expect(registry.lookup('old-name')).toBeNull();\n });\n\n it('preserves all other fields on the renamed entry', () => {\n registry.register(makeEntry({ name: 'old-name', pid: process.pid, tmuxSession: 'old-name', cwd: '/my/cwd' }));\n registry.rename('old-name', 'new-name');\n const entry = registry.lookup('new-name');\n expect(entry?.tmuxSession).toBe('old-name');\n expect(entry?.cwd).toBe('/my/cwd');\n expect(entry?.pid).toBe(process.pid);\n });\n\n it('throws RenameNotFoundError when current name does not exist', () => {\n expect(() => registry.rename('ghost', 'new-name')).toThrow(RenameNotFoundError);\n });\n\n it('throws RenameConflictError when new name is already in use by a live entry', () => {\n registry.register(makeEntry({ name: 'agent-a', pid: process.pid }));\n registry.register(makeEntry({ name: 'agent-b', pid: process.pid }));\n expect(() => registry.rename('agent-a', 'agent-b')).toThrow(RenameConflictError);\n });\n\n it('succeeds when new name exists only as a stale (dead) entry', () => {\n registry.register(makeEntry({ name: 'agent-a', pid: process.pid }));\n registry.register(makeEntry({ name: 'agent-b', pid: 999999 }));\n expect(() => registry.rename('agent-a', 'agent-b')).not.toThrow();\n expect(registry.lookup('agent-b')?.pid).toBe(process.pid);\n });\n\n it('writes atomically (no leftover .tmp on success)', () => {\n registry.register(makeEntry({ name: 'old-name', pid: process.pid }));\n registry.rename('old-name', 'new-name');\n expect(fs.existsSync(`${regPath}.tmp`)).toBe(false);\n });\n });\n});\n"],"names":["fs","os","path","AgentRegistry","RenameNotFoundError","RenameConflictError","makeEntry","over","name","type","pid","process","tmuxSession","cwd","startedAt","sessionId","sessionFilePath","describe","tmpDir","regPath","registry","beforeEach","mkdtempSync","join","tmpdir","afterEach","rmSync","recursive","force","it","register","expect","existsSync","toBe","parsed","JSON","parse","readFileSync","entries","toHaveLength","list","all","saved","lookup","registerBatch","writeSpy","vi","spyOn","toHaveBeenCalledTimes","mockRestore","toBeNull","toEqual","mkdirSync","dirname","writeFileSync","stringify","isAlive","prune","remaining","before","after","not","toThrow","default","rename","entry"],"mappings":"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"}
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Tests for utils/ClaudeSessionParser.ts — focused on stripping
3
+ * harness-injected XML tags from conversation content.
4
+ */ import * as fs from 'fs';
5
+ import * as os from 'os';
6
+ import * as path from 'path';
7
+ import { ClaudeSessionParser } from '../../utils/ClaudeSessionParser.js';
8
+ function writeSession(entries) {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'parser-test-'));
10
+ const filePath = path.join(dir, 'session.jsonl');
11
+ fs.writeFileSync(filePath, entries.map((e)=>JSON.stringify(e)).join('\n'));
12
+ return filePath;
13
+ }
14
+ describe('ClaudeSessionParser.getConversation — harness tag stripping', ()=>{
15
+ let parser;
16
+ const tempFiles = [];
17
+ beforeEach(()=>{
18
+ parser = new ClaudeSessionParser();
19
+ });
20
+ afterEach(()=>{
21
+ for (const f of tempFiles){
22
+ try {
23
+ fs.rmSync(path.dirname(f), {
24
+ recursive: true,
25
+ force: true
26
+ });
27
+ } catch {}
28
+ }
29
+ tempFiles.length = 0;
30
+ });
31
+ function makeSession(entries) {
32
+ const f = writeSession(entries);
33
+ tempFiles.push(f);
34
+ return f;
35
+ }
36
+ it('strips <system-reminder> blocks from text content', ()=>{
37
+ const file = makeSession([
38
+ {
39
+ type: 'assistant',
40
+ message: {
41
+ content: [
42
+ {
43
+ type: 'text',
44
+ text: 'Real response here.\n<system-reminder>\nDo not mention this reminder.\n</system-reminder>\nMore response.'
45
+ }
46
+ ]
47
+ }
48
+ }
49
+ ]);
50
+ const conv = parser.getConversation(file);
51
+ expect(conv).toHaveLength(1);
52
+ expect(conv[0].content).toBe('Real response here.\n\nMore response.');
53
+ });
54
+ it('strips <local-command-stdout> blocks', ()=>{
55
+ const file = makeSession([
56
+ {
57
+ type: 'system',
58
+ message: {
59
+ content: 'before <local-command-stdout>\nRunning...\nDone\n</local-command-stdout> after'
60
+ }
61
+ }
62
+ ]);
63
+ const conv = parser.getConversation(file);
64
+ expect(conv[0].content).toBe('before after');
65
+ });
66
+ it('strips <user-prompt-submit-hook> blocks', ()=>{
67
+ const file = makeSession([
68
+ {
69
+ type: 'user',
70
+ message: {
71
+ content: '<user-prompt-submit-hook>hook output</user-prompt-submit-hook>\nactual question'
72
+ }
73
+ }
74
+ ]);
75
+ const conv = parser.getConversation(file);
76
+ expect(conv[0].content).toBe('actual question');
77
+ });
78
+ it('strips bash and command stdout/stderr blocks', ()=>{
79
+ const file = makeSession([
80
+ {
81
+ type: 'assistant',
82
+ message: {
83
+ content: [
84
+ {
85
+ type: 'text',
86
+ text: 'Output:\n<bash-input>ls</bash-input>\n<bash-stdout>file.txt</bash-stdout>\n<bash-stderr></bash-stderr>\n<command-stdout>x</command-stdout>\n<command-stderr>y</command-stderr>\nEnd.'
87
+ }
88
+ ]
89
+ }
90
+ }
91
+ ]);
92
+ const conv = parser.getConversation(file);
93
+ expect(conv[0].content).toBe('Output:\n\n\n\n\n\nEnd.'.replace(/\n{3,}/g, '\n\n'));
94
+ expect(conv[0].content).not.toMatch(/<bash-/);
95
+ expect(conv[0].content).not.toMatch(/<command-stdout>|<command-stderr>/);
96
+ });
97
+ it('collapses <command-name>/<command-args> into "/name args" shorthand', ()=>{
98
+ const file = makeSession([
99
+ {
100
+ type: 'user',
101
+ message: {
102
+ content: '<command-message>debug</command-message>\n<command-name>/debug</command-name>\n<command-args>fix the bug</command-args>'
103
+ }
104
+ }
105
+ ]);
106
+ const conv = parser.getConversation(file);
107
+ expect(conv[0].content).toBe('/debug fix the bug');
108
+ });
109
+ it('handles <command-name> without <command-args>', ()=>{
110
+ const file = makeSession([
111
+ {
112
+ type: 'user',
113
+ message: {
114
+ content: '<command-message>clear</command-message>\n<command-name>/clear</command-name>'
115
+ }
116
+ }
117
+ ]);
118
+ const conv = parser.getConversation(file);
119
+ expect(conv[0].content).toBe('/clear');
120
+ });
121
+ it('handles multiple harness tags mixed together', ()=>{
122
+ const file = makeSession([
123
+ {
124
+ type: 'user',
125
+ message: {
126
+ content: [
127
+ {
128
+ type: 'text',
129
+ text: '<system-reminder>ignore me</system-reminder>\n<command-message>build</command-message>\n<command-name>/build</command-name>\n<command-args>--watch</command-args>\n<local-command-stdout>build output here</local-command-stdout>'
130
+ }
131
+ ]
132
+ }
133
+ }
134
+ ]);
135
+ const conv = parser.getConversation(file);
136
+ expect(conv[0].content).toBe('/build --watch');
137
+ });
138
+ it('leaves text without harness tags unchanged', ()=>{
139
+ const file = makeSession([
140
+ {
141
+ type: 'assistant',
142
+ message: {
143
+ content: [
144
+ {
145
+ type: 'text',
146
+ text: 'Hello, world!'
147
+ }
148
+ ]
149
+ }
150
+ }
151
+ ]);
152
+ const conv = parser.getConversation(file);
153
+ expect(conv[0].content).toBe('Hello, world!');
154
+ });
155
+ it('drops a message that becomes empty after stripping', ()=>{
156
+ const file = makeSession([
157
+ {
158
+ type: 'system',
159
+ message: {
160
+ content: '<system-reminder>only this</system-reminder>'
161
+ }
162
+ },
163
+ {
164
+ type: 'assistant',
165
+ message: {
166
+ content: [
167
+ {
168
+ type: 'text',
169
+ text: 'kept'
170
+ }
171
+ ]
172
+ }
173
+ }
174
+ ]);
175
+ const conv = parser.getConversation(file);
176
+ expect(conv).toHaveLength(1);
177
+ expect(conv[0].content).toBe('kept');
178
+ });
179
+ it('strips tags spanning multiple lines', ()=>{
180
+ const multilineReminder = '<system-reminder>\nLine 1\nLine 2\nLine 3\n</system-reminder>';
181
+ const file = makeSession([
182
+ {
183
+ type: 'assistant',
184
+ message: {
185
+ content: [
186
+ {
187
+ type: 'text',
188
+ text: `before\n${multilineReminder}\nafter`
189
+ }
190
+ ]
191
+ }
192
+ }
193
+ ]);
194
+ const conv = parser.getConversation(file);
195
+ expect(conv[0].content).toBe('before\n\nafter');
196
+ });
197
+ });
198
+ describe('ClaudeSessionParser.readSession — UI-state entries ignored for status', ()=>{
199
+ let parser;
200
+ const tempFiles = [];
201
+ beforeEach(()=>{
202
+ parser = new ClaudeSessionParser();
203
+ });
204
+ afterEach(()=>{
205
+ for (const f of tempFiles){
206
+ try {
207
+ fs.rmSync(path.dirname(f), {
208
+ recursive: true,
209
+ force: true
210
+ });
211
+ } catch {}
212
+ }
213
+ tempFiles.length = 0;
214
+ });
215
+ function writeRawSession(lines) {
216
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'parser-status-'));
217
+ const filePath = path.join(dir, 'session.jsonl');
218
+ fs.writeFileSync(filePath, lines.map((l)=>JSON.stringify(l)).join('\n'));
219
+ tempFiles.push(filePath);
220
+ return filePath;
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
+ for (const uiType of uiStateTypes){
232
+ it(`keeps lastEntryType from previous conversation turn when trailing entry is ${uiType}`, ()=>{
233
+ const file = writeRawSession([
234
+ {
235
+ type: 'user',
236
+ timestamp: '2026-05-30T06:17:57.189Z',
237
+ message: {
238
+ content: 'hello'
239
+ }
240
+ },
241
+ {
242
+ type: uiType,
243
+ timestamp: '2026-05-30T06:17:57.201Z'
244
+ }
245
+ ]);
246
+ const session = parser.readSession(file, '/test');
247
+ expect(session?.lastEntryType).toBe('user');
248
+ });
249
+ }
250
+ it('keeps lastEntryType from a user turn even with multiple trailing UI-state entries', ()=>{
251
+ const file = writeRawSession([
252
+ {
253
+ type: 'user',
254
+ timestamp: '2026-05-30T06:17:57.189Z',
255
+ message: {
256
+ content: 'hello'
257
+ }
258
+ },
259
+ {
260
+ type: 'attachment',
261
+ timestamp: '2026-05-30T06:17:57.200Z',
262
+ attachment: {
263
+ type: 'task_reminder',
264
+ content: []
265
+ }
266
+ },
267
+ {
268
+ type: 'permission-mode',
269
+ timestamp: '2026-05-30T06:17:57.210Z',
270
+ permissionMode: 'default'
271
+ },
272
+ {
273
+ type: 'ai-title',
274
+ timestamp: '2026-05-30T06:17:57.220Z'
275
+ }
276
+ ]);
277
+ const session = parser.readSession(file, '/test');
278
+ expect(session?.lastEntryType).toBe('user');
279
+ });
280
+ it('keeps lastEntryType from assistant turn when followed by UI-state events', ()=>{
281
+ const file = writeRawSession([
282
+ {
283
+ type: 'user',
284
+ timestamp: '2026-05-30T06:17:00.000Z',
285
+ message: {
286
+ content: 'go'
287
+ }
288
+ },
289
+ {
290
+ type: 'assistant',
291
+ timestamp: '2026-05-30T06:17:01.000Z',
292
+ message: {
293
+ content: [
294
+ {
295
+ type: 'text',
296
+ text: 'done'
297
+ }
298
+ ]
299
+ }
300
+ },
301
+ {
302
+ type: 'permission-mode',
303
+ timestamp: '2026-05-30T06:17:02.000Z',
304
+ permissionMode: 'default'
305
+ }
306
+ ]);
307
+ const session = parser.readSession(file, '/test');
308
+ expect(session?.lastEntryType).toBe('assistant');
309
+ });
310
+ });
311
+
312
+ //# sourceMappingURL=ClaudeSessionParser.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/__tests__/utils/ClaudeSessionParser.test.ts"],"sourcesContent":["/**\n * Tests for utils/ClaudeSessionParser.ts — focused on stripping\n * harness-injected XML tags from conversation content.\n */\n\n\nimport * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { ClaudeSessionParser } from '../../utils/ClaudeSessionParser.js';\n\ninterface JsonlEntry {\n type: 'user' | 'assistant' | 'system';\n message: { content: string | Array<{ type: string; text?: string; content?: string; name?: string; input?: unknown; is_error?: boolean }> };\n timestamp?: string;\n}\n\nfunction writeSession(entries: JsonlEntry[]): string {\n const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'parser-test-'));\n const filePath = path.join(dir, 'session.jsonl');\n fs.writeFileSync(filePath, entries.map(e => JSON.stringify(e)).join('\\n'));\n return filePath;\n}\n\ndescribe('ClaudeSessionParser.getConversation — harness tag stripping', () => {\n let parser: ClaudeSessionParser;\n const tempFiles: string[] = [];\n\n beforeEach(() => {\n parser = new ClaudeSessionParser();\n });\n\n afterEach(() => {\n for (const f of tempFiles) {\n try {\n fs.rmSync(path.dirname(f), { recursive: true, force: true });\n } catch { /* best effort */ }\n }\n tempFiles.length = 0;\n });\n\n function makeSession(entries: JsonlEntry[]): string {\n const f = writeSession(entries);\n tempFiles.push(f);\n return f;\n }\n\n it('strips <system-reminder> blocks from text content', () => {\n const file = makeSession([\n {\n type: 'assistant',\n message: {\n content: [{\n type: 'text',\n text: 'Real response here.\\n<system-reminder>\\nDo not mention this reminder.\\n</system-reminder>\\nMore response.',\n }],\n },\n },\n ]);\n\n const conv = parser.getConversation(file);\n expect(conv).toHaveLength(1);\n expect(conv[0].content).toBe('Real response here.\\n\\nMore response.');\n });\n\n it('strips <local-command-stdout> blocks', () => {\n const file = makeSession([\n {\n type: 'system',\n message: { content: 'before <local-command-stdout>\\nRunning...\\nDone\\n</local-command-stdout> after' },\n },\n ]);\n\n const conv = parser.getConversation(file);\n expect(conv[0].content).toBe('before after');\n });\n\n it('strips <user-prompt-submit-hook> blocks', () => {\n const file = makeSession([\n {\n type: 'user',\n message: { content: '<user-prompt-submit-hook>hook output</user-prompt-submit-hook>\\nactual question' },\n },\n ]);\n\n const conv = parser.getConversation(file);\n expect(conv[0].content).toBe('actual question');\n });\n\n it('strips bash and command stdout/stderr blocks', () => {\n const file = makeSession([\n {\n type: 'assistant',\n message: {\n content: [{\n type: 'text',\n text: 'Output:\\n<bash-input>ls</bash-input>\\n<bash-stdout>file.txt</bash-stdout>\\n<bash-stderr></bash-stderr>\\n<command-stdout>x</command-stdout>\\n<command-stderr>y</command-stderr>\\nEnd.',\n }],\n },\n },\n ]);\n\n const conv = parser.getConversation(file);\n expect(conv[0].content).toBe('Output:\\n\\n\\n\\n\\n\\nEnd.'.replace(/\\n{3,}/g, '\\n\\n'));\n expect(conv[0].content).not.toMatch(/<bash-/);\n expect(conv[0].content).not.toMatch(/<command-stdout>|<command-stderr>/);\n });\n\n it('collapses <command-name>/<command-args> into \"/name args\" shorthand', () => {\n const file = makeSession([\n {\n type: 'user',\n message: {\n content: '<command-message>debug</command-message>\\n<command-name>/debug</command-name>\\n<command-args>fix the bug</command-args>',\n },\n },\n ]);\n\n const conv = parser.getConversation(file);\n expect(conv[0].content).toBe('/debug fix the bug');\n });\n\n it('handles <command-name> without <command-args>', () => {\n const file = makeSession([\n {\n type: 'user',\n message: {\n content: '<command-message>clear</command-message>\\n<command-name>/clear</command-name>',\n },\n },\n ]);\n\n const conv = parser.getConversation(file);\n expect(conv[0].content).toBe('/clear');\n });\n\n it('handles multiple harness tags mixed together', () => {\n const file = makeSession([\n {\n type: 'user',\n message: {\n content: [{\n type: 'text',\n text: '<system-reminder>ignore me</system-reminder>\\n<command-message>build</command-message>\\n<command-name>/build</command-name>\\n<command-args>--watch</command-args>\\n<local-command-stdout>build output here</local-command-stdout>',\n }],\n },\n },\n ]);\n\n const conv = parser.getConversation(file);\n expect(conv[0].content).toBe('/build --watch');\n });\n\n it('leaves text without harness tags unchanged', () => {\n const file = makeSession([\n {\n type: 'assistant',\n message: { content: [{ type: 'text', text: 'Hello, world!' }] },\n },\n ]);\n\n const conv = parser.getConversation(file);\n expect(conv[0].content).toBe('Hello, world!');\n });\n\n it('drops a message that becomes empty after stripping', () => {\n const file = makeSession([\n {\n type: 'system',\n message: { content: '<system-reminder>only this</system-reminder>' },\n },\n {\n type: 'assistant',\n message: { content: [{ type: 'text', text: 'kept' }] },\n },\n ]);\n\n const conv = parser.getConversation(file);\n expect(conv).toHaveLength(1);\n expect(conv[0].content).toBe('kept');\n });\n\n it('strips tags spanning multiple lines', () => {\n const multilineReminder = '<system-reminder>\\nLine 1\\nLine 2\\nLine 3\\n</system-reminder>';\n const file = makeSession([\n {\n type: 'assistant',\n message: { content: [{ type: 'text', text: `before\\n${multilineReminder}\\nafter` }] },\n },\n ]);\n\n const conv = parser.getConversation(file);\n expect(conv[0].content).toBe('before\\n\\nafter');\n });\n});\n\ndescribe('ClaudeSessionParser.readSession — UI-state entries ignored for status', () => {\n let parser: ClaudeSessionParser;\n const tempFiles: string[] = [];\n\n beforeEach(() => {\n parser = new ClaudeSessionParser();\n });\n\n afterEach(() => {\n for (const f of tempFiles) {\n try {\n fs.rmSync(path.dirname(f), { recursive: true, force: true });\n } catch { /* best effort */ }\n }\n tempFiles.length = 0;\n });\n\n function writeRawSession(lines: object[]): string {\n const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'parser-status-'));\n const filePath = path.join(dir, 'session.jsonl');\n fs.writeFileSync(filePath, lines.map(l => JSON.stringify(l)).join('\\n'));\n tempFiles.push(filePath);\n return filePath;\n }\n\n const uiStateTypes = [\n 'attachment',\n 'permission-mode',\n 'ai-title',\n 'queued_command',\n 'tools_changed',\n 'model_changed',\n 'hook_progress',\n ];\n\n for (const uiType of uiStateTypes) {\n it(`keeps lastEntryType from previous conversation turn when trailing entry is ${uiType}`, () => {\n const file = writeRawSession([\n { type: 'user', timestamp: '2026-05-30T06:17:57.189Z', message: { content: 'hello' } },\n { type: uiType, timestamp: '2026-05-30T06:17:57.201Z' },\n ]);\n\n const session = parser.readSession(file, '/test');\n expect(session?.lastEntryType).toBe('user');\n });\n }\n\n it('keeps lastEntryType from a user turn even with multiple trailing UI-state entries', () => {\n const file = writeRawSession([\n { type: 'user', timestamp: '2026-05-30T06:17:57.189Z', message: { content: 'hello' } },\n { type: 'attachment', timestamp: '2026-05-30T06:17:57.200Z', attachment: { type: 'task_reminder', content: [] } },\n { type: 'permission-mode', timestamp: '2026-05-30T06:17:57.210Z', permissionMode: 'default' },\n { type: 'ai-title', timestamp: '2026-05-30T06:17:57.220Z' },\n ]);\n\n const session = parser.readSession(file, '/test');\n expect(session?.lastEntryType).toBe('user');\n });\n\n it('keeps lastEntryType from assistant turn when followed by UI-state events', () => {\n const file = writeRawSession([\n { type: 'user', timestamp: '2026-05-30T06:17:00.000Z', message: { content: 'go' } },\n { type: 'assistant', timestamp: '2026-05-30T06:17:01.000Z', message: { content: [{ type: 'text', text: 'done' }] } },\n { type: 'permission-mode', timestamp: '2026-05-30T06:17:02.000Z', permissionMode: 'default' },\n ]);\n\n const session = parser.readSession(file, '/test');\n expect(session?.lastEntryType).toBe('assistant');\n });\n});\n"],"names":["fs","os","path","ClaudeSessionParser","writeSession","entries","dir","mkdtempSync","join","tmpdir","filePath","writeFileSync","map","e","JSON","stringify","describe","parser","tempFiles","beforeEach","afterEach","f","rmSync","dirname","recursive","force","length","makeSession","push","it","file","type","message","content","text","conv","getConversation","expect","toHaveLength","toBe","replace","not","toMatch","multilineReminder","writeRawSession","lines","l","uiStateTypes","uiType","timestamp","session","readSession","lastEntryType","attachment","permissionMode"],"mappings":"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"}