@ai-devkit/agent-manager 0.26.3 → 0.26.4

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 +1 @@
1
- {"version":3,"sources":["../../src/utils/AgentRegistry.ts"],"sourcesContent":["import os from 'os';\nimport path from 'path';\nimport type { AgentType } from '../adapters/AgentAdapter.js';\nimport {\n DatabaseConnection,\n resolveAgentRegistryDbPath,\n} from '../database/index.js';\n\nexport class RenameNotFoundError extends Error {\n constructor(public agentName: string) {\n super(`Agent \"${agentName}\" not found in registry.`);\n this.name = 'RenameNotFoundError';\n }\n}\n\nexport class RenameConflictError extends Error {\n constructor(public agentName: string) {\n super(`Agent \"${agentName}\" is already in use.`);\n this.name = 'RenameConflictError';\n }\n}\n\nexport interface RegistryEntry {\n name: string;\n type: AgentType;\n pid: number;\n tmuxSession: string;\n cwd: string;\n startedAt: string; // ISO 8601\n sessionId: string;\n sessionFilePath: string;\n}\n\ninterface RegistryRow {\n name: string;\n type: AgentType;\n pid: number;\n tmux_session: string;\n cwd: string;\n started_at: string;\n session_id: string;\n session_file_path: string;\n updated_at: string;\n}\n\nconst DEFAULT_REGISTRY_PATH = path.join(os.homedir(), '.ai-devkit', 'agents.json');\n\nlet defaultInstance: AgentRegistry | null = null;\n\nexport class AgentRegistry {\n private db: DatabaseConnection;\n\n constructor(filePath: string = DEFAULT_REGISTRY_PATH) {\n this.db = new DatabaseConnection({ dbPath: resolveAgentRegistryDbPath(filePath) });\n }\n\n static default(): AgentRegistry {\n if (!defaultInstance) {\n defaultInstance = new AgentRegistry();\n }\n return defaultInstance;\n }\n\n private rowToEntry(row: RegistryRow): RegistryEntry {\n return {\n name: row.name,\n type: row.type,\n pid: row.pid,\n tmuxSession: row.tmux_session,\n cwd: row.cwd,\n startedAt: row.started_at,\n sessionId: row.session_id,\n sessionFilePath: row.session_file_path,\n };\n }\n\n private mergeEntry(incoming: RegistryEntry, existing: RegistryEntry | undefined): RegistryEntry {\n if (!existing) return incoming;\n const incomingIsManaged = Boolean(incoming.tmuxSession);\n return {\n ...existing,\n name: incomingIsManaged ? incoming.name : existing.name,\n tmuxSession: incoming.tmuxSession || existing.tmuxSession,\n cwd: incoming.cwd || existing.cwd,\n startedAt: existing.startedAt || incoming.startedAt,\n sessionId: incoming.sessionId || existing.sessionId,\n sessionFilePath: incoming.sessionFilePath || existing.sessionFilePath,\n };\n }\n\n private findByIdentity(type: AgentType, pid: number): RegistryEntry | undefined {\n const row = this.db.queryOne<RegistryRow>(\n 'SELECT * FROM agents WHERE type = ? AND pid = ?',\n [type, pid],\n );\n return row ? this.rowToEntry(row) : undefined;\n }\n\n private findByName(name: string): RegistryEntry | undefined {\n const row = this.db.queryOne<RegistryRow>('SELECT * FROM agents WHERE name = ?', [name]);\n return row ? this.rowToEntry(row) : undefined;\n }\n\n private deleteNameConflict(name: string, type: AgentType, pid: number): void {\n const conflict = this.findByName(name);\n if (!conflict) return;\n if (conflict.type === type && conflict.pid === pid) return;\n if (!this.isAlive(conflict)) {\n this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [conflict.type, conflict.pid]);\n }\n }\n\n private insertOrUpdate(entry: RegistryEntry): void {\n this.db.instance.prepare(`\n INSERT INTO agents (\n type, pid, name, tmux_session, cwd, started_at, session_id, session_file_path, updated_at\n )\n VALUES (\n @type, @pid, @name, @tmuxSession, @cwd, @startedAt, @sessionId, @sessionFilePath, @updatedAt\n )\n ON CONFLICT(type, pid) DO UPDATE SET\n name = excluded.name,\n tmux_session = excluded.tmux_session,\n cwd = excluded.cwd,\n started_at = agents.started_at,\n session_id = excluded.session_id,\n session_file_path = excluded.session_file_path,\n updated_at = excluded.updated_at\n `).run({ ...entry, updatedAt: new Date().toISOString() });\n }\n\n private save(entry: RegistryEntry): void {\n this.deleteNameConflict(entry.name, entry.type, entry.pid);\n this.insertOrUpdate(entry);\n }\n\n isAlive(entry: RegistryEntry): boolean {\n try {\n process.kill(entry.pid, 0);\n return true;\n } catch {\n return false;\n }\n }\n\n prune(): void {\n const entries = this.list();\n const stale = entries.filter((e) => !this.isAlive(e));\n this.db.transaction(() => {\n for (const entry of stale) {\n this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [entry.type, entry.pid]);\n }\n });\n }\n\n register(entry: RegistryEntry): void {\n this.registerBatch([entry]);\n }\n\n registerBatch(entries: RegistryEntry[]): void {\n if (entries.length === 0) return;\n this.db.transaction(() => {\n for (const incoming of entries) {\n const existing = this.findByIdentity(incoming.type, incoming.pid);\n this.save(this.mergeEntry(incoming, existing));\n }\n });\n }\n\n rename(currentName: string, newName: string): void {\n const existing = this.findByName(currentName);\n if (!existing) {\n throw new RenameNotFoundError(currentName);\n }\n const conflict = this.findByName(newName);\n if (conflict && this.isAlive(conflict)) {\n throw new RenameConflictError(newName);\n }\n\n this.db.transaction(() => {\n if (conflict) {\n this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [conflict.type, conflict.pid]);\n }\n this.db.execute(\n 'UPDATE agents SET name = ?, updated_at = ? WHERE type = ? AND pid = ?',\n [newName, new Date().toISOString(), existing.type, existing.pid],\n );\n });\n }\n\n lookup(name: string): RegistryEntry | null {\n return this.findByName(name) ?? null;\n }\n\n list(): RegistryEntry[] {\n const rows = this.db.query<RegistryRow>('SELECT * FROM agents ORDER BY started_at ASC, name ASC');\n return rows.map((row) => this.rowToEntry(row));\n }\n}\n"],"names":["os","path","DatabaseConnection","resolveAgentRegistryDbPath","RenameNotFoundError","Error","agentName","name","RenameConflictError","DEFAULT_REGISTRY_PATH","join","homedir","defaultInstance","AgentRegistry","db","filePath","dbPath","default","rowToEntry","row","type","pid","tmuxSession","tmux_session","cwd","startedAt","started_at","sessionId","session_id","sessionFilePath","session_file_path","mergeEntry","incoming","existing","incomingIsManaged","Boolean","findByIdentity","queryOne","undefined","findByName","deleteNameConflict","conflict","isAlive","execute","insertOrUpdate","entry","instance","prepare","run","updatedAt","Date","toISOString","save","process","kill","prune","entries","list","stale","filter","e","transaction","register","registerBatch","length","rename","currentName","newName","lookup","rows","query","map"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/utils/AgentRegistry.ts"],"sourcesContent":["import os from 'os';\nimport path from 'path';\nimport type { AgentType } from '../adapters/AgentAdapter.js';\nimport {\n DatabaseConnection,\n resolveAgentRegistryDbPath,\n} from '../database/index.js';\n\nexport class RenameNotFoundError extends Error {\n constructor(public agentName: string) {\n super(`Agent \"${agentName}\" not found in registry.`);\n this.name = 'RenameNotFoundError';\n }\n}\n\nexport class RenameConflictError extends Error {\n constructor(public agentName: string) {\n super(`Agent \"${agentName}\" is already in use.`);\n this.name = 'RenameConflictError';\n }\n}\n\nexport interface RegistryEntry {\n name: string;\n type: AgentType;\n pid: number;\n tmuxSession: string;\n cwd: string;\n startedAt: string; // ISO 8601\n sessionId: string;\n sessionFilePath: string;\n}\n\ninterface RegistryRow {\n name: string;\n type: AgentType;\n pid: number;\n tmux_session: string;\n cwd: string;\n started_at: string;\n session_id: string;\n session_file_path: string;\n updated_at: string;\n}\n\nconst DEFAULT_REGISTRY_PATH = path.join(os.homedir(), '.ai-devkit', 'agents.json');\nconst DEFAULT_PRUNE_INTERVAL_MS = 30_000;\n\nlet defaultInstance: AgentRegistry | null = null;\n\nexport interface AgentRegistryOptions {\n now?: () => Date;\n pruneIntervalMs?: number;\n onDatabaseOperation?: (sql: string) => void;\n}\n\nexport class AgentRegistry {\n private db: DatabaseConnection;\n private readonly now: () => Date;\n private readonly pruneIntervalMs: number;\n private lastPrunedAt: number | undefined;\n\n constructor(filePath: string = DEFAULT_REGISTRY_PATH, options: AgentRegistryOptions = {}) {\n this.now = options.now ?? (() => new Date());\n this.pruneIntervalMs = options.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS;\n this.db = new DatabaseConnection({\n dbPath: resolveAgentRegistryDbPath(filePath),\n verbose: options.onDatabaseOperation,\n });\n }\n\n static default(): AgentRegistry {\n if (!defaultInstance) {\n defaultInstance = new AgentRegistry();\n }\n return defaultInstance;\n }\n\n private rowToEntry(row: RegistryRow): RegistryEntry {\n return {\n name: row.name,\n type: row.type,\n pid: row.pid,\n tmuxSession: row.tmux_session,\n cwd: row.cwd,\n startedAt: row.started_at,\n sessionId: row.session_id,\n sessionFilePath: row.session_file_path,\n };\n }\n\n private mergeEntry(incoming: RegistryEntry, existing: RegistryEntry | undefined): RegistryEntry {\n if (!existing) return incoming;\n const incomingIsManaged = Boolean(incoming.tmuxSession);\n return {\n ...existing,\n name: incomingIsManaged ? incoming.name : existing.name,\n tmuxSession: incoming.tmuxSession || existing.tmuxSession,\n cwd: incoming.cwd || existing.cwd,\n startedAt: existing.startedAt || incoming.startedAt,\n sessionId: incoming.sessionId || existing.sessionId,\n sessionFilePath: incoming.sessionFilePath || existing.sessionFilePath,\n };\n }\n\n private findByIdentity(type: AgentType, pid: number): RegistryEntry | undefined {\n const row = this.db.queryOne<RegistryRow>(\n 'SELECT * FROM agents WHERE type = ? AND pid = ?',\n [type, pid],\n );\n return row ? this.rowToEntry(row) : undefined;\n }\n\n private findByName(name: string): RegistryEntry | undefined {\n const row = this.db.queryOne<RegistryRow>('SELECT * FROM agents WHERE name = ?', [name]);\n return row ? this.rowToEntry(row) : undefined;\n }\n\n private findPidConflicts(type: AgentType, pid: number): RegistryEntry[] {\n return this.db.query<RegistryRow>(\n 'SELECT * FROM agents WHERE pid = ? AND type <> ?',\n [pid, type],\n ).map((row) => this.rowToEntry(row));\n }\n\n private entriesEqual(left: RegistryEntry, right: RegistryEntry): boolean {\n return left.name === right.name\n && left.type === right.type\n && left.pid === right.pid\n && left.tmuxSession === right.tmuxSession\n && left.cwd === right.cwd\n && left.startedAt === right.startedAt\n && left.sessionId === right.sessionId\n && left.sessionFilePath === right.sessionFilePath;\n }\n\n private deleteNameConflict(name: string, type: AgentType, pid: number): void {\n const conflict = this.findByName(name);\n if (!conflict) return;\n if (conflict.type === type && conflict.pid === pid) return;\n if (!this.isAlive(conflict)) {\n this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [conflict.type, conflict.pid]);\n }\n }\n\n private insertOrUpdate(entry: RegistryEntry): void {\n this.db.instance.prepare(`\n INSERT INTO agents (\n type, pid, name, tmux_session, cwd, started_at, session_id, session_file_path, updated_at\n )\n VALUES (\n @type, @pid, @name, @tmuxSession, @cwd, @startedAt, @sessionId, @sessionFilePath, @updatedAt\n )\n ON CONFLICT(type, pid) DO UPDATE SET\n name = excluded.name,\n tmux_session = excluded.tmux_session,\n cwd = excluded.cwd,\n started_at = agents.started_at,\n session_id = excluded.session_id,\n session_file_path = excluded.session_file_path,\n updated_at = excluded.updated_at\n `).run({ ...entry, updatedAt: this.now().toISOString() });\n }\n\n private needsWrite(incoming: RegistryEntry): boolean {\n const existing = this.findByIdentity(incoming.type, incoming.pid);\n const merged = this.mergeEntry(incoming, existing);\n return !existing\n || !this.entriesEqual(merged, existing)\n || this.findPidConflicts(incoming.type, incoming.pid).length > 0;\n }\n\n private save(incoming: RegistryEntry): void {\n const existing = this.findByIdentity(incoming.type, incoming.pid);\n const merged = this.mergeEntry(incoming, existing);\n const pidConflicts = this.findPidConflicts(incoming.type, incoming.pid);\n if (existing && this.entriesEqual(merged, existing) && pidConflicts.length === 0) return;\n\n for (const conflict of pidConflicts) {\n this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [conflict.type, conflict.pid]);\n }\n if (existing && this.entriesEqual(merged, existing)) return;\n\n this.deleteNameConflict(merged.name, merged.type, merged.pid);\n this.insertOrUpdate(merged);\n }\n\n isAlive(entry: RegistryEntry): boolean {\n try {\n process.kill(entry.pid, 0);\n return true;\n } catch (error) {\n const code = error && typeof error === 'object' && 'code' in error\n ? error.code\n : undefined;\n return code !== 'ESRCH';\n }\n }\n\n private pruneAt(nowMs: number): void {\n const entries = this.list();\n const stale = entries.filter((e) => !this.isAlive(e));\n if (stale.length > 0) {\n this.db.transaction(() => {\n for (const entry of stale) {\n this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [entry.type, entry.pid]);\n }\n });\n }\n this.lastPrunedAt = nowMs;\n }\n\n prune(): void {\n this.pruneAt(this.now().getTime());\n }\n\n pruneIfDue(): void {\n const nowMs = this.now().getTime();\n const elapsed = this.lastPrunedAt === undefined ? undefined : nowMs - this.lastPrunedAt;\n if (elapsed !== undefined && elapsed >= 0 && elapsed < this.pruneIntervalMs) return;\n this.pruneAt(nowMs);\n }\n\n register(entry: RegistryEntry): void {\n this.registerBatch([entry]);\n }\n\n registerBatch(entries: RegistryEntry[]): void {\n if (entries.length === 0) return;\n if (!entries.some((entry) => this.needsWrite(entry))) return;\n this.db.transaction(() => {\n for (const incoming of entries) {\n this.save(incoming);\n }\n });\n }\n\n rename(currentName: string, newName: string): void {\n const existing = this.findByName(currentName);\n if (!existing) {\n throw new RenameNotFoundError(currentName);\n }\n const conflict = this.findByName(newName);\n if (conflict && this.isAlive(conflict)) {\n throw new RenameConflictError(newName);\n }\n\n this.db.transaction(() => {\n if (conflict) {\n this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [conflict.type, conflict.pid]);\n }\n this.db.execute(\n 'UPDATE agents SET name = ?, updated_at = ? WHERE type = ? AND pid = ?',\n [newName, this.now().toISOString(), existing.type, existing.pid],\n );\n });\n }\n\n lookup(name: string): RegistryEntry | null {\n return this.findByName(name) ?? null;\n }\n\n list(): RegistryEntry[] {\n const rows = this.db.query<RegistryRow>('SELECT * FROM agents ORDER BY started_at ASC, name ASC');\n return rows.map((row) => this.rowToEntry(row));\n }\n}\n"],"names":["os","path","DatabaseConnection","resolveAgentRegistryDbPath","RenameNotFoundError","Error","agentName","name","RenameConflictError","DEFAULT_REGISTRY_PATH","join","homedir","DEFAULT_PRUNE_INTERVAL_MS","defaultInstance","AgentRegistry","db","now","pruneIntervalMs","lastPrunedAt","filePath","options","Date","dbPath","verbose","onDatabaseOperation","default","rowToEntry","row","type","pid","tmuxSession","tmux_session","cwd","startedAt","started_at","sessionId","session_id","sessionFilePath","session_file_path","mergeEntry","incoming","existing","incomingIsManaged","Boolean","findByIdentity","queryOne","undefined","findByName","findPidConflicts","query","map","entriesEqual","left","right","deleteNameConflict","conflict","isAlive","execute","insertOrUpdate","entry","instance","prepare","run","updatedAt","toISOString","needsWrite","merged","length","save","pidConflicts","process","kill","error","code","pruneAt","nowMs","entries","list","stale","filter","e","transaction","prune","getTime","pruneIfDue","elapsed","register","registerBatch","some","rename","currentName","newName","lookup","rows"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-devkit/agent-manager",
3
- "version": "0.26.3",
3
+ "version": "0.26.4",
4
4
  "type": "module",
5
5
  "description": "Standalone agent detection and management utilities for AI DevKit",
6
6
  "main": "dist/index.js",
@@ -180,15 +180,18 @@ export class AgentManager {
180
180
  });
181
181
  }
182
182
 
183
- const preExistingByPid = new Map(this.registry.list().map((e) => [e.pid, e]));
183
+ const identityKey = (type: string, pid: number): string => `${type}:${pid}`;
184
+ const preExistingByIdentity = new Map(
185
+ this.registry.list().map((entry) => [identityKey(entry.type, entry.pid), entry]),
186
+ );
184
187
  const entries = allAgents.map((agent) =>
185
- this.toRegistryEntry(agent, preExistingByPid.get(agent.pid)),
188
+ this.toRegistryEntry(agent, preExistingByIdentity.get(identityKey(agent.type, agent.pid))),
186
189
  );
187
190
  if (entries.length > 0) this.registry.registerBatch(entries);
188
- this.registry.prune();
191
+ this.registry.pruneIfDue();
189
192
 
190
193
  for (const agent of allAgents) {
191
- const entry = preExistingByPid.get(agent.pid);
194
+ const entry = preExistingByIdentity.get(identityKey(agent.type, agent.pid));
192
195
  if (entry) {
193
196
  agent.name = entry.name;
194
197
  }
@@ -344,15 +344,25 @@ describe('AgentManager', () => {
344
344
  let regPath: string;
345
345
  let registry: AgentRegistry;
346
346
  let scopedManager: AgentManager;
347
+ let nowMs: number;
348
+ let databaseOperations: string[];
347
349
 
348
350
  beforeEach(() => {
349
351
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-manager-'));
350
352
  regPath = path.join(tmpDir, 'agents.json');
351
- registry = new AgentRegistry(regPath);
353
+ nowMs = Date.parse('2026-08-14T10:00:00.000Z');
354
+ databaseOperations = [];
355
+ registry = new AgentRegistry(regPath, {
356
+ now: () => new Date(nowMs),
357
+ pruneIntervalMs: 30_000,
358
+ onDatabaseOperation: (sql) => databaseOperations.push(sql),
359
+ });
352
360
  scopedManager = new AgentManager(registry);
361
+ databaseOperations = [];
353
362
  });
354
363
 
355
364
  afterEach(() => {
365
+ vi.restoreAllMocks();
356
366
  fs.rmSync(tmpDir, { recursive: true, force: true });
357
367
  });
358
368
 
@@ -429,6 +439,35 @@ describe('AgentManager', () => {
429
439
  expect(registry.list()[0].startedAt).toBe('2026-05-30T00:00:00.000Z');
430
440
  });
431
441
 
442
+ it('preserves custom name and tmux session across two EPERM refresh cycles', async () => {
443
+ registry.register({
444
+ name: 'merry',
445
+ type: 'claude',
446
+ pid: process.pid,
447
+ tmuxSession: 'merry-tmux',
448
+ cwd: '/cwd/merry',
449
+ startedAt: '2026-05-30T00:00:00.000Z',
450
+ sessionId: 'sid-merry',
451
+ sessionFilePath: '/path/merry.jsonl',
452
+ });
453
+ scopedManager.registerAdapter(new MockAdapter('claude', [
454
+ createMockAgent({ name: `ai-devkit-${process.pid}`, pid: process.pid }),
455
+ ]));
456
+ vi.spyOn(process, 'kill').mockImplementation(() => {
457
+ throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
458
+ });
459
+
460
+ const firstRefresh = await scopedManager.listAgents();
461
+ const secondRefresh = await scopedManager.listAgents();
462
+
463
+ expect(firstRefresh[0].name).toBe('merry');
464
+ expect(secondRefresh[0].name).toBe('merry');
465
+ expect(registry.lookup('merry')).toMatchObject({
466
+ name: 'merry',
467
+ tmuxSession: 'merry-tmux',
468
+ });
469
+ });
470
+
432
471
  it('preserves a user-managed name when a fallback row was written later for the same pid', async () => {
433
472
  registry.register({
434
473
  name: 'agent-list-debug',
@@ -495,15 +534,114 @@ describe('AgentManager', () => {
495
534
  .toEqual(['a', 'b']);
496
535
  });
497
536
 
498
- it('skips registerBatch when no agents detected (still calls prune)', async () => {
537
+ it('performs zero database writes on an unchanged refresh', async () => {
538
+ const adapter = new MockAdapter('claude', [
539
+ createMockAgent({
540
+ name: 'stable',
541
+ pid: process.pid,
542
+ projectPath: '/cwd/stable',
543
+ sessionId: 'stable-session',
544
+ sessionFilePath: '/sessions/stable.jsonl',
545
+ }),
546
+ ]);
547
+ scopedManager.registerAdapter(adapter);
548
+ await scopedManager.listAgents();
549
+ databaseOperations = [];
550
+
551
+ await scopedManager.listAgents();
552
+
553
+ const writes = databaseOperations.filter((sql) => /^\s*(BEGIN|COMMIT|INSERT|UPDATE|DELETE)/i.test(sql));
554
+ expect(writes).toEqual([]);
555
+ });
556
+
557
+ it('persists changed fields once in one write transaction', async () => {
558
+ const adapter = new MockAdapter('claude', [
559
+ createMockAgent({ name: 'changing', pid: process.pid, projectPath: '/cwd/before' }),
560
+ ]);
561
+ scopedManager.registerAdapter(adapter);
562
+ await scopedManager.listAgents();
563
+ databaseOperations = [];
564
+ nowMs += 1_000;
565
+ adapter.setAgents([
566
+ createMockAgent({ name: 'changing', pid: process.pid, projectPath: '/cwd/after' }),
567
+ ]);
568
+
569
+ await scopedManager.listAgents();
570
+
571
+ const upserts = databaseOperations.filter((sql) => /^\s*INSERT INTO agents/i.test(sql));
572
+ const transactions = databaseOperations.filter((sql) => /^\s*(BEGIN|COMMIT)/i.test(sql));
573
+ expect(upserts).toHaveLength(1);
574
+ expect(upserts[0]).toContain("'2026-08-14T10:00:01.000Z'");
575
+ expect(transactions).toHaveLength(2);
576
+ expect(registry.lookup('changing')?.cwd).toBe('/cwd/after');
577
+ });
578
+
579
+ it('prunes newly dead entries only when the passive cadence is due', async () => {
580
+ registry.register({
581
+ name: 'cadenced',
582
+ type: 'claude',
583
+ pid: process.pid,
584
+ tmuxSession: '',
585
+ cwd: '/cwd/cadenced',
586
+ startedAt: '2026-05-30T00:00:00.000Z',
587
+ sessionId: 'sid-cadenced',
588
+ sessionFilePath: '',
589
+ });
590
+ const alive = vi.spyOn(registry, 'isAlive').mockReturnValue(true);
591
+
592
+ await scopedManager.listAgents();
593
+ expect(alive).toHaveBeenCalledTimes(1);
594
+ alive.mockReturnValue(false);
595
+ nowMs += 29_999;
596
+
597
+ await scopedManager.listAgents();
598
+ expect(alive).toHaveBeenCalledTimes(1);
599
+ expect(registry.lookup('cadenced')).not.toBeNull();
600
+
601
+ nowMs += 1;
602
+ await scopedManager.listAgents();
603
+ expect(alive).toHaveBeenCalledTimes(2);
604
+ expect(registry.lookup('cadenced')).toBeNull();
605
+ });
606
+
607
+ it('does not inherit a name when the same pid is reused by another agent type', async () => {
608
+ registry.register({
609
+ name: 'old-claude',
610
+ type: 'claude',
611
+ pid: process.pid,
612
+ tmuxSession: 'old-claude',
613
+ cwd: '/cwd/old',
614
+ startedAt: '2026-05-30T00:00:00.000Z',
615
+ sessionId: 'old-session',
616
+ sessionFilePath: '',
617
+ });
618
+ scopedManager.registerAdapter(new MockAdapter('codex', [
619
+ createMockAgent({
620
+ name: 'new-codex',
621
+ type: 'codex',
622
+ pid: process.pid,
623
+ projectPath: '/cwd/new',
624
+ sessionId: 'new-session',
625
+ }),
626
+ ]));
627
+
628
+ const agents = await scopedManager.listAgents();
629
+
630
+ expect(agents[0].name).toBe('new-codex');
631
+ expect(registry.lookup('old-claude')).toBeNull();
632
+ expect(registry.lookup('new-codex')).toMatchObject({ type: 'codex', pid: process.pid });
633
+ });
634
+
635
+ it('skips registerBatch when no agents are detected and prune is not due', async () => {
499
636
  const writeSpy = vi.spyOn(registry, 'registerBatch');
500
- const pruneSpy = vi.spyOn(registry, 'prune');
637
+ const pruneSpy = vi.spyOn(registry, 'pruneIfDue');
501
638
 
502
639
  scopedManager.registerAdapter(new MockAdapter('claude', []));
503
640
  await scopedManager.listAgents();
641
+ await scopedManager.listAgents();
504
642
 
505
643
  expect(writeSpy).not.toHaveBeenCalled();
506
- expect(pruneSpy).toHaveBeenCalledTimes(1);
644
+ expect(pruneSpy).toHaveBeenCalledTimes(2);
507
645
  });
508
646
  });
509
647
 
@@ -29,6 +29,7 @@ describe('AgentRegistry', () => {
29
29
  });
30
30
 
31
31
  afterEach(() => {
32
+ vi.restoreAllMocks();
32
33
  fs.rmSync(tmpDir, { recursive: true, force: true });
33
34
  });
34
35
 
@@ -81,6 +82,19 @@ describe('AgentRegistry', () => {
81
82
  expect(registry.lookup(`ai-devkit-${process.pid}`)).toBeNull();
82
83
  expect(registry.list()).toHaveLength(1);
83
84
  });
85
+
86
+ it('preserves an existing name conflict when its probe fails with EPERM', () => {
87
+ registry.register(makeEntry({ name: 'claimed-name', pid: process.pid }));
88
+ vi.spyOn(process, 'kill').mockImplementation(() => {
89
+ throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
90
+ });
91
+
92
+ expect(() => registry.register(makeEntry({
93
+ name: 'claimed-name',
94
+ pid: process.pid + 1,
95
+ }))).toThrow();
96
+ expect(registry.lookup('claimed-name')?.pid).toBe(process.pid);
97
+ });
84
98
  });
85
99
 
86
100
  describe('registerBatch', () => {
@@ -118,6 +132,33 @@ describe('AgentRegistry', () => {
118
132
  expect(registry.list()).toHaveLength(1);
119
133
  expect(registry.lookup('custom-name')?.pid).toBe(process.pid);
120
134
  });
135
+
136
+ it('cleans up a cross-type row when its pid has been reused', () => {
137
+ registry.register(makeEntry({ name: 'old-claude', type: 'claude', pid: process.pid }));
138
+
139
+ registry.register(makeEntry({
140
+ name: 'new-codex',
141
+ type: 'codex',
142
+ pid: process.pid,
143
+ tmuxSession: '',
144
+ }));
145
+
146
+ expect(registry.lookup('old-claude')).toBeNull();
147
+ expect(registry.lookup('new-codex')).toMatchObject({ type: 'codex', pid: process.pid });
148
+ expect(registry.list()).toHaveLength(1);
149
+ });
150
+
151
+ it('rolls back the whole batch when a live name conflict rejects one entry', () => {
152
+ registry.register(makeEntry({ name: 'taken', pid: process.pid }));
153
+
154
+ expect(() => registry.registerBatch([
155
+ makeEntry({ name: 'fresh', pid: 999998 }),
156
+ makeEntry({ name: 'taken', type: 'codex', pid: 999997 }),
157
+ ])).toThrow(/UNIQUE constraint failed/);
158
+
159
+ expect(registry.lookup('fresh')).toBeNull();
160
+ expect(registry.lookup('taken')?.pid).toBe(process.pid);
161
+ });
121
162
  });
122
163
 
123
164
  describe('lookup', () => {
@@ -157,6 +198,30 @@ describe('AgentRegistry', () => {
157
198
  it('returns false for a PID that does not exist', () => {
158
199
  expect(registry.isAlive(makeEntry({ pid: 999999 }))).toBe(false);
159
200
  });
201
+
202
+ it('returns true when the process probe is forbidden with EPERM', () => {
203
+ vi.spyOn(process, 'kill').mockImplementation(() => {
204
+ throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
205
+ });
206
+
207
+ expect(registry.isAlive(makeEntry())).toBe(true);
208
+ });
209
+
210
+ it('returns false when the process probe reports ESRCH', () => {
211
+ vi.spyOn(process, 'kill').mockImplementation(() => {
212
+ throw Object.assign(new Error('no such process'), { code: 'ESRCH' });
213
+ });
214
+
215
+ expect(registry.isAlive(makeEntry())).toBe(false);
216
+ });
217
+
218
+ it('returns true when the process probe fails without a definitive error code', () => {
219
+ vi.spyOn(process, 'kill').mockImplementation(() => {
220
+ throw new Error('indeterminate probe failure');
221
+ });
222
+
223
+ expect(registry.isAlive(makeEntry())).toBe(true);
224
+ });
160
225
  });
161
226
 
162
227
  describe('prune', () => {
@@ -177,9 +242,52 @@ describe('AgentRegistry', () => {
177
242
  expect(after).toEqual(before);
178
243
  });
179
244
 
245
+ it('preserves entries when liveness probing fails with EPERM', () => {
246
+ registry.register(makeEntry({ name: 'custom-name', tmuxSession: 'tmux-custom' }));
247
+ vi.spyOn(process, 'kill').mockImplementation(() => {
248
+ throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
249
+ });
250
+
251
+ registry.prune();
252
+
253
+ expect(registry.lookup('custom-name')).toMatchObject({
254
+ name: 'custom-name',
255
+ tmuxSession: 'tmux-custom',
256
+ });
257
+ });
258
+
259
+ it('removes entries when liveness probing fails with ESRCH', () => {
260
+ registry.register(makeEntry({ name: 'dead' }));
261
+ vi.spyOn(process, 'kill').mockImplementation(() => {
262
+ throw Object.assign(new Error('no such process'), { code: 'ESRCH' });
263
+ });
264
+
265
+ registry.prune();
266
+
267
+ expect(registry.lookup('dead')).toBeNull();
268
+ });
269
+
180
270
  it('does nothing when file is missing', () => {
181
271
  expect(() => registry.prune()).not.toThrow();
182
272
  });
273
+
274
+ it('keeps forced prune available before the passive cadence is due', () => {
275
+ let nowMs = Date.parse('2026-08-14T10:00:00.000Z');
276
+ const clocked = new AgentRegistry(regPath, {
277
+ now: () => new Date(nowMs),
278
+ pruneIntervalMs: 30_000,
279
+ });
280
+ clocked.register(makeEntry({ name: 'forced', pid: process.pid }));
281
+ const alive = vi.spyOn(clocked, 'isAlive').mockReturnValue(true);
282
+ clocked.pruneIfDue();
283
+ alive.mockReturnValue(false);
284
+ nowMs += 1;
285
+
286
+ clocked.prune();
287
+
288
+ expect(alive).toHaveBeenCalledTimes(2);
289
+ expect(clocked.lookup('forced')).toBeNull();
290
+ });
183
291
  });
184
292
 
185
293
  describe('default()', () => {
@@ -215,6 +323,17 @@ describe('AgentRegistry', () => {
215
323
  expect(() => registry.rename('agent-a', 'agent-b')).toThrow(RenameConflictError);
216
324
  });
217
325
 
326
+ it('throws RenameConflictError when the conflicting entry probe fails with EPERM', () => {
327
+ registry.register(makeEntry({ name: 'agent-a', pid: process.pid }));
328
+ registry.register(makeEntry({ name: 'agent-b', pid: process.ppid }));
329
+ vi.spyOn(process, 'kill').mockImplementation(() => {
330
+ throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
331
+ });
332
+
333
+ expect(() => registry.rename('agent-a', 'agent-b')).toThrow(RenameConflictError);
334
+ expect(registry.lookup('agent-b')?.pid).toBe(process.ppid);
335
+ });
336
+
218
337
  it('succeeds when new name exists only as a stale (dead) entry', () => {
219
338
  registry.register(makeEntry({ name: 'agent-a', pid: process.pid }));
220
339
  registry.register(makeEntry({ name: 'agent-b', pid: 999999 }));
@@ -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
 
@@ -27,7 +27,9 @@ export class DatabaseConnection {
27
27
 
28
28
  this.db = new Database(this.dbPath, {
29
29
  readonly: options.readonly ?? false,
30
- verbose: options.verbose ? console.log : undefined,
30
+ verbose: typeof options.verbose === 'function'
31
+ ? options.verbose
32
+ : options.verbose ? console.log : undefined,
31
33
  });
32
34
 
33
35
  this.configure();
package/src/index.ts CHANGED
@@ -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';
@@ -44,14 +44,29 @@ interface RegistryRow {
44
44
  }
45
45
 
46
46
  const DEFAULT_REGISTRY_PATH = path.join(os.homedir(), '.ai-devkit', 'agents.json');
47
+ const DEFAULT_PRUNE_INTERVAL_MS = 30_000;
47
48
 
48
49
  let defaultInstance: AgentRegistry | null = null;
49
50
 
51
+ export interface AgentRegistryOptions {
52
+ now?: () => Date;
53
+ pruneIntervalMs?: number;
54
+ onDatabaseOperation?: (sql: string) => void;
55
+ }
56
+
50
57
  export class AgentRegistry {
51
58
  private db: DatabaseConnection;
59
+ private readonly now: () => Date;
60
+ private readonly pruneIntervalMs: number;
61
+ private lastPrunedAt: number | undefined;
52
62
 
53
- constructor(filePath: string = DEFAULT_REGISTRY_PATH) {
54
- this.db = new DatabaseConnection({ dbPath: resolveAgentRegistryDbPath(filePath) });
63
+ constructor(filePath: string = DEFAULT_REGISTRY_PATH, options: AgentRegistryOptions = {}) {
64
+ this.now = options.now ?? (() => new Date());
65
+ this.pruneIntervalMs = options.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS;
66
+ this.db = new DatabaseConnection({
67
+ dbPath: resolveAgentRegistryDbPath(filePath),
68
+ verbose: options.onDatabaseOperation,
69
+ });
55
70
  }
56
71
 
57
72
  static default(): AgentRegistry {
@@ -101,6 +116,24 @@ export class AgentRegistry {
101
116
  return row ? this.rowToEntry(row) : undefined;
102
117
  }
103
118
 
119
+ private findPidConflicts(type: AgentType, pid: number): RegistryEntry[] {
120
+ return this.db.query<RegistryRow>(
121
+ 'SELECT * FROM agents WHERE pid = ? AND type <> ?',
122
+ [pid, type],
123
+ ).map((row) => this.rowToEntry(row));
124
+ }
125
+
126
+ private entriesEqual(left: RegistryEntry, right: RegistryEntry): boolean {
127
+ return left.name === right.name
128
+ && left.type === right.type
129
+ && left.pid === right.pid
130
+ && left.tmuxSession === right.tmuxSession
131
+ && left.cwd === right.cwd
132
+ && left.startedAt === right.startedAt
133
+ && left.sessionId === right.sessionId
134
+ && left.sessionFilePath === right.sessionFilePath;
135
+ }
136
+
104
137
  private deleteNameConflict(name: string, type: AgentType, pid: number): void {
105
138
  const conflict = this.findByName(name);
106
139
  if (!conflict) return;
@@ -126,31 +159,66 @@ export class AgentRegistry {
126
159
  session_id = excluded.session_id,
127
160
  session_file_path = excluded.session_file_path,
128
161
  updated_at = excluded.updated_at
129
- `).run({ ...entry, updatedAt: new Date().toISOString() });
162
+ `).run({ ...entry, updatedAt: this.now().toISOString() });
130
163
  }
131
164
 
132
- private save(entry: RegistryEntry): void {
133
- this.deleteNameConflict(entry.name, entry.type, entry.pid);
134
- this.insertOrUpdate(entry);
165
+ private needsWrite(incoming: RegistryEntry): boolean {
166
+ const existing = this.findByIdentity(incoming.type, incoming.pid);
167
+ const merged = this.mergeEntry(incoming, existing);
168
+ return !existing
169
+ || !this.entriesEqual(merged, existing)
170
+ || this.findPidConflicts(incoming.type, incoming.pid).length > 0;
171
+ }
172
+
173
+ private save(incoming: RegistryEntry): void {
174
+ const existing = this.findByIdentity(incoming.type, incoming.pid);
175
+ const merged = this.mergeEntry(incoming, existing);
176
+ const pidConflicts = this.findPidConflicts(incoming.type, incoming.pid);
177
+ if (existing && this.entriesEqual(merged, existing) && pidConflicts.length === 0) return;
178
+
179
+ for (const conflict of pidConflicts) {
180
+ this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [conflict.type, conflict.pid]);
181
+ }
182
+ if (existing && this.entriesEqual(merged, existing)) return;
183
+
184
+ this.deleteNameConflict(merged.name, merged.type, merged.pid);
185
+ this.insertOrUpdate(merged);
135
186
  }
136
187
 
137
188
  isAlive(entry: RegistryEntry): boolean {
138
189
  try {
139
190
  process.kill(entry.pid, 0);
140
191
  return true;
141
- } catch {
142
- return false;
192
+ } catch (error) {
193
+ const code = error && typeof error === 'object' && 'code' in error
194
+ ? error.code
195
+ : undefined;
196
+ return code !== 'ESRCH';
143
197
  }
144
198
  }
145
199
 
146
- prune(): void {
200
+ private pruneAt(nowMs: number): void {
147
201
  const entries = this.list();
148
202
  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
- });
203
+ if (stale.length > 0) {
204
+ this.db.transaction(() => {
205
+ for (const entry of stale) {
206
+ this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [entry.type, entry.pid]);
207
+ }
208
+ });
209
+ }
210
+ this.lastPrunedAt = nowMs;
211
+ }
212
+
213
+ prune(): void {
214
+ this.pruneAt(this.now().getTime());
215
+ }
216
+
217
+ pruneIfDue(): void {
218
+ const nowMs = this.now().getTime();
219
+ const elapsed = this.lastPrunedAt === undefined ? undefined : nowMs - this.lastPrunedAt;
220
+ if (elapsed !== undefined && elapsed >= 0 && elapsed < this.pruneIntervalMs) return;
221
+ this.pruneAt(nowMs);
154
222
  }
155
223
 
156
224
  register(entry: RegistryEntry): void {
@@ -159,10 +227,10 @@ export class AgentRegistry {
159
227
 
160
228
  registerBatch(entries: RegistryEntry[]): void {
161
229
  if (entries.length === 0) return;
230
+ if (!entries.some((entry) => this.needsWrite(entry))) return;
162
231
  this.db.transaction(() => {
163
232
  for (const incoming of entries) {
164
- const existing = this.findByIdentity(incoming.type, incoming.pid);
165
- this.save(this.mergeEntry(incoming, existing));
233
+ this.save(incoming);
166
234
  }
167
235
  });
168
236
  }
@@ -183,7 +251,7 @@ export class AgentRegistry {
183
251
  }
184
252
  this.db.execute(
185
253
  'UPDATE agents SET name = ?, updated_at = ? WHERE type = ? AND pid = ?',
186
- [newName, new Date().toISOString(), existing.type, existing.pid],
254
+ [newName, this.now().toISOString(), existing.type, existing.pid],
187
255
  );
188
256
  });
189
257
  }