@ai-devkit/agent-manager 0.26.3 → 0.27.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.
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
+ import Database from 'better-sqlite3';
4
5
  import { AgentRegistry, RenameNotFoundError, RenameConflictError, type RegistryEntry } from '../../utils/AgentRegistry.js';
5
6
 
6
7
  function makeEntry(over: Partial<RegistryEntry> = {}): RegistryEntry {
@@ -13,6 +14,7 @@ function makeEntry(over: Partial<RegistryEntry> = {}): RegistryEntry {
13
14
  startedAt: '2026-05-30T00:00:00.000Z',
14
15
  sessionId: 'sid-1',
15
16
  sessionFilePath: '/tmp/session.jsonl',
17
+ pinned: false,
16
18
  ...over,
17
19
  };
18
20
  }
@@ -29,6 +31,7 @@ describe('AgentRegistry', () => {
29
31
  });
30
32
 
31
33
  afterEach(() => {
34
+ vi.restoreAllMocks();
32
35
  fs.rmSync(tmpDir, { recursive: true, force: true });
33
36
  });
34
37
 
@@ -81,6 +84,19 @@ describe('AgentRegistry', () => {
81
84
  expect(registry.lookup(`ai-devkit-${process.pid}`)).toBeNull();
82
85
  expect(registry.list()).toHaveLength(1);
83
86
  });
87
+
88
+ it('preserves an existing name conflict when its probe fails with EPERM', () => {
89
+ registry.register(makeEntry({ name: 'claimed-name', pid: process.pid }));
90
+ vi.spyOn(process, 'kill').mockImplementation(() => {
91
+ throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
92
+ });
93
+
94
+ expect(() => registry.register(makeEntry({
95
+ name: 'claimed-name',
96
+ pid: process.pid + 1,
97
+ }))).toThrow();
98
+ expect(registry.lookup('claimed-name')?.pid).toBe(process.pid);
99
+ });
84
100
  });
85
101
 
86
102
  describe('registerBatch', () => {
@@ -118,6 +134,33 @@ describe('AgentRegistry', () => {
118
134
  expect(registry.list()).toHaveLength(1);
119
135
  expect(registry.lookup('custom-name')?.pid).toBe(process.pid);
120
136
  });
137
+
138
+ it('cleans up a cross-type row when its pid has been reused', () => {
139
+ registry.register(makeEntry({ name: 'old-claude', type: 'claude', pid: process.pid }));
140
+
141
+ registry.register(makeEntry({
142
+ name: 'new-codex',
143
+ type: 'codex',
144
+ pid: process.pid,
145
+ tmuxSession: '',
146
+ }));
147
+
148
+ expect(registry.lookup('old-claude')).toBeNull();
149
+ expect(registry.lookup('new-codex')).toMatchObject({ type: 'codex', pid: process.pid });
150
+ expect(registry.list()).toHaveLength(1);
151
+ });
152
+
153
+ it('rolls back the whole batch when a live name conflict rejects one entry', () => {
154
+ registry.register(makeEntry({ name: 'taken', pid: process.pid }));
155
+
156
+ expect(() => registry.registerBatch([
157
+ makeEntry({ name: 'fresh', pid: 999998 }),
158
+ makeEntry({ name: 'taken', type: 'codex', pid: 999997 }),
159
+ ])).toThrow(/UNIQUE constraint failed/);
160
+
161
+ expect(registry.lookup('fresh')).toBeNull();
162
+ expect(registry.lookup('taken')?.pid).toBe(process.pid);
163
+ });
121
164
  });
122
165
 
123
166
  describe('lookup', () => {
@@ -131,6 +174,72 @@ describe('AgentRegistry', () => {
131
174
  });
132
175
  });
133
176
 
