@ai-devkit/agent-manager 0.15.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.
Files changed (47) hide show
  1. package/dist/AgentManager.d.ts +4 -0
  2. package/dist/AgentManager.d.ts.map +1 -1
  3. package/dist/AgentManager.js +35 -0
  4. package/dist/AgentManager.js.map +1 -1
  5. package/dist/adapters/CodexAdapter.d.ts +4 -1
  6. package/dist/adapters/CodexAdapter.d.ts.map +1 -1
  7. package/dist/adapters/CodexAdapter.js +35 -6
  8. package/dist/adapters/CodexAdapter.js.map +1 -1
  9. package/dist/adapters/GeminiCliAdapter.d.ts +4 -1
  10. package/dist/adapters/GeminiCliAdapter.d.ts.map +1 -1
  11. package/dist/adapters/GeminiCliAdapter.js +35 -6
  12. package/dist/adapters/GeminiCliAdapter.js.map +1 -1
  13. package/dist/index.d.ts +5 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/terminal/TmuxManager.d.ts +28 -0
  18. package/dist/terminal/TmuxManager.d.ts.map +1 -0
  19. package/dist/terminal/TmuxManager.js +112 -0
  20. package/dist/terminal/TmuxManager.js.map +1 -0
  21. package/dist/utils/AgentRegistry.d.ts +35 -0
  22. package/dist/utils/AgentRegistry.d.ts.map +1 -0
  23. package/dist/utils/AgentRegistry.js +114 -0
  24. package/dist/utils/AgentRegistry.js.map +1 -0
  25. package/dist/utils/ClaudeSessionParser.d.ts.map +1 -1
  26. package/dist/utils/ClaudeSessionParser.js +21 -11
  27. package/dist/utils/ClaudeSessionParser.js.map +1 -1
  28. package/dist/utils/agents.d.ts +15 -0
  29. package/dist/utils/agents.d.ts.map +1 -0
  30. package/dist/utils/agents.js +30 -0
  31. package/dist/utils/agents.js.map +1 -0
  32. package/package.json +1 -1
  33. package/src/AgentManager.ts +40 -0
  34. package/src/__tests__/AgentManager.test.ts +135 -0
  35. package/src/__tests__/adapters/ClaudeCodeAdapter.test.ts +1 -2
  36. package/src/__tests__/adapters/CodexAdapter.test.ts +105 -0
  37. package/src/__tests__/adapters/GeminiCliAdapter.test.ts +111 -0
  38. package/src/__tests__/terminal/TmuxManager.test.ts +175 -0
  39. package/src/__tests__/utils/AgentRegistry.test.ts +223 -0
  40. package/src/__tests__/utils/ClaudeSessionParser.test.ts +71 -0
  41. package/src/adapters/CodexAdapter.ts +45 -6
  42. package/src/adapters/GeminiCliAdapter.ts +45 -6
  43. package/src/index.ts +6 -0
  44. package/src/terminal/TmuxManager.ts +118 -0
  45. package/src/utils/AgentRegistry.ts +139 -0
  46. package/src/utils/ClaudeSessionParser.ts +21 -10
  47. package/src/utils/agents.ts +41 -0
@@ -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
+ });
@@ -25,6 +25,7 @@ import { listAgentProcesses, enrichProcesses } from '../utils/process.js';
25
25
  import { batchGetSessionFileBirthtimes, isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session.js';
26
26
  import type { SessionFile } from '../utils/session.js';
27
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 { sessions, contentCache } = this.discoverSessions(processes);
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 processes.map((p) => this.mapProcessOnlyAgent(p));
83
+ return [...cachedAgents, ...remaining.map((p) => this.mapProcessOnlyAgent(p))];
78
84
  }
79
85
 
