@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 +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
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
|
*
|
|
@@ -180,17 +187,22 @@ export class AgentManager {
|
|
|
180
187
|
});
|
|
181
188
|
}
|
|
182
189
|
|
|
183
|
-
const
|
|
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,
|
|
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.
|
|
198
|
+
this.registry.pruneIfDue();
|
|
189
199
|
|
|
190
200
|
for (const agent of allAgents) {
|
|
191
|
-
const entry =
|
|
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
|
-
|
|
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('
|
|
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, '
|
|
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(
|
|
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
|
|