Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions src/daemon/agent-registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it, expect } from 'vitest';
import { AgentRegistry } from './agent-registry.js';

function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'agent-registry-'));
}

describe('AgentRegistry', () => {
it('creates and persists agent records', () => {
const dir = makeTempDir();
const registry = new AgentRegistry(dir);

const created = registry.registerOrUpdate({
name: 'alice',
cli: 'claude',
workingDirectory: '/tmp/alice',
});

expect(created.id).toBeTruthy();
expect(created.firstSeen).toBeTruthy();
expect(created.messagesSent).toBe(0);
expect(created.messagesReceived).toBe(0);

registry.recordSend('alice');
registry.recordReceive('alice');

const agentsPath = path.join(dir, 'agents.json');
const fileData = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
const fileAgent = fileData.agents.find((a: any) => a.name === 'alice');
expect(fileAgent.messagesSent).toBe(1);
expect(fileAgent.messagesReceived).toBe(1);

const registryReloaded = new AgentRegistry(dir);
const [loaded] = registryReloaded.getAgents();
expect(loaded.name).toBe('alice');
expect(loaded.messagesSent).toBe(1);
expect(loaded.messagesReceived).toBe(1);
});

it('updates metadata on re-register', () => {
const dir = makeTempDir();
const registry = new AgentRegistry(dir);

registry.registerOrUpdate({
name: 'bob',
cli: 'claude',
workingDirectory: '/tmp/one',
});
const first = registry.getAgents()[0];

registry.registerOrUpdate({
name: 'bob',
cli: 'gemini',
workingDirectory: '/tmp/two',
});
const [updated] = registry.getAgents();

expect(updated.firstSeen).toBe(first.firstSeen);
expect(updated.cli).toBe('gemini');
expect(updated.workingDirectory).toBe('/tmp/two');
expect(new Date(updated.lastSeen).getTime()).toBeGreaterThanOrEqual(new Date(first.lastSeen).getTime());
});

it('handles malformed agents.json gracefully', () => {
const dir = makeTempDir();
const agentsPath = path.join(dir, 'agents.json');
fs.writeFileSync(agentsPath, '{bad json', 'utf-8');
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

const registry = new AgentRegistry(dir);
expect(registry.getAgents()).toEqual([]);
expect(errorSpy).toHaveBeenCalled();

errorSpy.mockRestore();
});

it('logs write failures without throwing', () => {
const dir = makeTempDir();
const registry = new AgentRegistry(dir);
registry.registerOrUpdate({ name: 'carol' });

const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const writeSpy = vi.spyOn(fs, 'writeFileSync').mockImplementationOnce(() => {
throw new Error('disk full');
});

// Trigger a save
registry.recordSend('carol');

expect(errorSpy).toHaveBeenCalled();
writeSpy.mockRestore();
errorSpy.mockRestore();
});
});
178 changes: 178 additions & 0 deletions src/daemon/agent-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* Agent Registry
* Persists agent metadata across daemon restarts.
*/

import fs from 'node:fs';
import path from 'node:path';
import { v4 as uuid } from 'uuid';

export interface AgentRecord {
id: string;
name: string;
cli?: string;
workingDirectory?: string;
firstSeen: string;
lastSeen: string;
messagesSent: number;
messagesReceived: number;
}

type AgentInput = {
name: string;
cli?: string;
workingDirectory?: string;
};