80
- const matches = matchProcessesToSessions(processes, sessions);
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 processes) {
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
  /**
@@ -26,6 +26,7 @@ import { listAgentProcesses, enrichProcesses } from '../utils/process.js';
26
26
  import { isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session.js';
27
27
  import type { SessionFile } from '../utils/session.js';
28
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 { sessions, contentCache } = this.discoverSessions(processes);
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 processes.map((p) => this.mapProcessOnlyAgent(p));
125
+ return [...cachedAgents, ...remaining.map((p) => this.mapProcessOnlyAgent(p))];
120
126
  }
121
127
 
122
- const matches = matchProcessesToSessions(processes, sessions);
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 processes) {
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
  /**
package/src/index.ts CHANGED
@@ -22,3 +22,9 @@ export { TtyWriter } from './terminal/TtyWriter.js';
22
22
  export { getProcessTty } from './utils/process.js';
23
23
  export type { AgentSortKey } from './utils/sortAgents.js';
24
24
  export type { ListAgentsOptions } from './AgentManager.js';
25
+
26
+ export { AgentRegistry, RenameNotFoundError, RenameConflictError } from './utils/AgentRegistry.js';
27
+ export type { RegistryEntry } from './utils/AgentRegistry.js';
28
+ export { TmuxManager } from './terminal/TmuxManager.js';
29
+ export { AGENTS } from './utils/agents.js';
30
+ export type { AgentConfig, StartableAgentType } from './utils/agents.js';
@@ -0,0 +1,118 @@
1
+ import { execFile } from 'child_process';
2
+ import { promisify } from 'util';
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ export class TmuxManager {
7
+ async isAvailable(): Promise<boolean> {
8
+ try {
9
+ await execFileAsync('tmux', ['-V']);
10
+ return true;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ async sessionExists(name: string): Promise<boolean> {
17
+ try {
18
+ await execFileAsync('tmux', ['has-session', '-t', name]);
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ async createSession(name: string, cwd: string): Promise<void> {
26
+ await execFileAsync('tmux', ['new-session', '-d', '-s', name, '-c', cwd]);
27
+ }
28
+
29
+ async sendKeys(session: string, keys: string): Promise<void> {
30
+ await execFileAsync('tmux', ['send-keys', '-t', session, keys, 'Enter']);
31
+ }
32
+
33
+ async killSession(name: string): Promise<void> {
34
+ try {
35
+ await execFileAsync('tmux', ['kill-session', '-t', name]);
36
+ } catch {
37
+ // Already gone — ignore
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Find the actual agent process PID inside a tmux pane.
43
+ *
44
+ * Strategy: BFS the process tree, return the deepest descendant whose
45
+ * `ps` command line is accepted by `matches`. The caller supplies the
46
+ * matcher so this method has no agent-type knowledge.
47
+ *
48
+ * This handles two real-world process shapes:
49
+ * - Wrapper case: shell → claude-wrapper (matches) → claude (matches, deeper)
50
+ * → returns the deeper one
51
+ * - Subprocess case: shell → claude (matches) → MCP server child (doesn't match)
52
+ * → returns claude, not the subprocess
53
+ *
54
+ * Returns null when no descendant matches yet (agent still starting); the
55
+ * caller's poll loop retries.
56
+ */
57
+ async findAgentPid(session: string, matches: (psCommand: string) => boolean): Promise<number | null> {
58
+ const panePid = await this.getPanePid(session);
59
+ if (panePid === null) return null;
60
+
61
+ const visited = new Set<number>();
62
+ const queue: number[] = [panePid];
63
+ let deepestMatch: number | null = null;
64
+
65
+ while (queue.length > 0) {
66
+ const pid = queue.shift()!;
67
+ if (visited.has(pid)) continue;
68
+ visited.add(pid);
69
+
70
+ if (pid !== panePid) {
71
+ const command = await this.getProcessCommand(pid);
72
+ if (command && matches(command)) {
73
+ deepestMatch = pid;
74
+ }
75
+ }
76
+
77
+ const children = await this.pgrepChildren(pid);
78
+ queue.push(...children);
79
+ }
80
+
81
+ return deepestMatch;
82
+ }
83
+
84
+ private async getPanePid(session: string): Promise<number | null> {
85
+ try {
86
+ const { stdout } = await execFileAsync('tmux', [
87
+ 'list-panes', '-t', session, '-F', '#{pane_pid}',
88
+ ]);
89
+ const panePid = parseInt(stdout.trim().split('\n')[0], 10);
90
+ return isNaN(panePid) ? null : panePid;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ private async pgrepChildren(pid: number): Promise<number[]> {
97
+ try {
98
+ const { stdout } = await execFileAsync('pgrep', ['-P', String(pid)]);
99
+ return stdout
100
+ .trim()
101
+ .split('\n')
102
+ .map((s) => parseInt(s, 10))
103
+ .filter((n) => !isNaN(n));
104
+ } catch {
105
+ return [];
106
+ }
107
+ }
108
+
109
+ private async getProcessCommand(pid: number): Promise<string | null> {
110
+ try {
111
+ const { stdout } = await execFileAsync('ps', ['-p', String(pid), '-o', 'command=']);
112
+ const trimmed = stdout.trim();
113
+ return trimmed || null;
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+ }