@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,175 @@
1
+ import { execFile } from 'child_process';
2
+ import type { MockedFunction } from 'vitest';
3
+ import { TmuxManager } from '../../terminal/TmuxManager.js';
4
+
5
+ vi.mock('child_process', () => ({
6
+ execFile: vi.fn(),
7
+ }));
8
+
9
+ type ExecFileCb = (err: Error | null, result?: { stdout: string; stderr: string }) => void;
10
+ const mockedExecFile = execFile as unknown as MockedFunction<
11
+ (cmd: string, args: string[], cb: ExecFileCb) => void
12
+ >;
13
+
14
+ /** Drive the promisified execFile mock with a per-call handler. */
15
+ function setExecFileHandler(handler: (cmd: string, args: string[]) => string | Error) {
16
+ mockedExecFile.mockImplementation((_cmd, _args, cb) => {
17
+ const result = handler(_cmd, _args);
18
+ if (result instanceof Error) cb(result);
19
+ else cb(null, { stdout: result, stderr: '' });
20
+ });
21
+ }
22
+
23
+ describe('TmuxManager', () => {
24
+ let tmux: TmuxManager;
25
+
26
+ beforeEach(() => {
27
+ tmux = new TmuxManager();
28
+ mockedExecFile.mockReset();
29
+ });
30
+
31
+ describe('isAvailable', () => {
32
+ it('returns true when `tmux -V` succeeds', async () => {
33
+ setExecFileHandler(() => 'tmux 3.4');
34
+ expect(await tmux.isAvailable()).toBe(true);
35
+ });
36
+
37
+ it('returns false when tmux is missing', async () => {
38
+ setExecFileHandler(() => new Error('ENOENT'));
39
+ expect(await tmux.isAvailable()).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe('sessionExists', () => {
44
+ it('returns true when has-session succeeds', async () => {
45
+ setExecFileHandler(() => '');
46
+ expect(await tmux.sessionExists('foo')).toBe(true);
47
+ });
48
+
49
+ it('returns false when has-session fails', async () => {
50
+ setExecFileHandler(() => new Error('no session'));
51
+ expect(await tmux.sessionExists('foo')).toBe(false);
52
+ });
53
+ });
54
+
55
+ describe('createSession', () => {
56
+ it('issues `tmux new-session -d -s <name> -c <cwd>`', async () => {
57
+ setExecFileHandler(() => '');
58
+ await tmux.createSession('foo', '/work');
59
+ expect(mockedExecFile).toHaveBeenCalledWith(
60
+ 'tmux',
61
+ ['new-session', '-d', '-s', 'foo', '-c', '/work'],
62
+ expect.any(Function),
63
+ );
64
+ });
65
+ });
66
+
67
+ describe('sendKeys', () => {
68
+ it('appends Enter so the command runs', async () => {
69
+ setExecFileHandler(() => '');
70
+ await tmux.sendKeys('foo', 'claude');
71
+ expect(mockedExecFile).toHaveBeenCalledWith(
72
+ 'tmux',
73
+ ['send-keys', '-t', 'foo', 'claude', 'Enter'],
74
+ expect.any(Function),
75
+ );
76
+ });
77
+ });
78
+
79
+ describe('killSession', () => {
80
+ it('swallows errors when session is already gone', async () => {
81
+ setExecFileHandler(() => new Error("can't find session"));
82
+ await expect(tmux.killSession('foo')).resolves.toBeUndefined();
83
+ });
84
+ });
85
+
86
+ describe('findAgentPid', () => {
87
+ const matchesClaude = (cmd: string) => cmd.split(/\s+/)[0]?.endsWith('claude') ?? false;
88
+
89
+ it('returns null when the session has no pane', async () => {
90
+ setExecFileHandler((cmd, args) => {
91
+ if (args[0] === 'list-panes') return new Error('no session');
92
+ return '';
93
+ });
94
+ expect(await tmux.findAgentPid('foo', matchesClaude)).toBeNull();
95
+ });
96
+
97
+ it('returns null when no descendant matches', async () => {
98
+ // pane PID 100; child 200 is "node /unrelated" — no match
99
+ setExecFileHandler((cmd, args) => {
100
+ if (cmd === 'tmux' && args[0] === 'list-panes') return '100\n';
101
+ if (cmd === 'pgrep' && args[1] === '100') return '200\n';
102
+ if (cmd === 'pgrep') return new Error('no children');
103
+ if (cmd === 'ps' && args[1] === '200') return 'node /unrelated';
104
+ return '';
105
+ });
106
+ expect(await tmux.findAgentPid('foo', matchesClaude)).toBeNull();
107
+ });
108
+
109
+ it('returns the matching descendant when found', async () => {
110
+ // pane 100 → child 200 (claude) — no grandchildren
111
+ setExecFileHandler((cmd, args) => {
112
+ if (cmd === 'tmux') return '100\n';
113
+ if (cmd === 'pgrep' && args[1] === '100') return '200\n';
114
+ if (cmd === 'pgrep') return new Error('no children');
115
+ if (cmd === 'ps' && args[1] === '200') return 'claude';
116
+ return '';
117
+ });
118
+ expect(await tmux.findAgentPid('foo', matchesClaude)).toBe(200);
119
+ });
120
+
121
+ it('prefers the deepest match (wrapper case)', async () => {
122
+ // pane 100 → 200 (claude wrapper, matches) → 300 (claude, matches, deeper)
123
+ setExecFileHandler((cmd, args) => {
124
+ if (cmd === 'tmux') return '100\n';
125
+ if (cmd === 'pgrep' && args[1] === '100') return '200\n';
126
+ if (cmd === 'pgrep' && args[1] === '200') return '300\n';
127
+ if (cmd === 'pgrep') return new Error('no children');
128
+ if (cmd === 'ps' && args[1] === '200') return '/usr/local/bin/claude';
129
+ if (cmd === 'ps' && args[1] === '300') return '/usr/local/lib/claude';
130
+ return '';
131
+ });
132
+ expect(await tmux.findAgentPid('foo', matchesClaude)).toBe(300);
133
+ });
134
+
135
+ it('skips non-matching subprocesses (MCP child case)', async () => {
136
+ // pane 100 → 200 (claude, matches) → 300 (mcp-server, no match)
137
+ setExecFileHandler((cmd, args) => {
138
+ if (cmd === 'tmux') return '100\n';
139
+ if (cmd === 'pgrep' && args[1] === '100') return '200\n';
140
+ if (cmd === 'pgrep' && args[1] === '200') return '300\n';
141
+ if (cmd === 'pgrep') return new Error('no children');
142
+ if (cmd === 'ps' && args[1] === '200') return 'claude';
143
+ if (cmd === 'ps' && args[1] === '300') return 'node mcp-server.js';
144
+ return '';
145
+ });
146
+ expect(await tmux.findAgentPid('foo', matchesClaude)).toBe(200);
147
+ });
148
+
149
+ it('handles the gemini Node-script shape via a token-scan matcher', async () => {
150
+ const matchesGemini = (cmd: string) =>
151
+ cmd.split(/\s+/).some((t) => t.endsWith('/gemini') || t === 'gemini');
152
+ setExecFileHandler((cmd, args) => {
153
+ if (cmd === 'tmux') return '100\n';
154
+ if (cmd === 'pgrep' && args[1] === '100') return '200\n';
155
+ if (cmd === 'pgrep') return new Error('no children');
156
+ if (cmd === 'ps' && args[1] === '200') return 'node /opt/homebrew/bin/gemini --foo';
157
+ return '';
158
+ });
159
+ expect(await tmux.findAgentPid('foo', matchesGemini)).toBe(200);
160
+ });
161
+
162
+ it('handles multiple children at the same level', async () => {
163
+ // pane 100 → [200 (nope), 201 (claude)]
164
+ setExecFileHandler((cmd, args) => {
165
+ if (cmd === 'tmux') return '100\n';
166
+ if (cmd === 'pgrep' && args[1] === '100') return '200\n201\n';
167
+ if (cmd === 'pgrep') return new Error('no children');
168
+ if (cmd === 'ps' && args[1] === '200') return 'bash';
169
+ if (cmd === 'ps' && args[1] === '201') return 'claude';
170
+ return '';
171
+ });
172
+ expect(await tmux.findAgentPid('foo', matchesClaude)).toBe(201);
173
+ });
174
+ });
175
+ });
@@ -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
+ });
@@ -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
+ });
@@ -8,7 +8,7 @@
8
8
  /**
9
9
  * Type of AI agent
10
10
  */
11
- export type AgentType = 'claude' | 'gemini_cli' | 'codex' | 'opencode' | 'other';
11
+ export type AgentType = 'claude' | 'gemini_cli' | 'codex' | 'opencode' | 'copilot' | 'other';
12
12
 
13
13
  /**
14
14
  * Current status of an agent