@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 +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 pinned: boolean;\n updatedAt?: 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 pinned: number;\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 readonly?: boolean;\n}\n\nexport class AgentRegistry {\n private db: DatabaseConnection;\n private readonly now: () => Date;\n private readonly pruneIntervalMs: number;\n private readonly readonly: boolean;\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.readonly = options.readonly ?? false;\n this.db = new DatabaseConnection({\n dbPath: resolveAgentRegistryDbPath(filePath),\n verbose: options.onDatabaseOperation,\n readonly: this.readonly,\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 pinned: row.pinned !== 0,\n updatedAt: row.updated_at,\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 togglePin(type: AgentType, pid: number): boolean | null {\n if (this.readonly) {\n throw new Error('Agent registry is readonly; cannot toggle pin.');\n }\n const result = this.db.execute(\n 'UPDATE agents SET pinned = NOT pinned, updated_at = ? WHERE type = ? AND pid = ?',\n [this.now().toISOString(), type, pid],\n );\n if (result.changes === 0) return null;\n return this.findByIdentity(type, pid)?.pinned ?? null;\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","readonly","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","pinned","updatedAt","updated_at","mergeEntry","incoming","existing","incomingIsManaged","Boolean","findByIdentity","queryOne","undefined","findByName","findPidConflicts","query","map","entriesEqual","left","right","deleteNameConflict","conflict","isAlive","execute","insertOrUpdate","entry","instance","prepare","run","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","togglePin","result","changes","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.27.0",
4
4
  "type": "module",
5
5
  "description": "Standalone agent detection and management utilities for AI DevKit",
6
6
  "main": "dist/index.js",
@@ -26,6 +26,13 @@ export interface ListAgentsOptions {
26
26
  sortBy?: AgentSortKey;
27
27
  }
28
28
 
29
+ export class AgentNotRunningError extends Error {
30
+ constructor(public agentName: string) {
31
+ super(`Agent "${agentName}" is no longer running.`);
32
+ this.name = 'AgentNotRunningError';
33
+ }
34
+ }
35
+
29
36
  /**
30
37
  * Agent Manager Class
31
38
  *
@@ -180,17 +187,22 @@ export class AgentManager {
180
187
  });
181
188
  }
182
189
 
183
- const preExistingByPid = new Map(this.registry.list().map((e) => [e.pid, e]));
190
+ const identityKey = (type: string, pid: number): string => `${type}:${pid}`;
191
+ const preExistingByIdentity = new Map(
192
+ this.registry.list().map((entry) => [identityKey(entry.type, entry.pid), entry]),
193
+ );
184
194
  const entries = allAgents.map((agent) =>
185
- this.toRegistryEntry(agent, preExistingByPid.get(agent.pid)),
195
+ this.toRegistryEntry(agent, preExistingByIdentity.get(identityKey(agent.type, agent.pid))),
186
196
  );
187
197
  if (entries.length > 0) this.registry.registerBatch(entries);
188
- this.registry.prune();
198
+ this.registry.pruneIfDue();
189
199
 
190
200
  for (const agent of allAgents) {
191
- const entry = preExistingByPid.get(agent.pid);
201
+ const entry = preExistingByIdentity.get(identityKey(agent.type, agent.pid));
192
202
  if (entry) {
193
203
  agent.name = entry.name;
204
+ agent.pinned = entry.pinned;
205
+ if (entry.pinned && entry.updatedAt) agent.lastActive = new Date(entry.updatedAt);
194
206
  }
195
207
  }
196
208
 
@@ -208,9 +220,21 @@ export class AgentManager {
208
220
  startedAt: existing?.startedAt ?? new Date().toISOString(),
209
221
  sessionId: agent.sessionId,
210
222
  sessionFilePath: agent.sessionFilePath ?? '',
223
+ pinned: existing?.pinned ?? agent.pinned ?? false,
211
224
  };
212
225
  }
213
226
 
227
+ togglePin(agentName: string): boolean {
228
+ const entry = this.registry.lookup(agentName);
229
+ if (!entry || !this.registry.isAlive(entry)) {
230
+ if (entry) this.registry.prune();
231
+ throw new AgentNotRunningError(agentName);
232
+ }
233
+ const pinned = this.registry.togglePin(entry.type, entry.pid);
234
+ if (pinned === null) throw new AgentNotRunningError(agentName);
235
+ return pinned;
236
+ }
237
+
214
238
  /**
215
239
  * List historical sessions across every registered adapter.
216
240
  *
@@ -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,231 @@ 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('exposes a persisted pin and preserves it across a changed poll refresh', async () => {
558
+ const adapter = new MockAdapter('claude', [
559
+ createMockAgent({ name: 'pinned', pid: process.pid, sessionId: 'before' }),
560
+ ]);
561
+ scopedManager.registerAdapter(adapter);
562
+ await scopedManager.listAgents();
563
+ registry.togglePin('claude', process.pid);
564
+ adapter.setAgents([
565
+ createMockAgent({ name: 'pinned', pid: process.pid, sessionId: 'after' }),
566
+ ]);
567
+
568
+ const agents = await scopedManager.listAgents();
569
+
570
+ expect(agents[0].pinned).toBe(true);
571
+ expect(registry.lookup('pinned')).toMatchObject({ sessionId: 'after', pinned: true });
572
+ });
573
+
574
+ it('uses registry updated_at as lastActive for pinned recency ordering', async () => {
575
+ const adapter = new MockAdapter('claude', [
576
+ createMockAgent({
577
+ name: 'recently-pinned',
578
+ pid: process.pid,
579
+ lastActive: new Date('2026-01-01T00:00:00.000Z'),
580
+ }),
581
+ ]);
582
+ scopedManager.registerAdapter(adapter);
583
+ await scopedManager.listAgents();
584
+ nowMs += 60_000;
585
+ scopedManager.togglePin('recently-pinned');
586
+
587
+ const agents = await scopedManager.listAgents();
588
+
589
+ expect(agents[0].pinned).toBe(true);
590
+ expect(agents[0].lastActive.toISOString()).toBe('2026-08-14T10:01:00.000Z');
591
+ });
592
+
593
+ it('preserves adapter lastActive for unpinned agents', async () => {
594
+ scopedManager.registerAdapter(new MockAdapter('claude', [
595
+ createMockAgent({
596
+ name: 'unpinned',
597
+ pid: process.pid,
598
+ lastActive: new Date('2026-01-01T00:00:00.000Z'),
599
+ }),
600
+ ]));
601
+
602
+ await scopedManager.listAgents();
603
+ const agents = await scopedManager.listAgents();
604
+
605
+ expect(agents[0].pinned).toBe(false);
606
+ expect(agents[0].lastActive.toISOString()).toBe('2026-01-01T00:00:00.000Z');
607
+ });
608
+
609
+ it('persists changed fields once in one write transaction', async () => {
610
+ const adapter = new MockAdapter('claude', [
611
+ createMockAgent({ name: 'changing', pid: process.pid, projectPath: '/cwd/before' }),
612
+ ]);
613
+ scopedManager.registerAdapter(adapter);
614
+ await scopedManager.listAgents();
615
+ databaseOperations = [];
616
+ nowMs += 1_000;
617
+ adapter.setAgents([
618
+ createMockAgent({ name: 'changing', pid: process.pid, projectPath: '/cwd/after' }),
619
+ ]);
620
+
621
+ await scopedManager.listAgents();
622
+
623
+ const upserts = databaseOperations.filter((sql) => /^\s*INSERT INTO agents/i.test(sql));
624
+ const transactions = databaseOperations.filter((sql) => /^\s*(BEGIN|COMMIT)/i.test(sql));
625
+ expect(upserts).toHaveLength(1);
626
+ expect(upserts[0]).toContain("'2026-08-14T10:00:01.000Z'");
627
+ expect(transactions).toHaveLength(2);
628
+ expect(registry.lookup('changing')?.cwd).toBe('/cwd/after');
629
+ });
630
+
631
+ it('prunes newly dead entries only when the passive cadence is due', async () => {
632
+ registry.register({
633
+ name: 'cadenced',
634
+ type: 'claude',
635
+ pid: process.pid,
636
+ tmuxSession: '',
637
+ cwd: '/cwd/cadenced',
638
+ startedAt: '2026-05-30T00:00:00.000Z',
639
+ sessionId: 'sid-cadenced',
640
+ sessionFilePath: '',
641
+ });
642
+ const alive = vi.spyOn(registry, 'isAlive').mockReturnValue(true);
643
+
644
+ await scopedManager.listAgents();
645
+ expect(alive).toHaveBeenCalledTimes(1);
646
+ alive.mockReturnValue(false);
647
+ nowMs += 29_999;
648
+
649
+ await scopedManager.listAgents();
650
+ expect(alive).toHaveBeenCalledTimes(1);
651
+ expect(registry.lookup('cadenced')).not.toBeNull();
652
+
653
+ nowMs += 1;
654
+ await scopedManager.listAgents();
655
+ expect(alive).toHaveBeenCalledTimes(2);
656
+ expect(registry.lookup('cadenced')).toBeNull();
657
+ });
658
+
659
+ it('does not inherit a name when the same pid is reused by another agent type', async () => {
660
+ registry.register({
661
+ name: 'old-claude',
662
+ type: 'claude',
663
+ pid: process.pid,
664
+ tmuxSession: 'old-claude',
665
+ cwd: '/cwd/old',
666
+ startedAt: '2026-05-30T00:00:00.000Z',
667
+ sessionId: 'old-session',
668
+ sessionFilePath: '',
669
+ });
670
+ scopedManager.registerAdapter(new MockAdapter('codex', [
671
+ createMockAgent({
672
+ name: 'new-codex',
673
+ type: 'codex',
674
+ pid: process.pid,
675
+ projectPath: '/cwd/new',
676
+ sessionId: 'new-session',
677
+ }),
678
+ ]));
679
+
680
+ const agents = await scopedManager.listAgents();
681
+
682
+ expect(agents[0].name).toBe('new-codex');
683
+ expect(registry.lookup('old-claude')).toBeNull();
684
+ expect(registry.lookup('new-codex')).toMatchObject({ type: 'codex', pid: process.pid });
685
+ });
686
+
687
+ it('skips registerBatch when no agents are detected and prune is not due', async () => {
499
688
  const writeSpy = vi.spyOn(registry, 'registerBatch');
500
- const pruneSpy = vi.spyOn(registry, 'prune');
689
+ const pruneSpy = vi.spyOn(registry, 'pruneIfDue');
501
690
 
502
691
  scopedManager.registerAdapter(new MockAdapter('claude', []));
503
692
  await scopedManager.listAgents();
693
+ await scopedManager.listAgents();
504
694
 
505
695
  expect(writeSpy).not.toHaveBeenCalled();
506
- expect(pruneSpy).toHaveBeenCalledTimes(1);
696
+ expect(pruneSpy).toHaveBeenCalledTimes(2);
697
+ });
698
+ });
699
+
700
+ describe('togglePin', () => {
701
+ it('resolves the agent name to its process identity and toggles the pin', () => {
702
+ const registry = new AgentRegistry(path.join(tmpDir, 'toggle.json'));
703
+ const scopedManager = new AgentManager(registry);
704
+ registry.register({
705
+ name: 'renamed-agent',
706
+ type: 'claude',
707
+ pid: process.pid,
708
+ tmuxSession: '',
709
+ cwd: '/tmp',
710
+ startedAt: '2026-08-16T00:00:00.000Z',
711
+ sessionId: 'session',
712
+ sessionFilePath: '',
713
+ pinned: false,
714
+ });
715
+
716
+ expect(scopedManager.togglePin('renamed-agent')).toBe(true);
717
+ expect(registry.lookup('renamed-agent')?.pinned).toBe(true);
718
+ });
719
+
720
+ it('reports when the agent is no longer running', () => {
721
+ expect(() => manager.togglePin('missing')).toThrow(/no longer running/i);
722
+ });
723
+
724
+ it('rejects a dead process and prunes its row', () => {
725
+ const registry = new AgentRegistry(path.join(tmpDir, 'dead-toggle.json'));
726
+ const scopedManager = new AgentManager(registry);
727
+ registry.register({
728
+ name: 'dead',
729
+ type: 'claude',
730
+ pid: 999999,
731
+ tmuxSession: '',
732
+ cwd: '/tmp',
733
+ startedAt: '2026-08-16T00:00:00.000Z',
734
+ sessionId: 'session',
735
+ sessionFilePath: '',
736
+ pinned: false,
737
+ });
738
+
739
+ expect(() => scopedManager.togglePin('dead')).toThrow(/no longer running/i);
740
+ expect(registry.lookup('dead')).toBeNull();
741
+ });
742
+
743
+ it('surfaces a clear readonly mutation error', () => {
744
+ const regPath = path.join(tmpDir, 'readonly-toggle.json');
745
+ const writable = new AgentRegistry(regPath);
746
+ writable.register({
747
+ name: 'readonly-agent',
748
+ type: 'claude',
749
+ pid: process.pid,
750
+ tmuxSession: '',
751
+ cwd: '/tmp',
752
+ startedAt: '2026-08-16T00:00:00.000Z',
753
+ sessionId: 'session',
754
+ sessionFilePath: '',
755
+ pinned: false,
756
+ });
757
+ const readonlyManager = new AgentManager(new AgentRegistry(regPath, { readonly: true }));
758
+
759
+ expect(() => readonlyManager.togglePin('readonly-agent')).toThrow(
760
+ 'Agent registry is readonly; cannot toggle pin.',
761
+ );
507
762
  });
508
763
  });
509
764