@ai-devkit/agent-manager 0.26.4 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-devkit/agent-manager",
3
- "version": "0.26.4",
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
  *
@@ -194,6 +201,8 @@ export class AgentManager {
194
201
  const entry = preExistingByIdentity.get(identityKey(agent.type, agent.pid));
195
202
  if (entry) {
196
203
  agent.name = entry.name;
204
+ agent.pinned = entry.pinned;
205
+ if (entry.pinned && entry.updatedAt) agent.lastActive = new Date(entry.updatedAt);
197
206
  }
198
207
  }
199
208
 
@@ -211,9 +220,21 @@ export class AgentManager {
211
220
  startedAt: existing?.startedAt ?? new Date().toISOString(),
212
221
  sessionId: agent.sessionId,
213
222
  sessionFilePath: agent.sessionFilePath ?? '',
223
+ pinned: existing?.pinned ?? agent.pinned ?? false,
214
224
  };
215
225
  }
216
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
+
217
238
  /**
218
239
  * List historical sessions across every registered adapter.
219
240
  *
@@ -554,6 +554,58 @@ describe('AgentManager', () => {
554
554
  expect(writes).toEqual([]);
555
555
  });
556
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
+
557
609
  it('persists changed fields once in one write transaction', async () => {
558
610
  const adapter = new MockAdapter('claude', [
559
611
  createMockAgent({ name: 'changing', pid: process.pid, projectPath: '/cwd/before' }),
@@ -645,6 +697,71 @@ describe('AgentManager', () => {
645
697
  });
646
698
  });
647
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
+ );
762
+ });
763
+ });
764
+
648
765
  describe('clear', () => {
649
766
  it('should remove all adapters', () => {
650
767
  manager.registerAdapter(new MockAdapter('claude'));
@@ -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
  }
@@ -172,6 +174,72 @@ describe('AgentRegistry', () => {
172
174
  });
173
175
  });
174
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
+
175
243
  describe('list', () => {
176
244
  it('returns empty array when database does not contain entries', () => {
177
245
  expect(registry.list()).toEqual([]);
@@ -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
  }
@@ -20,23 +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,
31
+ readonly: this.readonly,
30
32
  verbose: typeof options.verbose === 'function'
31
33
  ? options.verbose
32
34
  : options.verbose ? console.log : undefined,
33
35
  });
34
36
 
35
37
  this.configure();
36
- initializeSchema(this);
38
+ if (!this.readonly) initializeSchema(this);
37
39
  }
38
40
 
39
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
+ }
40
47
  this.db.pragma('journal_mode = WAL');
41
48
  this.db.pragma('foreign_keys = ON');
42
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,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,6 +43,7 @@ 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');
@@ -52,20 +55,24 @@ export interface AgentRegistryOptions {
52
55
  now?: () => Date;
53
56
  pruneIntervalMs?: number;
54
57
  onDatabaseOperation?: (sql: string) => void;
58
+ readonly?: boolean;
55
59
  }
56
60
 
57
61
  export class AgentRegistry {
58
62
  private db: DatabaseConnection;
59
63
  private readonly now: () => Date;
60
64
  private readonly pruneIntervalMs: number;
65
+ private readonly readonly: boolean;
61
66
  private lastPrunedAt: number | undefined;
62
67
 
63
68
  constructor(filePath: string = DEFAULT_REGISTRY_PATH, options: AgentRegistryOptions = {}) {
64
69
  this.now = options.now ?? (() => new Date());
65
70
  this.pruneIntervalMs = options.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS;
71
+ this.readonly = options.readonly ?? false;
66
72
  this.db = new DatabaseConnection({
67
73
  dbPath: resolveAgentRegistryDbPath(filePath),
68
74
  verbose: options.onDatabaseOperation,
75
+ readonly: this.readonly,
69
76
  });
70
77
  }
71
78
 
@@ -86,6 +93,8 @@ export class AgentRegistry {
86
93
  startedAt: row.started_at,
87
94
  sessionId: row.session_id,
88
95
  sessionFilePath: row.session_file_path,
96
+ pinned: row.pinned !== 0,
97
+ updatedAt: row.updated_at,
89
98
  };
90
99
  }
91
100
 
@@ -256,6 +265,18 @@ export class AgentRegistry {
256
265
  });
257
266
  }
258
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
+
259
280
  lookup(name: string): RegistryEntry | null {
260
281
  return this.findByName(name) ?? null;
261
282
  }