177
+ describe('pinning', () => {
178
+ it('defaults new rows to unpinned and toggles the persisted state', () => {
179
+ registry.register(makeEntry());
180
+
181
+ expect(registry.lookup('agent1')?.pinned).toBe(false);
182
+ expect(registry.togglePin('claude', process.pid)).toBe(true);
183
+ expect(registry.lookup('agent1')?.pinned).toBe(true);
184
+ expect(registry.togglePin('claude', process.pid)).toBe(false);
185
+ expect(registry.lookup('agent1')?.pinned).toBe(false);
186
+ });
187
+
188
+ it('updates existing recency when toggled', () => {
189
+ let now = new Date('2026-08-16T10:00:00.000Z');
190
+ const clocked = new AgentRegistry(regPath, { now: () => now });
191
+ clocked.register(makeEntry());
192
+ now = new Date('2026-08-16T10:01:00.000Z');
193
+
194
+ clocked.togglePin('claude', process.pid);
195
+
196
+ expect(clocked.lookup('agent1')?.updatedAt).toBe(now.toISOString());
197
+ const db = new Database(regPath.replace(/\.json$/, '.db'), { readonly: true });
198
+ const row = db.prepare('SELECT updated_at FROM agents WHERE type = ? AND pid = ?')
199
+ .get('claude', process.pid) as { updated_at: string };
200
+ db.close();
201
+ expect(row.updated_at).toBe(now.toISOString());
202
+ });
203
+
204
+ it('returns null when the process row has disappeared', () => {
205
+ expect(registry.togglePin('claude', 999999)).toBeNull();
206
+ });
207
+
208
+ it('preserves a pin when poll registration updates the row', () => {
209
+ registry.register(makeEntry({ sessionId: 'before' }));
210
+ registry.togglePin('claude', process.pid);
211
+
212
+ registry.register(makeEntry({ sessionId: 'after' }));
213
+
214
+ expect(registry.lookup('agent1')).toMatchObject({ sessionId: 'after', pinned: true });
215
+ });
216
+
217
+ it('preserves a pin through rename', () => {
218
+ registry.register(makeEntry({ name: 'before' }));
219
+ registry.togglePin('claude', process.pid);
220
+
221
+ registry.rename('before', 'after');
222
+
223
+ expect(registry.lookup('after')?.pinned).toBe(true);
224
+ });
225
+
226
+ it('removes the pin with a pruned process row', () => {
227
+ registry.register(makeEntry({ pid: 999999 }));
228
+ registry.togglePin('claude', 999999);
229
+
230
+ registry.prune();
231
+
232
+ expect(registry.lookup('agent1')).toBeNull();
233
+ });
234
+
235
+ it('reports a clear error when a readonly registry toggles a pin', () => {
236
+ registry.register(makeEntry());
237
+ const readonlyRegistry = new AgentRegistry(regPath, { readonly: true });
238
+
239
+ expect(() => readonlyRegistry.togglePin('claude', process.pid)).toThrow(/readonly/i);
240
+ });
241
+ });
242
+
134
243
  describe('list', () => {
135
244
  it('returns empty array when database does not contain entries', () => {
136
245
  expect(registry.list()).toEqual([]);
@@ -157,6 +266,30 @@ describe('AgentRegistry', () => {
157
266
  it('returns false for a PID that does not exist', () => {
158
267
  expect(registry.isAlive(makeEntry({ pid: 999999 }))).toBe(false);
159
268
  });
269
+
270
+ it('returns true when the process probe is forbidden with EPERM', () => {
271
+ vi.spyOn(process, 'kill').mockImplementation(() => {
272
+ throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
273
+ });
274
+
275
+ expect(registry.isAlive(makeEntry())).toBe(true);
276
+ });
277
+
278
+ it('returns false when the process probe reports ESRCH', () => {
279
+ vi.spyOn(process, 'kill').mockImplementation(() => {
280
+ throw Object.assign(new Error('no such process'), { code: 'ESRCH' });
281
+ });
282
+
283
+ expect(registry.isAlive(makeEntry())).toBe(false);
284
+ });
285
+
286
+ it('returns true when the process probe fails without a definitive error code', () => {
287
+ vi.spyOn(process, 'kill').mockImplementation(() => {
288
+ throw new Error('indeterminate probe failure');
289
+ });
290
+
291
+ expect(registry.isAlive(makeEntry())).toBe(true);
292
+ });
160
293
  });
161
294
 
162
295
  describe('prune', () => {
@@ -177,9 +310,52 @@ describe('AgentRegistry', () => {
177
310
  expect(after).toEqual(before);
178
311
  });
179
312
 
313
+ it('preserves entries when liveness probing fails with EPERM', () => {
314
+ registry.register(makeEntry({ name: 'custom-name', tmuxSession: 'tmux-custom' }));
315
+ vi.spyOn(process, 'kill').mockImplementation(() => {
316
+ throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
317
+ });
318
+
319
+ registry.prune();
320
+
321
+ expect(registry.lookup('custom-name')).toMatchObject({
322
+ name: 'custom-name',
323
+ tmuxSession: 'tmux-custom',
324
+ });
325
+ });
326
+
327
+ it('removes entries when liveness probing fails with ESRCH', () => {
328
+ registry.register(makeEntry({ name: 'dead' }));
329
+ vi.spyOn(process, 'kill').mockImplementation(() => {
330
+ throw Object.assign(new Error('no such process'), { code: 'ESRCH' });
331
+ });
332
+
333
+ registry.prune();
334
+
335
+ expect(registry.lookup('dead')).toBeNull();
336
+ });
337
+
180
338
  it('does nothing when file is missing', () => {
181
339
  expect(() => registry.prune()).not.toThrow();
182
340
  });
341
+
342
+ it('keeps forced prune available before the passive cadence is due', () => {
343
+ let nowMs = Date.parse('2026-08-14T10:00:00.000Z');
344
+ const clocked = new AgentRegistry(regPath, {
345
+ now: () => new Date(nowMs),
346
+ pruneIntervalMs: 30_000,
347
+ });
348
+ clocked.register(makeEntry({ name: 'forced', pid: process.pid }));
349
+ const alive = vi.spyOn(clocked, 'isAlive').mockReturnValue(true);
350
+ clocked.pruneIfDue();
351
+ alive.mockReturnValue(false);
352
+ nowMs += 1;
353
+
354
+ clocked.prune();
355
+
356
+ expect(alive).toHaveBeenCalledTimes(2);
357
+ expect(clocked.lookup('forced')).toBeNull();
358
+ });
183
359
  });
184
360
 
185
361
  describe('default()', () => {
@@ -215,6 +391,17 @@ describe('AgentRegistry', () => {
215
391
  expect(() => registry.rename('agent-a', 'agent-b')).toThrow(RenameConflictError);
216
392
  });
217
393
 
394
+ it('throws RenameConflictError when the conflicting entry probe fails with EPERM', () => {
395
+ registry.register(makeEntry({ name: 'agent-a', pid: process.pid }));
396
+ registry.register(makeEntry({ name: 'agent-b', pid: process.ppid }));
397
+ vi.spyOn(process, 'kill').mockImplementation(() => {
398
+ throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
399
+ });
400
+
401
+ expect(() => registry.rename('agent-a', 'agent-b')).toThrow(RenameConflictError);
402
+ expect(registry.lookup('agent-b')?.pid).toBe(process.ppid);
403
+ });
404
+
218
405
  it('succeeds when new name exists only as a stale (dead) entry', () => {
219
406
  registry.register(makeEntry({ name: 'agent-a', pid: process.pid }));
220
407
  registry.register(makeEntry({ name: 'agent-b', pid: 999999 }));
@@ -48,6 +48,9 @@ export interface AgentInfo {
48
48
  /** Timestamp of last activity */
49
49
  lastActive: Date;
50
50
 
51
+ /** Whether the live process is pinned in the agent console */
52
+ pinned?: boolean;
53
+
51
54
  /** Path to the session JSONL file on disk */
52
55
  sessionFilePath?: string;
53
56
  }
@@ -8,7 +8,7 @@ export const DEFAULT_AGENT_REGISTRY_DB_PATH = join(homedir(), '.ai-devkit', 'age
8
8
 
9
9
  export interface DatabaseOptions {
10
10
  dbPath?: string;
11
- verbose?: boolean;
11
+ verbose?: boolean | ((message: string) => void);
12
12
  readonly?: boolean;
13
13
  }
14
14
 
@@ -20,21 +20,30 @@ export function resolveAgentRegistryDbPath(filePath?: string): string {
20
20
  export class DatabaseConnection {
21
21
  private db: Database.Database;
22
22
  private readonly dbPath: string;
23
+ private readonly readonly: boolean;
23
24
 
24
25
  constructor(options: DatabaseOptions = {}) {
25
26
  this.dbPath = options.dbPath ?? DEFAULT_AGENT_REGISTRY_DB_PATH;
27
+ this.readonly = options.readonly ?? false;
26
28
  mkdirSync(dirname(this.dbPath), { recursive: true });
27
29
 
28
30
  this.db = new Database(this.dbPath, {
29
- readonly: options.readonly ?? false,
30
- verbose: options.verbose ? console.log : undefined,
31
+ readonly: this.readonly,
32
+ verbose: typeof options.verbose === 'function'
33
+ ? options.verbose
34
+ : options.verbose ? console.log : undefined,
31
35
  });
32
36
 
33
37
  this.configure();
34
- initializeSchema(this);
38
+ if (!this.readonly) initializeSchema(this);
35
39
  }
36
40
 
37
41
  private configure(): void {
42
+ if (this.readonly) {
43
+ this.db.pragma('foreign_keys = ON');
44
+ this.db.pragma('busy_timeout = 5000');
45
+ return;
46
+ }
38
47
  this.db.pragma('journal_mode = WAL');
39
48
  this.db.pragma('foreign_keys = ON');
40
49
  this.db.pragma('synchronous = NORMAL');
@@ -0,0 +1 @@
1
+ ALTER TABLE agents ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0;
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { AgentManager } from './AgentManager.js';
1
+ export { AgentManager, AgentNotRunningError } from './AgentManager.js';
2
2
 
3
3
  export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter.js';
4
4
  export { CodexAdapter } from './adapters/CodexAdapter.js';
@@ -29,7 +29,7 @@ export type { AgentSortKey } from './utils/sortAgents.js';
29
29
  export type { ListAgentsOptions } from './AgentManager.js';
30
30
 
31
31
  export { AgentRegistry, RenameNotFoundError, RenameConflictError } from './utils/AgentRegistry.js';
32
- export type { RegistryEntry } from './utils/AgentRegistry.js';
32
+ export type { AgentRegistryOptions, RegistryEntry } from './utils/AgentRegistry.js';
33
33
  export { TmuxManager } from './terminal/TmuxManager.js';
34
34
  export { AGENTS } from './utils/agents.js';
35
35
  export type { AgentConfig, StartableAgentType } from './utils/agents.js';
@@ -29,6 +29,8 @@ export interface RegistryEntry {
29
29
  startedAt: string; // ISO 8601
30
30
  sessionId: string;
31
31
  sessionFilePath: string;
32
+ pinned: boolean;
33
+ updatedAt?: string;
32
34
  }
33
35
 
34
36
  interface RegistryRow {
@@ -41,17 +43,37 @@ interface RegistryRow {
41
43
  session_id: string;
42
44
  session_file_path: string;
43
45
  updated_at: string;
46
+ pinned: number;
44
47
  }
45
48
 
46
49
  const DEFAULT_REGISTRY_PATH = path.join(os.homedir(), '.ai-devkit', 'agents.json');
50
+ const DEFAULT_PRUNE_INTERVAL_MS = 30_000;
47
51
 
48
52
  let defaultInstance: AgentRegistry | null = null;
49
53
 
54
+ export interface AgentRegistryOptions {
55
+ now?: () => Date;
56
+ pruneIntervalMs?: number;
57
+ onDatabaseOperation?: (sql: string) => void;
58
+ readonly?: boolean;
59
+ }
60
+
50
61
  export class AgentRegistry {
51
62
  private db: DatabaseConnection;
63
+ private readonly now: () => Date;
64
+ private readonly pruneIntervalMs: number;
65
+ private readonly readonly: boolean;
66
+ private lastPrunedAt: number | undefined;
52
67
 
53
- constructor(filePath: string = DEFAULT_REGISTRY_PATH) {
54
- this.db = new DatabaseConnection({ dbPath: resolveAgentRegistryDbPath(filePath) });
68
+ constructor(filePath: string = DEFAULT_REGISTRY_PATH, options: AgentRegistryOptions = {}) {
69
+ this.now = options.now ?? (() => new Date());
70
+ this.pruneIntervalMs = options.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS;
71
+ this.readonly = options.readonly ?? false;
72
+ this.db = new DatabaseConnection({
73
+ dbPath: resolveAgentRegistryDbPath(filePath),
74
+ verbose: options.onDatabaseOperation,
75
+ readonly: this.readonly,
76
+ });
55
77
  }
56
78
 
57
79
  static default(): AgentRegistry {
@@ -71,6 +93,8 @@ export class AgentRegistry {
71
93
  startedAt: row.started_at,
72
94
  sessionId: row.session_id,
73
95
  sessionFilePath: row.session_file_path,
96
+ pinned: row.pinned !== 0,
97
+ updatedAt: row.updated_at,
74
98
  };
75
99
  }
76
100
 
@@ -101,6 +125,24 @@ export class AgentRegistry {
101
125
  return row ? this.rowToEntry(row) : undefined;
102
126
  }
103
127
 
128
+ private findPidConflicts(type: AgentType, pid: number): RegistryEntry[] {
129
+ return this.db.query<RegistryRow>(
130
+ 'SELECT * FROM agents WHERE pid = ? AND type <> ?',
131
+ [pid, type],
132
+ ).map((row) => this.rowToEntry(row));
133
+ }
134
+
135
+ private entriesEqual(left: RegistryEntry, right: RegistryEntry): boolean {
136
+ return left.name === right.name
137
+ && left.type === right.type
138
+ && left.pid === right.pid
139
+ && left.tmuxSession === right.tmuxSession
140
+ && left.cwd === right.cwd
141
+ && left.startedAt === right.startedAt
142
+ && left.sessionId === right.sessionId
143
+ && left.sessionFilePath === right.sessionFilePath;
144
+ }
145
+
104
146
  private deleteNameConflict(name: string, type: AgentType, pid: number): void {
105
147
  const conflict = this.findByName(name);
106
148
  if (!conflict) return;
@@ -126,31 +168,66 @@ export class AgentRegistry {
126
168
  session_id = excluded.session_id,
127
169
  session_file_path = excluded.session_file_path,
128
170
  updated_at = excluded.updated_at
129
- `).run({ ...entry, updatedAt: new Date().toISOString() });
171
+ `).run({ ...entry, updatedAt: this.now().toISOString() });
130
172
  }
131
173
 
132
- private save(entry: RegistryEntry): void {
133
- this.deleteNameConflict(entry.name, entry.type, entry.pid);
134
- this.insertOrUpdate(entry);
174
+ private needsWrite(incoming: RegistryEntry): boolean {
175
+ const existing = this.findByIdentity(incoming.type, incoming.pid);
176
+ const merged = this.mergeEntry(incoming, existing);
177
+ return !existing
178
+ || !this.entriesEqual(merged, existing)
179
+ || this.findPidConflicts(incoming.type, incoming.pid).length > 0;
180
+ }
181
+
182
+ private save(incoming: RegistryEntry): void {
183
+ const existing = this.findByIdentity(incoming.type, incoming.pid);
184
+ const merged = this.mergeEntry(incoming, existing);
185
+ const pidConflicts = this.findPidConflicts(incoming.type, incoming.pid);
186
+ if (existing && this.entriesEqual(merged, existing) && pidConflicts.length === 0) return;
187
+
188
+ for (const conflict of pidConflicts) {
189
+ this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [conflict.type, conflict.pid]);
190
+ }
191
+ if (existing && this.entriesEqual(merged, existing)) return;
192
+
193
+ this.deleteNameConflict(merged.name, merged.type, merged.pid);
194
+ this.insertOrUpdate(merged);
135
195
  }
136
196
 
137
197
  isAlive(entry: RegistryEntry): boolean {
138
198
  try {
139
199
  process.kill(entry.pid, 0);
140
200
  return true;
141
- } catch {
142
- return false;
201
+ } catch (error) {
202
+ const code = error && typeof error === 'object' && 'code' in error
203
+ ? error.code
204
+ : undefined;
205
+ return code !== 'ESRCH';
143
206
  }
144
207
  }
145
208
 
146
- prune(): void {
209
+ private pruneAt(nowMs: number): void {
147
210
  const entries = this.list();
148
211
  const stale = entries.filter((e) => !this.isAlive(e));
149
- this.db.transaction(() => {
150
- for (const entry of stale) {
151
- this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [entry.type, entry.pid]);
152
- }
153
- });
212
+ if (stale.length > 0) {
213
+ this.db.transaction(() => {
214
+ for (const entry of stale) {
215
+ this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [entry.type, entry.pid]);
216
+ }
217
+ });
218
+ }
219
+ this.lastPrunedAt = nowMs;
220
+ }
221
+
222
+ prune(): void {
223
+ this.pruneAt(this.now().getTime());
224
+ }
225
+
226
+ pruneIfDue(): void {
227
+ const nowMs = this.now().getTime();
228
+ const elapsed = this.lastPrunedAt === undefined ? undefined : nowMs - this.lastPrunedAt;
229
+ if (elapsed !== undefined && elapsed >= 0 && elapsed < this.pruneIntervalMs) return;
230
+ this.pruneAt(nowMs);
154
231
  }
155
232
 
156
233
  register(entry: RegistryEntry): void {
@@ -159,10 +236,10 @@ export class AgentRegistry {
159
236
 
160
237
  registerBatch(entries: RegistryEntry[]): void {
161
238
  if (entries.length === 0) return;
239
+ if (!entries.some((entry) => this.needsWrite(entry))) return;
162
240
  this.db.transaction(() => {
163
241
  for (const incoming of entries) {
164
- const existing = this.findByIdentity(incoming.type, incoming.pid);
165
- this.save(this.mergeEntry(incoming, existing));
242
+ this.save(incoming);
166
243
  }
167
244
  });
168
245
  }
@@ -183,11 +260,23 @@ export class AgentRegistry {
183
260
  }
184
261
  this.db.execute(
185
262
  'UPDATE agents SET name = ?, updated_at = ? WHERE type = ? AND pid = ?',
186
- [newName, new Date().toISOString(), existing.type, existing.pid],
263
+ [newName, this.now().toISOString(), existing.type, existing.pid],
187
264
  );
188
265
  });
189
266
  }
190
267
 
268
+ togglePin(type: AgentType, pid: number): boolean | null {
269
+ if (this.readonly) {
270
+ throw new Error('Agent registry is readonly; cannot toggle pin.');
271
+ }
272
+ const result = this.db.execute(
273
+ 'UPDATE agents SET pinned = NOT pinned, updated_at = ? WHERE type = ? AND pid = ?',
274
+ [this.now().toISOString(), type, pid],
275
+ );
276
+ if (result.changes === 0) return null;
277
+ return this.findByIdentity(type, pid)?.pinned ?? null;
278
+ }
279
+
191
280
  lookup(name: string): RegistryEntry | null {
192
281
  return this.findByName(name) ?? null;
193
282
  }