@ai-devkit/agent-manager 0.26.1 → 0.26.2

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.
@@ -33,29 +33,28 @@ describe('AgentRegistry', () => {
33
33
  });
34
34
 
35
35
  describe('register', () => {
36
- it('creates the file and parent directory if missing', () => {
36
+ it('creates the SQLite database and parent directory if missing', () => {
37
37
  registry.register(makeEntry());
38
- expect(fs.existsSync(regPath)).toBe(true);
39
- const parsed = JSON.parse(fs.readFileSync(regPath, 'utf8'));
40
- expect(parsed.entries).toHaveLength(1);
41
- expect(parsed.entries[0].name).toBe('agent1');
38
+ expect(fs.existsSync(regPath.replace(/\.json$/, '.db'))).toBe(true);
39
+ expect(registry.list()[0].name).toBe('agent1');
42
40
  });
43
41
 
44
42
  it('appends a new entry when name is unique', () => {
45
43
  registry.register(makeEntry({ name: 'a' }));
46
- registry.register(makeEntry({ name: 'b' }));
44
+ registry.register(makeEntry({ name: 'b', pid: process.ppid }));
47
45
  expect(registry.list()).toHaveLength(2);
48
46
  });
49
47
 
50
- it('upserts in place when name already exists', () => {
51
- registry.register(makeEntry({ name: 'a', pid: 100 }));
52
- registry.register(makeEntry({ name: 'a', pid: 200 }));
48
+ it('upserts in place when type and pid already exist', () => {
49
+ registry.register(makeEntry({ name: 'a', pid: process.pid }));
50
+ registry.register(makeEntry({ name: 'fallback', pid: process.pid, tmuxSession: '' }));
53
51
  const all = registry.list();
54
52
  expect(all).toHaveLength(1);
55
- expect(all[0].pid).toBe(200);
53
+ expect(all[0].pid).toBe(process.pid);
54
+ expect(all[0].name).toBe('a');
56
55
  });
57
56
 
58
- it('writes atomically (no leftover .tmp on success)', () => {
57
+ it('does not write through the legacy fixed .tmp path', () => {
59
58
  registry.register(makeEntry());
60
59
  expect(fs.existsSync(`${regPath}.tmp`)).toBe(false);
61
60
  });
@@ -69,16 +68,18 @@ describe('AgentRegistry', () => {
69
68
 
70
69
  it('preserves existing tmuxSession when incoming is empty string', () => {
71
70
  registry.register(makeEntry({ name: 'a', tmuxSession: 'pinned' }));
72
- registry.register(makeEntry({ name: 'a', tmuxSession: '', pid: 999 }));
71
+ registry.register(makeEntry({ name: 'fallback', tmuxSession: '', pid: process.pid }));
73
72
  const saved = registry.lookup('a');
74
73
  expect(saved?.tmuxSession).toBe('pinned');
75
- expect(saved?.pid).toBe(999);
74
+ expect(saved?.pid).toBe(process.pid);
76
75
  });
77
76
 
78
- it('replaces tmuxSession when incoming is non-empty', () => {
79
- registry.register(makeEntry({ name: 'a', tmuxSession: 'old' }));
80
- registry.register(makeEntry({ name: 'a', tmuxSession: 'new' }));
81
- expect(registry.lookup('a')?.tmuxSession).toBe('new');
77
+ it('lets a managed start entry replace a generated fallback for the same pid', () => {
78
+ registry.register(makeEntry({ name: `ai-devkit-${process.pid}`, tmuxSession: '' }));
79
+ registry.register(makeEntry({ name: 'custom-name', tmuxSession: 'custom-name' }));
80
+ expect(registry.lookup('custom-name')?.tmuxSession).toBe('custom-name');
81
+ expect(registry.lookup(`ai-devkit-${process.pid}`)).toBeNull();
82
+ expect(registry.list()).toHaveLength(1);
82
83
  });
83
84
  });
84
85
 
@@ -88,28 +89,35 @@ describe('AgentRegistry', () => {
88
89
  expect(fs.existsSync(regPath)).toBe(false);
89
90
  });
90
91
 
91
- it('upserts multiple entries with a single write', () => {
92
- const writeSpy = vi.spyOn(fs, 'writeFileSync');
92
+ it('upserts multiple entries in a single batch', () => {
93
93
  registry.registerBatch([
94
94
  makeEntry({ name: 'a' }),
95
- makeEntry({ name: 'b' }),
96
- makeEntry({ name: 'c' }),
95
+ makeEntry({ name: 'b', pid: process.pid + 1 }),
96
+ makeEntry({ name: 'c', pid: process.pid + 2 }),
97
97
  ]);
98
- expect(writeSpy).toHaveBeenCalledTimes(1);
99
- writeSpy.mockRestore();
100
98
  expect(registry.list()).toHaveLength(3);
101
99
  });
102
100
 
103
101
  it('applies the tmuxSession merge per entry', () => {
104
102
  registry.register(makeEntry({ name: 'a', tmuxSession: 'pinned' }));
105
103
  registry.registerBatch([
106
- makeEntry({ name: 'a', tmuxSession: '', pid: 7 }),
107
- makeEntry({ name: 'b', tmuxSession: '' }),
104
+ makeEntry({ name: 'fallback', tmuxSession: '', pid: process.pid }),
105
+ makeEntry({ name: 'b', tmuxSession: '', pid: process.pid + 1 }),
108
106
  ]);
109
107
  expect(registry.lookup('a')?.tmuxSession).toBe('pinned');
110
- expect(registry.lookup('a')?.pid).toBe(7);
108
+ expect(registry.lookup('a')?.pid).toBe(process.pid);
111
109
  expect(registry.lookup('b')?.tmuxSession).toBe('');
112
110
  });
111
+
112
+ it('handles concurrent registry instances without duplicate pid rows', () => {
113
+ const other = new AgentRegistry(regPath);
114
+ registry.register(makeEntry({ name: `ai-devkit-${process.pid}`, tmuxSession: '' }));
115
+ other.register(makeEntry({ name: 'custom-name', tmuxSession: 'custom-name' }));
116
+ registry.register(makeEntry({ name: `ai-devkit-${process.pid}`, tmuxSession: '' }));
117
+
118
+ expect(registry.list()).toHaveLength(1);
119
+ expect(registry.lookup('custom-name')?.pid).toBe(process.pid);
120
+ });
113
121
  });
114
122
 
115
123
  describe('lookup', () => {
@@ -124,20 +132,20 @@ describe('AgentRegistry', () => {
124
132
  });
125
133
 
126
134
  describe('list', () => {
127
- it('returns empty array when file does not exist', () => {
135
+ it('returns empty array when database does not contain entries', () => {
128
136
  expect(registry.list()).toEqual([]);
129
137
  });
130
138
 
131
- it('returns empty array when file is malformed', () => {
139
+ it('ignores existing legacy agents.json entries', () => {
140
+ const legacyEntry = makeEntry({ name: 'legacy', tmuxSession: 'legacy' });
132
141
  fs.mkdirSync(path.dirname(regPath), { recursive: true });
133
- fs.writeFileSync(regPath, 'not json', 'utf8');
134
- expect(registry.list()).toEqual([]);
135
- });
142
+ fs.writeFileSync(regPath, JSON.stringify({ entries: [legacyEntry] }), 'utf8');
136
143
 
137
- it('coerces non-array entries to []', () => {
138
- fs.mkdirSync(path.dirname(regPath), { recursive: true });
139
- fs.writeFileSync(regPath, JSON.stringify({ entries: 'oops' }), 'utf8');
140
- expect(registry.list()).toEqual([]);
144
+ const legacyRegistry = new AgentRegistry(regPath);
145
+
146
+ expect(legacyRegistry.lookup('legacy')).toBeNull();
147
+ expect(legacyRegistry.list()).toEqual([]);
148
+ expect(fs.existsSync(regPath.replace(/\.json$/, '.db'))).toBe(true);
141
149
  });
142
150
  });
143
151
 
@@ -163,10 +171,10 @@ describe('AgentRegistry', () => {
163
171
 
164
172
  it('is a no-op when all entries are alive', () => {
165
173
  registry.register(makeEntry({ pid: process.pid }));
166
- const before = fs.readFileSync(regPath, 'utf8');
174
+ const before = registry.list();
167
175
  registry.prune();
168
- const after = fs.readFileSync(regPath, 'utf8');
169
- expect(after).toBe(before);
176
+ const after = registry.list();
177
+ expect(after).toEqual(before);
170
178
  });
171
179
 
172
180
  it('does nothing when file is missing', () => {
@@ -203,7 +211,7 @@ describe('AgentRegistry', () => {
203
211
 
204
212
  it('throws RenameConflictError when new name is already in use by a live entry', () => {
205
213
  registry.register(makeEntry({ name: 'agent-a', pid: process.pid }));
206
- registry.register(makeEntry({ name: 'agent-b', pid: process.pid }));
214
+ registry.register(makeEntry({ name: 'agent-b', pid: process.ppid }));
207
215
  expect(() => registry.rename('agent-a', 'agent-b')).toThrow(RenameConflictError);
208
216
  });
209
217
 
@@ -214,7 +222,7 @@ describe('AgentRegistry', () => {
214
222
  expect(registry.lookup('agent-b')?.pid).toBe(process.pid);
215
223
  });
216
224
 
217
- it('writes atomically (no leftover .tmp on success)', () => {
225
+ it('does not create the legacy fixed .tmp path on rename', () => {
218
226
  registry.register(makeEntry({ name: 'old-name', pid: process.pid }));
219
227
  registry.rename('old-name', 'new-name');
220
228
  expect(fs.existsSync(`${regPath}.tmp`)).toBe(false);
@@ -0,0 +1,74 @@
1
+ import Database from 'better-sqlite3';
2
+ import { mkdirSync } from 'fs';
3
+ import { dirname, join } from 'path';
4
+ import { homedir } from 'os';
5
+ import { initializeSchema } from './schema.js';
6
+
7
+ export const DEFAULT_AGENT_REGISTRY_DB_PATH = join(homedir(), '.ai-devkit', 'agents.db');
8
+
9
+ export interface DatabaseOptions {
10
+ dbPath?: string;
11
+ verbose?: boolean;
12
+ readonly?: boolean;
13
+ }
14
+
15
+ export function resolveAgentRegistryDbPath(filePath?: string): string {
16
+ if (!filePath) return DEFAULT_AGENT_REGISTRY_DB_PATH;
17
+ return filePath.endsWith('.json') ? filePath.replace(/\.json$/, '.db') : filePath;
18
+ }
19
+
20
+ export class DatabaseConnection {
21
+ private db: Database.Database;
22
+ private readonly dbPath: string;
23
+
24
+ constructor(options: DatabaseOptions = {}) {
25
+ this.dbPath = options.dbPath ?? DEFAULT_AGENT_REGISTRY_DB_PATH;
26
+ mkdirSync(dirname(this.dbPath), { recursive: true });
27
+
28
+ this.db = new Database(this.dbPath, {
29
+ readonly: options.readonly ?? false,
30
+ verbose: options.verbose ? console.log : undefined,
31
+ });
32
+
33
+ this.configure();
34
+ initializeSchema(this);
35
+ }
36
+
37
+ private configure(): void {
38
+ this.db.pragma('journal_mode = WAL');
39
+ this.db.pragma('foreign_keys = ON');
40
+ this.db.pragma('synchronous = NORMAL');
41
+ this.db.pragma('busy_timeout = 5000');
42
+ this.db.pragma('mmap_size = 268435456');
43
+ }
44
+
45
+ get instance(): Database.Database {
46
+ return this.db;
47
+ }
48
+
49
+ get path(): string {
50
+ return this.dbPath;
51
+ }
52
+
53
+ query<T>(sql: string, params: unknown[] = []): T[] {
54
+ return this.db.prepare(sql).all(...params) as T[];
55
+ }
56
+
57
+ queryOne<T>(sql: string, params: unknown[] = []): T | undefined {
58
+ return this.db.prepare(sql).get(...params) as T | undefined;
59
+ }
60
+
61
+ execute(sql: string, params: unknown[] = []): Database.RunResult {
62
+ return this.db.prepare(sql).run(...params);
63
+ }
64
+
65
+ transaction<T>(fn: () => T): T {
66
+ return this.db.transaction(fn)();
67
+ }
68
+
69
+ close(): void {
70
+ if (this.db.open) {
71
+ this.db.close();
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ DatabaseConnection,
3
+ DEFAULT_AGENT_REGISTRY_DB_PATH,
4
+ resolveAgentRegistryDbPath,
5
+ } from './connection.js';
6
+ export type { DatabaseOptions } from './connection.js';
7
+ export { getSchemaVersion, initializeSchema } from './schema.js';
@@ -0,0 +1,12 @@
1
+ CREATE TABLE IF NOT EXISTS agents (
2
+ type TEXT NOT NULL,
3
+ pid INTEGER NOT NULL,
4
+ name TEXT NOT NULL UNIQUE,
5
+ tmux_session TEXT NOT NULL DEFAULT '',
6
+ cwd TEXT NOT NULL DEFAULT '',
7
+ started_at TEXT NOT NULL,
8
+ session_id TEXT NOT NULL DEFAULT '',
9
+ session_file_path TEXT NOT NULL DEFAULT '',
10
+ updated_at TEXT NOT NULL,
11
+ PRIMARY KEY (type, pid)
12
+ );
@@ -0,0 +1,62 @@
1
+ import { readFileSync, readdirSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import type { DatabaseConnection } from './connection.js';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+
8
+ export function getSchemaVersion(db: DatabaseConnection): number {
9
+ const result = db.instance.pragma('user_version') as { user_version: number }[];
10
+ return result[0]?.user_version ?? 0;
11
+ }
12
+
13
+ function setSchemaVersion(db: DatabaseConnection, version: number): void {
14
+ db.instance.pragma(`user_version = ${version}`);
15
+ }
16
+
17
+ function getMigrationsDir(): string {
18
+ return join(__dirname, 'migrations');
19
+ }
20
+
21
+ interface Migration {
22
+ version: number;
23
+ path: string;
24
+ name: string;
25
+ }
26
+
27
+ function getMigrationFiles(): Migration[] {
28
+ const migrationsDir = getMigrationsDir();
29
+
30
+ let files: string[];
31
+ try {
32
+ files = readdirSync(migrationsDir).filter((f) => f.endsWith('.sql')).sort();
33
+ } catch {
34
+ return [];
35
+ }
36
+
37
+ return files.map((file) => {
38
+ const match = file.match(/^(\d+)_(.+)\.sql$/);
39
+ if (!match || !match[1] || !match[2]) {
40
+ throw new Error(`Invalid migration filename: ${file}. Expected format: 001_name.sql`);
41
+ }
42
+ return {
43
+ version: parseInt(match[1], 10),
44
+ name: match[2],
45
+ path: join(migrationsDir, file),
46
+ };
47
+ });
48
+ }
49
+
50
+ export function initializeSchema(db: DatabaseConnection): void {
51
+ const currentVersion = getSchemaVersion(db);
52
+ const pendingMigrations = getMigrationFiles().filter((m) => m.version > currentVersion);
53
+
54
+ for (const migration of pendingMigrations) {
55
+ const sql = readFileSync(migration.path, 'utf-8');
56
+
57
+ db.transaction(() => {
58
+ db.instance.exec(sql);
59
+ setSchemaVersion(db, migration.version);
60
+ });
61
+ }
62
+ }
@@ -1,7 +1,10 @@
1
- import fs from 'fs';
2
1
  import os from 'os';
3
2
  import path from 'path';
4
3
  import type { AgentType } from '../adapters/AgentAdapter.js';
4
+ import {
5
+ DatabaseConnection,
6
+ resolveAgentRegistryDbPath,
7
+ } from '../database/index.js';
5
8
 
6
9
  export class RenameNotFoundError extends Error {
7
10
  constructor(public agentName: string) {
@@ -28,8 +31,16 @@ export interface RegistryEntry {
28
31
  sessionFilePath: string;
29
32
  }
30
33
 
31
- interface RegistryFile {
32
- entries: RegistryEntry[];
34
+ interface RegistryRow {
35
+ name: string;
36
+ type: AgentType;
37
+ pid: number;
38
+ tmux_session: string;
39
+ cwd: string;
40
+ started_at: string;
41
+ session_id: string;
42
+ session_file_path: string;
43
+ updated_at: string;
33
44
  }
34
45
 
35
46
  const DEFAULT_REGISTRY_PATH = path.join(os.homedir(), '.ai-devkit', 'agents.json');
@@ -37,10 +48,10 @@ const DEFAULT_REGISTRY_PATH = path.join(os.homedir(), '.ai-devkit', 'agents.json
37
48
  let defaultInstance: AgentRegistry | null = null;
38
49
 
39
50
  export class AgentRegistry {
40
- private filePath: string;
51
+ private db: DatabaseConnection;
41
52
 
42
53
  constructor(filePath: string = DEFAULT_REGISTRY_PATH) {
43
- this.filePath = filePath;
54
+ this.db = new DatabaseConnection({ dbPath: resolveAgentRegistryDbPath(filePath) });
44
55
  }
45
56
 
46
57
  static default(): AgentRegistry {
@@ -50,32 +61,79 @@ export class AgentRegistry {
50
61
  return defaultInstance;
51
62
  }
52
63
 
53
- private readFile(): RegistryFile {
54
- try {
55
- const raw = fs.readFileSync(this.filePath, 'utf8');
56
- const parsed = JSON.parse(raw) as RegistryFile;
57
- return { entries: Array.isArray(parsed.entries) ? parsed.entries : [] };
58
- } catch {
59
- return { entries: [] };
60
- }
61
- }
62
-
63
- private writeFile(data: RegistryFile): void {
64
- const dir = path.dirname(this.filePath);
65
- fs.mkdirSync(dir, { recursive: true });
66
- const tmp = `${this.filePath}.tmp`;
67
- fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
68
- fs.renameSync(tmp, this.filePath);
64
+ private rowToEntry(row: RegistryRow): RegistryEntry {
65
+ return {
66
+ name: row.name,
67
+ type: row.type,
68
+ pid: row.pid,
69
+ tmuxSession: row.tmux_session,
70
+ cwd: row.cwd,
71
+ startedAt: row.started_at,
72
+ sessionId: row.session_id,
73
+ sessionFilePath: row.session_file_path,
74
+ };
69
75
  }
70
76
 
71
77
  private mergeEntry(incoming: RegistryEntry, existing: RegistryEntry | undefined): RegistryEntry {
72
78
  if (!existing) return incoming;
79
+ const incomingIsManaged = Boolean(incoming.tmuxSession);
73
80
  return {
74
- ...incoming,
81
+ ...existing,
82
+ name: incomingIsManaged ? incoming.name : existing.name,
75
83
  tmuxSession: incoming.tmuxSession || existing.tmuxSession,
84
+ cwd: incoming.cwd || existing.cwd,
85
+ startedAt: existing.startedAt || incoming.startedAt,
86
+ sessionId: incoming.sessionId || existing.sessionId,
87
+ sessionFilePath: incoming.sessionFilePath || existing.sessionFilePath,
76
88
  };
77
89
  }
78
90
 
91
+ private findByIdentity(type: AgentType, pid: number): RegistryEntry | undefined {
92
+ const row = this.db.queryOne<RegistryRow>(
93
+ 'SELECT * FROM agents WHERE type = ? AND pid = ?',
94
+ [type, pid],
95
+ );
96
+ return row ? this.rowToEntry(row) : undefined;
97
+ }
98
+
99
+ private findByName(name: string): RegistryEntry | undefined {
100
+ const row = this.db.queryOne<RegistryRow>('SELECT * FROM agents WHERE name = ?', [name]);
101
+ return row ? this.rowToEntry(row) : undefined;
102
+ }
103
+
104
+ private deleteNameConflict(name: string, type: AgentType, pid: number): void {
105
+ const conflict = this.findByName(name);
106
+ if (!conflict) return;
107
+ if (conflict.type === type && conflict.pid === pid) return;
108
+ if (!this.isAlive(conflict)) {
109
+ this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [conflict.type, conflict.pid]);
110
+ }
111
+ }
112
+
113
+ private insertOrUpdate(entry: RegistryEntry): void {
114
+ this.db.instance.prepare(`
115
+ INSERT INTO agents (
116
+ type, pid, name, tmux_session, cwd, started_at, session_id, session_file_path, updated_at
117
+ )
118
+ VALUES (
119
+ @type, @pid, @name, @tmuxSession, @cwd, @startedAt, @sessionId, @sessionFilePath, @updatedAt
120
+ )
121
+ ON CONFLICT(type, pid) DO UPDATE SET
122
+ name = excluded.name,
123
+ tmux_session = excluded.tmux_session,
124
+ cwd = excluded.cwd,
125
+ started_at = agents.started_at,
126
+ session_id = excluded.session_id,
127
+ session_file_path = excluded.session_file_path,
128
+ updated_at = excluded.updated_at
129
+ `).run({ ...entry, updatedAt: new Date().toISOString() });
130
+ }
131
+
132
+ private save(entry: RegistryEntry): void {
133
+ this.deleteNameConflict(entry.name, entry.type, entry.pid);
134
+ this.insertOrUpdate(entry);
135
+ }
136
+
79
137
  isAlive(entry: RegistryEntry): boolean {
80
138
  try {
81
139
  process.kill(entry.pid, 0);
@@ -86,11 +144,13 @@ export class AgentRegistry {
86
144
  }
87
145
 
88
146
  prune(): void {
89
- const data = this.readFile();
90
- const live = data.entries.filter((e) => this.isAlive(e));
91
- if (live.length !== data.entries.length) {
92
- this.writeFile({ entries: live });
93
- }
147
+ const entries = this.list();
148
+ 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
+ });
94
154
  }
95
155
 
96
156
  register(entry: RegistryEntry): void {
@@ -99,41 +159,41 @@ export class AgentRegistry {
99
159
 
100
160
  registerBatch(entries: RegistryEntry[]): void {
101
161
  if (entries.length === 0) return;
102
- const data = this.readFile();
103
- for (const incoming of entries) {
104
- const idx = data.entries.findIndex((e) => e.name === incoming.name);
105
- if (idx >= 0) {
106
- data.entries[idx] = this.mergeEntry(incoming, data.entries[idx]);
107
- } else {
108
- data.entries.push(incoming);
162
+ this.db.transaction(() => {
163
+ for (const incoming of entries) {
164
+ const existing = this.findByIdentity(incoming.type, incoming.pid);
165
+ this.save(this.mergeEntry(incoming, existing));
109
166
  }
110
- }
111
- this.writeFile(data);
167
+ });
112
168
  }
113
169
 
114
170
  rename(currentName: string, newName: string): void {
115
- const data = this.readFile();
116
- const idx = data.entries.findIndex((e) => e.name === currentName);
117
- if (idx < 0) {
171
+ const existing = this.findByName(currentName);
172
+ if (!existing) {
118
173
  throw new RenameNotFoundError(currentName);
119
174
  }
120
- const liveEntries = data.entries.filter((e) => this.isAlive(e));
121
- const conflict = liveEntries.find((e) => e.name === newName);
122
- if (conflict) {
175
+ const conflict = this.findByName(newName);
176
+ if (conflict && this.isAlive(conflict)) {
123
177
  throw new RenameConflictError(newName);
124
178
  }
125
- const pruned = liveEntries.map((e) =>
126
- e.name === currentName ? { ...e, name: newName } : e,
127
- );
128
- this.writeFile({ entries: pruned });
179
+
180
+ this.db.transaction(() => {
181
+ if (conflict) {
182
+ this.db.execute('DELETE FROM agents WHERE type = ? AND pid = ?', [conflict.type, conflict.pid]);
183
+ }
184
+ this.db.execute(
185
+ 'UPDATE agents SET name = ?, updated_at = ? WHERE type = ? AND pid = ?',
186
+ [newName, new Date().toISOString(), existing.type, existing.pid],
187
+ );
188
+ });
129
189
  }
130
190
 
131
191
  lookup(name: string): RegistryEntry | null {
132
- const data = this.readFile();
133
- return data.entries.find((e) => e.name === name) ?? null;
192
+ return this.findByName(name) ?? null;
134
193
  }
135
194
 
136
195
  list(): RegistryEntry[] {
137
- return this.readFile().entries;
196
+ const rows = this.db.query<RegistryRow>('SELECT * FROM agents ORDER BY started_at ASC, name ASC');
197
+ return rows.map((row) => this.rowToEntry(row));
138
198
  }
139
199
  }