export class AgentRegistry {
private registryPath: string;
private agents: Map<string, AgentRecord> = new Map(); // name -> record

constructor(teamDir: string) {
this.registryPath = path.join(teamDir, 'agents.json');
this.ensureDir(teamDir);
this.load();
}

/**
* Register or update an agent, refreshing lastSeen and metadata.
*/
registerOrUpdate(agent: AgentInput): AgentRecord {
const now = new Date().toISOString();
const existing = this.agents.get(agent.name);

if (existing) {
const updated: AgentRecord = {
...existing,
cli: agent.cli ?? existing.cli,
workingDirectory: agent.workingDirectory ?? existing.workingDirectory,
lastSeen: now,
};
this.agents.set(agent.name, updated);
this.save();
return updated;
}

const record: AgentRecord = {
id: `agent-${uuid()}`,
name: agent.name,
cli: agent.cli,
workingDirectory: agent.workingDirectory,
firstSeen: now,
lastSeen: now,
messagesSent: 0,
messagesReceived: 0,
};

this.agents.set(agent.name, record);
this.save();
return record;
}

/**
* Increment sent counter for an agent.
*/
recordSend(agentName: string): void {
const record = this.ensureRecord(agentName);
record.messagesSent += 1;
record.lastSeen = new Date().toISOString();
this.agents.set(agentName, record);
this.save();
}

/**
* Increment received counter for an agent.
*/
recordReceive(agentName: string): void {
const record = this.ensureRecord(agentName);
record.messagesReceived += 1;
record.lastSeen = new Date().toISOString();
this.agents.set(agentName, record);
this.save();
}

/**
* Touch lastSeen for an agent (e.g., on disconnect).
*/
touch(agentName: string): void {
const record = this.ensureRecord(agentName);
record.lastSeen = new Date().toISOString();
this.agents.set(agentName, record);
this.save();
}

/**
* Get a snapshot of all agents.
*/
getAgents(): AgentRecord[] {
return Array.from(this.agents.values());
}

private ensureRecord(agentName: string): AgentRecord {
const existing = this.agents.get(agentName);
if (existing) return existing;

const now = new Date().toISOString();
const record: AgentRecord = {
id: `agent-${uuid()}`,
name: agentName,
firstSeen: now,
lastSeen: now,
messagesSent: 0,
messagesReceived: 0,
};

this.agents.set(agentName, record);
return record;
}

private ensureDir(dir: string): void {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}

private load(): void {
if (!fs.existsSync(this.registryPath)) {
return;
}

try {
const data = JSON.parse(fs.readFileSync(this.registryPath, 'utf-8'));
const rawAgents = Array.isArray(data?.agents)
? data.agents
: typeof data?.agents === 'object' && data?.agents !== null
? Object.values(data.agents)
: [];

for (const raw of rawAgents) {
if (!raw?.name) continue;
const record: AgentRecord = {
id: raw.id ?? `agent-${uuid()}`,
name: raw.name,
cli: raw.cli,
workingDirectory: raw.workingDirectory,
firstSeen: raw.firstSeen ?? new Date().toISOString(),
lastSeen: raw.lastSeen ?? new Date().toISOString(),
messagesSent: typeof raw.messagesSent === 'number' ? raw.messagesSent : 0,
messagesReceived: typeof raw.messagesReceived === 'number' ? raw.messagesReceived : 0,
};
this.agents.set(record.name, record);
}
} catch (err) {
console.error('[registry] Failed to load agents.json:', err);
}
}

private save(): void {
try {
fs.writeFileSync(
this.registryPath,
JSON.stringify({ agents: this.getAgents() }, null, 2),
'utf-8'
);
} catch (err) {
console.error('[registry] Failed to write agents.json:', err);
}
}
}
6 changes: 6 additions & 0 deletions src/daemon/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export class Connection {
private _state: ConnectionState = 'CONNECTING';
private _agentName?: string;
private _cli?: string;
private _workingDirectory?: string;
private _sessionId: string;
private _resumeToken: string;

Expand Down Expand Up @@ -83,6 +84,10 @@ export class Connection {
return this._cli;
}

get workingDirectory(): string | undefined {
return this._workingDirectory;
}

get sessionId(): string {
return this._sessionId;
}
Expand Down Expand Up @@ -139,6 +144,7 @@ export class Connection {

this._agentName = envelope.payload.agent;
this._cli = envelope.payload.cli;
this._workingDirectory = envelope.payload.workingDirectory;

// Check for session resume
if (envelope.payload.session?.resume_token) {
Expand Down
1 change: 1 addition & 0 deletions src/daemon/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './server.js';
export * from './router.js';
export * from './connection.js';
export * from './agent-registry.js';
15 changes: 14 additions & 1 deletion src/daemon/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import {
PROTOCOL_VERSION,
} from '../protocol/types.js';
import type { StorageAdapter } from '../storage/adapter.js';
import type { AgentRegistry } from './agent-registry.js';

export interface RoutableConnection {
id: string;
agentName?: string;
cli?: string;
workingDirectory?: string;
sessionId: string;
close(): void;
send(envelope: Envelope): boolean;
Expand Down Expand Up @@ -53,10 +55,12 @@ export class Router {
private subscriptions: Map<string, Set<string>> = new Map(); // topic -> Set<agentName>
private pendingDeliveries: Map<string, PendingDelivery> = new Map(); // deliverId -> pending
private deliveryOptions: DeliveryReliabilityOptions;
private registry?: AgentRegistry;

constructor(options: { storage?: StorageAdapter; delivery?: Partial<DeliveryReliabilityOptions> } = {}) {
constructor(options: { storage?: StorageAdapter; delivery?: Partial<DeliveryReliabilityOptions>; registry?: AgentRegistry } = {}) {
this.storage = options.storage;
this.deliveryOptions = { ...DEFAULT_DELIVERY_OPTIONS, ...options.delivery };
this.registry = options.registry;
}

/**
Expand All @@ -73,6 +77,11 @@ export class Router {
this.connections.delete(existing.id);
}
this.agents.set(connection.agentName, connection);
this.registry?.registerOrUpdate({
name: connection.agentName,
cli: connection.cli,
workingDirectory: connection.workingDirectory,
});
}
}

Expand Down Expand Up @@ -131,6 +140,8 @@ export class Router {
return;
}

this.registry?.recordSend(senderName);

const to = envelope.to;
const topic = envelope.topic;

Expand Down Expand Up @@ -165,6 +176,7 @@ export class Router {
this.persistDeliverEnvelope(deliver);
if (sent) {
this.trackDelivery(target, deliver);
this.registry?.recordReceive(to);
}
return sent;
}
Expand All @@ -191,6 +203,7 @@ export class Router {
this.persistDeliverEnvelope(deliver);
if (sent) {
this.trackDelivery(target, deliver);
this.registry?.recordReceive(agentName);
}
}
}
Expand Down
Loading