@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/dist/AgentManager.d.ts +5 -0
- package/dist/AgentManager.d.ts.map +1 -1
- package/dist/AgentManager.js +21 -1
- package/dist/AgentManager.js.map +1 -1
- package/dist/__tests__/AgentManager.test.js +112 -0
- package/dist/__tests__/AgentManager.test.js.map +1 -1
- package/dist/__tests__/utils/AgentRegistry.test.js +67 -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 +1 -0
- package/dist/database/connection.d.ts.map +1 -1
- package/dist/database/connection.js +9 -2
- package/dist/database/connection.js.map +1 -1
- package/dist/database/migrations/002_pins.sql +1 -0
- package/dist/index.d.ts +1 -1
- 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 +5 -0
- package/dist/utils/AgentRegistry.d.ts.map +1 -1
- package/dist/utils/AgentRegistry.js +19 -2
- package/dist/utils/AgentRegistry.js.map +1 -1
- package/package.json +1 -1
- package/src/AgentManager.ts +21 -0
- package/src/__tests__/AgentManager.test.ts +117 -0
- package/src/__tests__/utils/AgentRegistry.test.ts +68 -0
- package/src/adapters/AgentAdapter.ts +3 -0
- package/src/database/connection.ts +9 -2
- package/src/database/migrations/002_pins.sql +1 -0
- package/src/index.ts +1 -1
- package/src/utils/AgentRegistry.ts +21 -0
package/package.json
CHANGED
package/src/AgentManager.ts
CHANGED
|
@@ -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:
|
|
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
|
@@ -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
|
}
|