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