refactor(server): switch agent registry to AgentStorage

This commit is contained in:
Mohamed Boudra
2026-01-24 17:46:11 +07:00
parent 9b82c02fd2
commit 21cff48d3f
20 changed files with 336 additions and 292 deletions
+2 -2
View File
@@ -75,13 +75,13 @@ DEEPGRAM_API_KEY=... # Streaming STT
STT_MODEL=whisper-1 # Optional: override to gpt-4o-transcribe, etc. STT_MODEL=whisper-1 # Optional: override to gpt-4o-transcribe, etc.
STT_CONFIDENCE_THRESHOLD=-3.0 # Optional: reject low-confidence clips STT_CONFIDENCE_THRESHOLD=-3.0 # Optional: reject low-confidence clips
STT_DEBUG_AUDIO_DIR=.stt-debug # Optional: persist raw dictation audio for debugging STT_DEBUG_AUDIO_DIR=.stt-debug # Optional: persist raw dictation audio for debugging
PASEO_HOME=~/.paseo # Runtime state directory (agents.json, etc.) PASEO_HOME=~/.paseo # Runtime state directory (agents/, etc.)
PASEO_PORT=6767 # Voice server + MCP port (falls back to 6767) PASEO_PORT=6767 # Voice server + MCP port (falls back to 6767)
PORT=3000 # Server port PORT=3000 # Server port
NODE_ENV=development # Environment NODE_ENV=development # Environment
``` ```
`PASEO_HOME` defaults to `~/.paseo` and isolates runtime artifacts like `agents.json`. `PASEO_PORT` takes precedence over `PORT` and controls both the HTTP server and MCP endpoint. For blue/green testing you can run a parallel server without touching production state: `PASEO_HOME` defaults to `~/.paseo` and isolates runtime artifacts like `agents/`. `PASEO_PORT` takes precedence over `PORT` and controls both the HTTP server and MCP endpoint. For blue/green testing you can run a parallel server without touching production state:
```bash ```bash
PASEO_HOME=~/.paseo-blue PASEO_PORT=7777 npm run dev PASEO_HOME=~/.paseo-blue PASEO_PORT=7777 npm run dev
@@ -6,7 +6,7 @@ import { randomUUID } from "node:crypto";
import { createTestLogger } from "../../test-utils/test-logger.js"; import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentManager } from "./agent-manager.js"; import { AgentManager } from "./agent-manager.js";
import { AgentRegistry } from "./agent-registry.js"; import { AgentStorage } from "./agent-storage.js";
import type { import type {
AgentClient, AgentClient,
AgentRunResult, AgentRunResult,
@@ -106,13 +106,13 @@ describe("AgentManager", () => {
test("normalizeConfig does not inject default model when omitted", async () => { test("normalizeConfig does not inject default model when omitted", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const registryPath = join(workdir, "agents.json"); const storagePath = join(workdir, "agents");
const registry = new AgentRegistry(registryPath, logger); const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({ const manager = new AgentManager({
clients: { clients: {
codex: new TestAgentClient(), codex: new TestAgentClient(),
}, },
registry, registry: storage,
logger, logger,
idFactory: () => "agent-without-model", idFactory: () => "agent-without-model",
}); });
@@ -127,13 +127,13 @@ describe("AgentManager", () => {
test("createAgent persists provided title before returning", async () => { test("createAgent persists provided title before returning", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const registryPath = join(workdir, "agents.json"); const storagePath = join(workdir, "agents");
const registry = new AgentRegistry(registryPath, logger); const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({ const manager = new AgentManager({
clients: { clients: {
codex: new TestAgentClient(), codex: new TestAgentClient(),
}, },
registry, registry: storage,
logger, logger,
idFactory: () => "agent-with-title", idFactory: () => "agent-with-title",
}); });
@@ -147,20 +147,20 @@ describe("AgentManager", () => {
expect(snapshot.id).toBe("agent-with-title"); expect(snapshot.id).toBe("agent-with-title");
expect(snapshot.lifecycle).toBe("idle"); expect(snapshot.lifecycle).toBe("idle");
const persisted = await registry.get("agent-with-title"); const persisted = await storage.get("agent-with-title");
expect(persisted?.title).toBe("Fix Login Bug"); expect(persisted?.title).toBe("Fix Login Bug");
expect(persisted?.id).toBe("agent-with-title"); expect(persisted?.id).toBe("agent-with-title");
}); });
test("createAgent populates runtimeInfo after session creation", async () => { test("createAgent populates runtimeInfo after session creation", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const registryPath = join(workdir, "agents.json"); const storagePath = join(workdir, "agents");
const registry = new AgentRegistry(registryPath, logger); const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({ const manager = new AgentManager({
clients: { clients: {
codex: new TestAgentClient(), codex: new TestAgentClient(),
}, },
registry, registry: storage,
logger, logger,
idFactory: () => "agent-with-runtime-info", idFactory: () => "agent-with-runtime-info",
}); });
@@ -179,13 +179,13 @@ describe("AgentManager", () => {
test("runAgent refreshes runtimeInfo after completion", async () => { test("runAgent refreshes runtimeInfo after completion", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const registryPath = join(workdir, "agents.json"); const storagePath = join(workdir, "agents");
const registry = new AgentRegistry(registryPath, logger); const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({ const manager = new AgentManager({
clients: { clients: {
codex: new TestAgentClient(), codex: new TestAgentClient(),
}, },
registry, registry: storage,
logger, logger,
idFactory: () => "agent-with-run-runtime", idFactory: () => "agent-with-run-runtime",
}); });
@@ -205,14 +205,14 @@ describe("AgentManager", () => {
test("listAgents excludes internal agents", async () => { test("listAgents excludes internal agents", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const registryPath = join(workdir, "agents.json"); const storagePath = join(workdir, "agents");
const registry = new AgentRegistry(registryPath, logger); const storage = new AgentStorage(storagePath, logger);
let agentCounter = 0; let agentCounter = 0;
const manager = new AgentManager({ const manager = new AgentManager({
clients: { clients: {
codex: new TestAgentClient(), codex: new TestAgentClient(),
}, },
registry, registry: storage,
logger, logger,
idFactory: () => `agent-${agentCounter++}`, idFactory: () => `agent-${agentCounter++}`,
}); });
@@ -239,13 +239,13 @@ describe("AgentManager", () => {
test("getAgent returns internal agents by ID", async () => { test("getAgent returns internal agents by ID", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const registryPath = join(workdir, "agents.json"); const storagePath = join(workdir, "agents");
const registry = new AgentRegistry(registryPath, logger); const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({ const manager = new AgentManager({
clients: { clients: {
codex: new TestAgentClient(), codex: new TestAgentClient(),
}, },
registry, registry: storage,
logger, logger,
idFactory: () => "internal-agent", idFactory: () => "internal-agent",
}); });
@@ -264,14 +264,14 @@ describe("AgentManager", () => {
test("subscribe does not emit state events for internal agents to global subscribers", async () => { test("subscribe does not emit state events for internal agents to global subscribers", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const registryPath = join(workdir, "agents.json"); const storagePath = join(workdir, "agents");
const registry = new AgentRegistry(registryPath, logger); const storage = new AgentStorage(storagePath, logger);
let agentCounter = 0; let agentCounter = 0;
const manager = new AgentManager({ const manager = new AgentManager({
clients: { clients: {
codex: new TestAgentClient(), codex: new TestAgentClient(),
}, },
registry, registry: storage,
logger, logger,
idFactory: () => `agent-${agentCounter++}`, idFactory: () => `agent-${agentCounter++}`,
}); });
@@ -305,13 +305,13 @@ describe("AgentManager", () => {
test("subscribe emits state events for internal agents when subscribed by agentId", async () => { test("subscribe emits state events for internal agents when subscribed by agentId", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const registryPath = join(workdir, "agents.json"); const storagePath = join(workdir, "agents");
const registry = new AgentRegistry(registryPath, logger); const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({ const manager = new AgentManager({
clients: { clients: {
codex: new TestAgentClient(), codex: new TestAgentClient(),
}, },
registry, registry: storage,
logger, logger,
idFactory: () => "internal-agent", idFactory: () => "internal-agent",
}); });
@@ -340,14 +340,14 @@ describe("AgentManager", () => {
test("onAgentAttention is not called for internal agents", async () => { test("onAgentAttention is not called for internal agents", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const registryPath = join(workdir, "agents.json"); const storagePath = join(workdir, "agents");
const registry = new AgentRegistry(registryPath, logger); const storage = new AgentStorage(storagePath, logger);
const attentionCalls: string[] = []; const attentionCalls: string[] = [];
const manager = new AgentManager({ const manager = new AgentManager({
clients: { clients: {
codex: new TestAgentClient(), codex: new TestAgentClient(),
}, },
registry, registry: storage,
logger, logger,
idFactory: () => "internal-agent", idFactory: () => "internal-agent",
onAgentAttention: ({ agentId }) => { onAgentAttention: ({ agentId }) => {
@@ -28,7 +28,7 @@ import type {
ListPersistedAgentsOptions, ListPersistedAgentsOptions,
PersistedAgentDescriptor, PersistedAgentDescriptor,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
import type { AgentRegistry } from "./agent-registry.js"; import type { AgentStorage } from "./agent-storage.js";
export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus }; export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus };
@@ -57,7 +57,7 @@ export type AgentManagerOptions = {
clients?: Partial<Record<AgentProvider, AgentClient>>; clients?: Partial<Record<AgentProvider, AgentClient>>;
maxTimelineItems?: number; maxTimelineItems?: number;
idFactory?: () => string; idFactory?: () => string;
registry?: AgentRegistry; registry?: AgentStorage;
agentControlMcp?: AgentControlMcpConfig; agentControlMcp?: AgentControlMcpConfig;
onAgentAttention?: AgentAttentionCallback; onAgentAttention?: AgentAttentionCallback;
logger: Logger; logger: Logger;
@@ -187,7 +187,7 @@ export class AgentManager {
private readonly subscribers = new Set<SubscriptionRecord>(); private readonly subscribers = new Set<SubscriptionRecord>();
private readonly maxTimelineItems: number; private readonly maxTimelineItems: number;
private readonly idFactory: () => string; private readonly idFactory: () => string;
private readonly registry?: AgentRegistry; private readonly registry?: AgentStorage;
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>(); private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
private readonly agentControlMcp?: AgentControlMcpConfig; private readonly agentControlMcp?: AgentControlMcpConfig;
private onAgentAttention?: AgentAttentionCallback; private onAgentAttention?: AgentAttentionCallback;
@@ -101,7 +101,7 @@ describe("agent MCP end-to-end", () => {
staticDir, staticDir,
mcpDebug: false, mcpDebug: false,
agentClients: {}, agentClients: {},
agentRegistryPath: path.join(paseoHome, "agents.json"), agentStoragePath: path.join(paseoHome, "agents"),
agentControlMcp: { agentControlMcp: {
url: `http://127.0.0.1:${port}/mcp/agents`, url: `http://127.0.0.1:${port}/mcp/agents`,
}, },
@@ -234,7 +234,7 @@ describe("agent MCP end-to-end", () => {
staticDir, staticDir,
mcpDebug: false, mcpDebug: false,
agentClients: {}, agentClients: {},
agentRegistryPath: path.join(paseoHome, "agents.json"), agentStoragePath: path.join(paseoHome, "agents"),
agentControlMcp: { agentControlMcp: {
url: `http://127.0.0.1:${port}/mcp/agents`, url: `http://127.0.0.1:${port}/mcp/agents`,
}, },
@@ -2,7 +2,7 @@ import type { AgentSnapshotPayload } from "../messages.js";
import type { import type {
SerializableAgentConfig, SerializableAgentConfig,
StoredAgentRecord, StoredAgentRecord,
} from "./agent-registry.js"; } from "./agent-storage.js";
import type { import type {
AgentCapabilityFlags, AgentCapabilityFlags,
AgentMetadata, AgentMetadata,
@@ -7,7 +7,7 @@ import { randomUUID } from "node:crypto";
import { createTestLogger } from "../../test-utils/test-logger.js"; import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentManager } from "./agent-manager.js"; import { AgentManager } from "./agent-manager.js";
import { AgentRegistry } from "./agent-registry.js"; import { AgentStorage } from "./agent-storage.js";
import { createAgentMcpServer } from "./mcp-server.js"; import { createAgentMcpServer } from "./mcp-server.js";
import { createWorktree } from "../../utils/worktree.js"; import { createWorktree } from "../../utils/worktree.js";
import type { import type {
@@ -132,11 +132,11 @@ describe("self-identification MCP tools", () => {
worktreeSlug: "self-ident", worktreeSlug: "self-ident",
}); });
const registryPath = path.join(repoDir, "agents.json"); const storagePath = path.join(repoDir, "agents");
const registry = new AgentRegistry(registryPath, logger); const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({ const manager = new AgentManager({
clients: { codex: new TestAgentClient() }, clients: { codex: new TestAgentClient() },
registry, registry: storage,
logger, logger,
idFactory: () => "agent-self-ident", idFactory: () => "agent-self-ident",
}); });
@@ -148,7 +148,7 @@ describe("self-identification MCP tools", () => {
const server = await createAgentMcpServer({ const server = await createAgentMcpServer({
agentManager: manager, agentManager: manager,
agentRegistry: registry, agentStorage: storage,
callerAgentId: agent.id, callerAgentId: agent.id,
logger, logger,
}); });
@@ -183,11 +183,11 @@ describe("self-identification MCP tools", () => {
const nestedDir = path.join(worktree.worktreePath, "nested"); const nestedDir = path.join(worktree.worktreePath, "nested");
execSync(`mkdir -p "${nestedDir}"`, { stdio: "ignore" }); execSync(`mkdir -p "${nestedDir}"`, { stdio: "ignore" });
const registryPath = path.join(repoDir, "agents.json"); const storagePath = path.join(repoDir, "agents");
const registry = new AgentRegistry(registryPath, logger); const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({ const manager = new AgentManager({
clients: { codex: new TestAgentClient() }, clients: { codex: new TestAgentClient() },
registry, registry: storage,
logger, logger,
idFactory: () => "agent-self-ident-subdir", idFactory: () => "agent-self-ident-subdir",
}); });
@@ -199,7 +199,7 @@ describe("self-identification MCP tools", () => {
const server = await createAgentMcpServer({ const server = await createAgentMcpServer({
agentManager: manager, agentManager: manager,
agentRegistry: registry, agentStorage: storage,
callerAgentId: agent.id, callerAgentId: agent.id,
logger, logger,
}); });
@@ -225,11 +225,11 @@ describe("self-identification MCP tools", () => {
try { try {
initGitRepo(repoDir); initGitRepo(repoDir);
const registryPath = path.join(repoDir, "agents.json"); const storagePath = path.join(repoDir, "agents");
const registry = new AgentRegistry(registryPath, logger); const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({ const manager = new AgentManager({
clients: { codex: new TestAgentClient() }, clients: { codex: new TestAgentClient() },
registry, registry: storage,
logger, logger,
idFactory: () => "agent-non-worktree", idFactory: () => "agent-non-worktree",
}); });
@@ -241,7 +241,7 @@ describe("self-identification MCP tools", () => {
const server = await createAgentMcpServer({ const server = await createAgentMcpServer({
agentManager: manager, agentManager: manager,
agentRegistry: registry, agentStorage: storage,
callerAgentId: agent.id, callerAgentId: agent.id,
logger, logger,
}); });
@@ -259,11 +259,11 @@ describe("self-identification MCP tools", () => {
const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-")); const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-"));
try { try {
const registryPath = path.join(repoDir, "agents.json"); const storagePath = path.join(repoDir, "agents");
const registry = new AgentRegistry(registryPath, logger); const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({ const manager = new AgentManager({
clients: { codex: new TestAgentClient() }, clients: { codex: new TestAgentClient() },
registry, registry: storage,
logger, logger,
idFactory: () => "agent-non-git", idFactory: () => "agent-non-git",
}); });
@@ -275,7 +275,7 @@ describe("self-identification MCP tools", () => {
const server = await createAgentMcpServer({ const server = await createAgentMcpServer({
agentManager: manager, agentManager: manager,
agentRegistry: registry, agentStorage: storage,
callerAgentId: agent.id, callerAgentId: agent.id,
logger, logger,
}); });
@@ -1,10 +1,10 @@
import { describe, expect, test, beforeEach, afterEach } from "vitest"; import { describe, expect, test, beforeEach, afterEach } from "vitest";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { createTestLogger } from "../../test-utils/test-logger.js"; import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentRegistry } from "./agent-registry.js"; import { AgentStorage } from "./agent-storage.js";
import type { ManagedAgent } from "./agent-manager.js"; import type { ManagedAgent } from "./agent-manager.js";
import type { import type {
AgentPermissionRequest, AgentPermissionRequest,
@@ -91,16 +91,16 @@ function createManagedAgent(
return agent; return agent;
} }
describe("AgentRegistry", () => { describe("AgentStorage", () => {
let tmpDir: string; let tmpDir: string;
let filePath: string; let storagePath: string;
let registry: AgentRegistry; let storage: AgentStorage;
const logger = createTestLogger(); const logger = createTestLogger();
beforeEach(() => { beforeEach(() => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "agent-registry-")); tmpDir = mkdtempSync(path.join(os.tmpdir(), "agent-registry-"));
filePath = path.join(tmpDir, "agents.json"); storagePath = path.join(tmpDir, "agents");
registry = new AgentRegistry(filePath, logger); storage = new AgentStorage(storagePath, logger);
}); });
afterEach(() => { afterEach(() => {
@@ -108,7 +108,7 @@ describe("AgentRegistry", () => {
}); });
test("applySnapshot persists configs and snapshot metadata", async () => { test("applySnapshot persists configs and snapshot metadata", async () => {
await registry.applySnapshot( await storage.applySnapshot(
createManagedAgent({ createManagedAgent({
id: "agent-1", id: "agent-1",
cwd: "/tmp/project", cwd: "/tmp/project",
@@ -122,7 +122,7 @@ describe("AgentRegistry", () => {
}) })
); );
const records = await registry.list(); const records = await storage.list();
expect(records).toHaveLength(1); expect(records).toHaveLength(1);
const [record] = records; const [record] = records;
expect(record.provider).toBe("claude"); expect(record.provider).toBe("claude");
@@ -131,7 +131,7 @@ describe("AgentRegistry", () => {
expect(record.lastModeId).toBe("coding"); expect(record.lastModeId).toBe("coding");
expect(record.lastStatus).toBe("idle"); expect(record.lastStatus).toBe("idle");
const reloaded = new AgentRegistry(filePath, logger); const reloaded = new AgentStorage(storagePath, logger);
const [persisted] = await reloaded.list(); const [persisted] = await reloaded.list();
expect(persisted.cwd).toBe("/tmp/project"); expect(persisted.cwd).toBe("/tmp/project");
expect(persisted.config?.extra?.claude).toMatchObject({ maxThinkingTokens: 1024 }); expect(persisted.config?.extra?.claude).toMatchObject({ maxThinkingTokens: 1024 });
@@ -140,14 +140,14 @@ describe("AgentRegistry", () => {
test("applySnapshot preserves original createdAt timestamp", async () => { test("applySnapshot preserves original createdAt timestamp", async () => {
const agentId = "agent-created-at"; const agentId = "agent-created-at";
const firstTimestamp = new Date("2025-01-01T00:00:00.000Z"); const firstTimestamp = new Date("2025-01-01T00:00:00.000Z");
await registry.applySnapshot( await storage.applySnapshot(
createManagedAgent({ id: agentId, createdAt: firstTimestamp }) createManagedAgent({ id: agentId, createdAt: firstTimestamp })
); );
const initialRecord = await registry.get(agentId); const initialRecord = await storage.get(agentId);
expect(initialRecord?.createdAt).toBe(firstTimestamp.toISOString()); expect(initialRecord?.createdAt).toBe(firstTimestamp.toISOString());
await registry.applySnapshot( await storage.applySnapshot(
createManagedAgent({ createManagedAgent({
id: agentId, id: agentId,
createdAt: new Date("2025-02-01T00:00:00.000Z"), createdAt: new Date("2025-02-01T00:00:00.000Z"),
@@ -156,57 +156,57 @@ describe("AgentRegistry", () => {
}) })
); );
const updatedRecord = await registry.get(agentId); const updatedRecord = await storage.get(agentId);
expect(updatedRecord?.createdAt).toBe(firstTimestamp.toISOString()); expect(updatedRecord?.createdAt).toBe(firstTimestamp.toISOString());
expect(updatedRecord?.lastStatus).toBe("running"); expect(updatedRecord?.lastStatus).toBe("running");
}); });
test("stores titles independently of snapshots", async () => { test("stores titles independently of snapshots", async () => {
await registry.applySnapshot( await storage.applySnapshot(
createManagedAgent({ createManagedAgent({
id: "agent-2", id: "agent-2",
provider: "codex", provider: "codex",
cwd: "/tmp/second", cwd: "/tmp/second",
}) })
); );
await registry.setTitle("agent-2", "Fix Login Bug"); await storage.setTitle("agent-2", "Fix Login Bug");
const current = await registry.get("agent-2"); const current = await storage.get("agent-2");
expect(current?.title).toBe("Fix Login Bug"); expect(current?.title).toBe("Fix Login Bug");
const reloaded = new AgentRegistry(filePath, logger); const reloaded = new AgentStorage(storagePath, logger);
const persisted = await reloaded.get("agent-2"); const persisted = await reloaded.get("agent-2");
expect(persisted?.title).toBe("Fix Login Bug"); expect(persisted?.title).toBe("Fix Login Bug");
}); });
test("setTitle throws when the agent record does not exist", async () => { test("setTitle throws when the agent record does not exist", async () => {
await expect(registry.setTitle("missing-agent", "Impossible")) await expect(storage.setTitle("missing-agent", "Impossible"))
.rejects.toThrow("Agent missing-agent not found"); .rejects.toThrow("Agent missing-agent not found");
}); });
test("applySnapshot accepts explicit title overrides", async () => { test("applySnapshot accepts explicit title overrides", async () => {
const agentId = "agent-override"; const agentId = "agent-override";
await registry.applySnapshot( await storage.applySnapshot(
createManagedAgent({ id: agentId }), createManagedAgent({ id: agentId }),
{ title: "Provided Title" } { title: "Provided Title" }
); );
const record = await registry.get(agentId); const record = await storage.get(agentId);
expect(record?.title).toBe("Provided Title"); expect(record?.title).toBe("Provided Title");
}); });
test("applySnapshot preserves custom titles while updating metadata", async () => { test("applySnapshot preserves custom titles while updating metadata", async () => {
const agentId = "agent-3"; const agentId = "agent-3";
await registry.applySnapshot( await storage.applySnapshot(
createManagedAgent({ createManagedAgent({
id: agentId, id: agentId,
lifecycle: "idle", lifecycle: "idle",
currentModeId: "plan", currentModeId: "plan",
}) })
); );
await registry.setTitle(agentId, "Important Bug Fix"); await storage.setTitle(agentId, "Important Bug Fix");
await registry.applySnapshot( await storage.applySnapshot(
createManagedAgent({ createManagedAgent({
id: agentId, id: agentId,
lifecycle: "running", lifecycle: "running",
@@ -215,44 +215,15 @@ describe("AgentRegistry", () => {
}) })
); );
const record = await registry.get(agentId); const record = await storage.get(agentId);
expect(record?.title).toBe("Important Bug Fix"); expect(record?.title).toBe("Important Bug Fix");
expect(record?.lastModeId).toBe("build"); expect(record?.lastModeId).toBe("build");
expect(record?.lastStatus).toBe("running"); expect(record?.lastStatus).toBe("running");
}); });
test("recovers from trailing garbage in agents.json", async () => {
const payload = [
{
id: "agent-4",
provider: "claude",
cwd: "/tmp/project",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
title: "Recovered agent",
lastStatus: "idle",
lastModeId: "plan",
config: null,
persistence: null,
},
];
writeFileSync(
filePath,
`${JSON.stringify(payload, null, 2)}\nGARBAGE-TRAILING`
);
const reloaded = new AgentRegistry(filePath, logger);
const result = await reloaded.list();
expect(result).toHaveLength(1);
expect(result[0]?.title).toBe("Recovered agent");
const sanitized = readFileSync(filePath, "utf8");
expect(sanitized.includes("GARBAGE-TRAILING")).toBe(false);
});
test("list returns all agents including internal ones", async () => { test("list returns all agents including internal ones", async () => {
// Create a normal agent // Create a normal agent
await registry.applySnapshot( await storage.applySnapshot(
createManagedAgent({ createManagedAgent({
id: "normal-agent", id: "normal-agent",
cwd: "/tmp/project", cwd: "/tmp/project",
@@ -260,7 +231,7 @@ describe("AgentRegistry", () => {
); );
// Create an internal agent // Create an internal agent
await registry.applySnapshot( await storage.applySnapshot(
createManagedAgent({ createManagedAgent({
id: "internal-agent", id: "internal-agent",
cwd: "/tmp/project", cwd: "/tmp/project",
@@ -270,12 +241,12 @@ describe("AgentRegistry", () => {
); );
// Registry should return all agents - filtering is done at the manager level // Registry should return all agents - filtering is done at the manager level
const records = await registry.list(); const records = await storage.list();
expect(records).toHaveLength(2); expect(records).toHaveLength(2);
}); });
test("get returns internal agents by ID", async () => { test("get returns internal agents by ID", async () => {
await registry.applySnapshot( await storage.applySnapshot(
createManagedAgent({ createManagedAgent({
id: "internal-agent", id: "internal-agent",
cwd: "/tmp/project", cwd: "/tmp/project",
@@ -284,13 +255,13 @@ describe("AgentRegistry", () => {
{ internal: true } { internal: true }
); );
const record = await registry.get("internal-agent"); const record = await storage.get("internal-agent");
expect(record).not.toBeNull(); expect(record).not.toBeNull();
expect(record?.internal).toBe(true); expect(record?.internal).toBe(true);
}); });
test("internal flag is persisted and reloaded", async () => { test("internal flag is persisted and reloaded", async () => {
await registry.applySnapshot( await storage.applySnapshot(
createManagedAgent({ createManagedAgent({
id: "internal-agent", id: "internal-agent",
cwd: "/tmp/project", cwd: "/tmp/project",
@@ -300,7 +271,7 @@ describe("AgentRegistry", () => {
); );
// Reload the registry from disk // Reload the registry from disk
const reloaded = new AgentRegistry(filePath, logger); const reloaded = new AgentStorage(storagePath, logger);
const record = await reloaded.get("internal-agent"); const record = await reloaded.get("internal-agent");
expect(record?.internal).toBe(true); expect(record?.internal).toBe(true);
@@ -64,19 +64,112 @@ export type SerializableAgentConfig = Pick<
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>; export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
export class AgentRegistry { export class AgentStorage {
private cache: Map<string, StoredAgentRecord> = new Map(); private cache: Map<string, StoredAgentRecord> = new Map();
private pathById: Map<string, string> = new Map();
private loaded = false; private loaded = false;
private filePath: string; private baseDir: string;
private loadPromise: Promise<StoredAgentRecord[]> | null = null; private loadPromise: Promise<StoredAgentRecord[]> | null = null;
private logger: Logger; private logger: Logger;
constructor(filePath: string, logger: Logger) { constructor(baseDir: string, logger: Logger) {
this.filePath = filePath; this.baseDir = baseDir;
this.logger = logger.child({ module: "agent", component: "agent-registry" }); this.logger = logger.child({ module: "agent", component: "agent-storage" });
} }
async load(): Promise<StoredAgentRecord[]> { async list(): Promise<StoredAgentRecord[]> {
await this.load();
return Array.from(this.cache.values());
}
async get(agentId: string): Promise<StoredAgentRecord | null> {
await this.load();
const cached = this.cache.get(agentId);
if (cached) {
return cached;
}
const discoveredPath = await this.findAgentPathById(agentId);
if (!discoveredPath) {
return null;
}
const record = await this.readRecordFile(discoveredPath);
if (!record) {
return null;
}
this.cache.set(record.id, record);
this.pathById.set(record.id, discoveredPath);
return record;
}
async upsert(record: StoredAgentRecord): Promise<void> {
await this.load();
const nextPath = this.buildRecordPath(record);
const previousPath = this.pathById.get(record.id);
await fs.mkdir(path.dirname(nextPath), { recursive: true });
await writeFileAtomically(nextPath, JSON.stringify(record, null, 2));
if (previousPath && previousPath !== nextPath) {
try {
await fs.unlink(previousPath);
} catch {
// ignore cleanup errors
}
}
this.cache.set(record.id, record);
this.pathById.set(record.id, nextPath);
}
async remove(agentId: string): Promise<void> {
await this.load();
const existingPath =
this.pathById.get(agentId) ?? (await this.findAgentPathById(agentId));
if (existingPath) {
try {
await fs.unlink(existingPath);
} catch {
// ignore removal errors
}
}
this.cache.delete(agentId);
this.pathById.delete(agentId);
}
async applySnapshot(
agent: ManagedAgent,
options?: { title?: string | null; internal?: boolean }
): Promise<void> {
await this.load();
const existing = (await this.get(agent.id)) ?? null;
const hasTitleOverride =
options !== undefined && Object.prototype.hasOwnProperty.call(options, "title");
const hasInternalOverride =
options !== undefined && Object.prototype.hasOwnProperty.call(options, "internal");
const record = toStoredAgentRecord(agent, {
title: hasTitleOverride ? options?.title ?? null : existing?.title ?? null,
createdAt: existing?.createdAt,
internal: hasInternalOverride
? options?.internal
: (agent.internal ?? existing?.internal),
});
await this.upsert(record);
}
async setTitle(agentId: string, title: string): Promise<void> {
await this.load();
const record = await this.get(agentId);
if (!record) {
throw new Error(`Agent ${agentId} not found`);
}
await this.upsert({ ...record, title });
}
private async load(): Promise<StoredAgentRecord[]> {
if (this.loaded) { if (this.loaded) {
return Array.from(this.cache.values()); return Array.from(this.cache.values());
} }
@@ -89,140 +182,120 @@ export class AgentRegistry {
} }
private async doLoad(): Promise<StoredAgentRecord[]> { private async doLoad(): Promise<StoredAgentRecord[]> {
this.cache.clear();
this.pathById.clear();
try { try {
const content = await fs.readFile(this.filePath, "utf8"); const records = await this.scanDisk();
const parsed = await this.parseContent(content);
this.loaded = true; this.loaded = true;
return parsed; return records;
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") { if ((error as NodeJS.ErrnoException).code === "ENOENT") {
this.loaded = true; this.loaded = true;
this.cache.clear();
return []; return [];
} }
this.logger.error({ err: error }, "Failed to load agents"); this.logger.error({ err: error }, "Failed to load agents");
this.loaded = true; this.loaded = true;
this.cache.clear();
return []; return [];
} }
} }
async list(): Promise<StoredAgentRecord[]> { private async scanDisk(): Promise<StoredAgentRecord[]> {
return this.load(); const records: StoredAgentRecord[] = [];
} let entries: Array<import("node:fs").Dirent> = [];
async get(agentId: string): Promise<StoredAgentRecord | null> {
await this.load();
return this.cache.get(agentId) ?? null;
}
async upsert(record: StoredAgentRecord): Promise<void> {
await this.load();
this.cache.set(record.id, record);
await this.flush();
}
async remove(agentId: string): Promise<void> {
await this.load();
this.cache.delete(agentId);
await this.flush();
}
async applySnapshot(
agent: ManagedAgent,
options?: { title?: string | null; internal?: boolean }
): Promise<void> {
await this.load();
const existing = this.cache.get(agent.id);
const hasTitleOverride =
options !== undefined && Object.prototype.hasOwnProperty.call(options, "title");
const hasInternalOverride =
options !== undefined && Object.prototype.hasOwnProperty.call(options, "internal");
const record = toStoredAgentRecord(agent, {
title: hasTitleOverride ? options?.title ?? null : existing?.title ?? null,
createdAt: existing?.createdAt,
internal: hasInternalOverride ? options?.internal : (agent.internal ?? existing?.internal),
});
this.cache.set(agent.id, record);
await this.flush();
}
async setTitle(agentId: string, title: string): Promise<void> {
await this.load();
const record = this.cache.get(agentId);
if (!record) {
throw new Error(`Agent ${agentId} not found`);
}
this.cache.set(agentId, { ...record, title });
await this.flush();
}
private async flush(): Promise<void> {
const payload = JSON.stringify(Array.from(this.cache.values()), null, 2);
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
await writeFileAtomically(this.filePath, payload);
}
private async parseContent(content: string): Promise<StoredAgentRecord[]> {
try { try {
return this.parseRecords(content); entries = await fs.readdir(this.baseDir, { withFileTypes: true });
} catch (error) { } catch (error) {
if (error instanceof SyntaxError) { if ((error as NodeJS.ErrnoException).code === "ENOENT") {
const recovered = await this.tryRecoverCorruptedContent(content); return [];
if (recovered) {
return recovered;
}
} }
throw error; throw error;
} }
}
private parseRecords(content: string): StoredAgentRecord[] { for (const entry of entries) {
const parsed = JSON.parse(content); if (!entry.isDirectory()) {
if (!Array.isArray(parsed)) { continue;
throw new Error("Invalid agents.json format"); }
} const projectDir = path.join(this.baseDir, entry.name);
const records: StoredAgentRecord[] = []; let files: Array<import("node:fs").Dirent> = [];
for (const entry of parsed) {
try { try {
const record = STORED_AGENT_SCHEMA.parse(entry); files = await fs.readdir(projectDir, { withFileTypes: true });
} catch {
continue;
}
for (const file of files) {
if (!file.isFile() || !file.name.endsWith(".json")) {
continue;
}
const filePath = path.join(projectDir, file.name);
const record = await this.readRecordFile(filePath);
if (!record) {
continue;
}
records.push(record); records.push(record);
this.cache.set(record.id, record); this.cache.set(record.id, record);
} catch (error) { this.pathById.set(record.id, filePath);
this.logger.error({ err: error }, "Skipping invalid record");
} }
} }
return records; return records;
} }
private async tryRecoverCorruptedContent( private async readRecordFile(filePath: string): Promise<StoredAgentRecord | null> {
content: string
): Promise<StoredAgentRecord[] | null> {
const start = content.indexOf("[");
const end = content.lastIndexOf("]");
if (start === -1 || end === -1 || end <= start) {
return null;
}
const candidate = content.slice(start, end + 1);
try { try {
this.cache.clear(); const content = await fs.readFile(filePath, "utf8");
const records = this.parseRecords(candidate); const parsed = JSON.parse(content);
this.logger.warn("Recovered corrupted agents.json payload; rewrote sanitized copy"); return STORED_AGENT_SCHEMA.parse(parsed);
const sanitizedPayload = JSON.stringify(records, null, 2);
await writeFileAtomically(this.filePath, sanitizedPayload);
return records;
} catch (error) { } catch (error) {
this.cache.clear(); this.logger.error({ err: error, filePath }, "Skipping invalid agent record");
return null; return null;
} }
} }
private buildRecordPath(record: StoredAgentRecord): string {
const projectDir = projectDirNameFromCwd(record.cwd);
return path.join(this.baseDir, projectDir, `${record.id}.json`);
}
private async findAgentPathById(agentId: string): Promise<string | null> {
let projects: Array<import("node:fs").Dirent> = [];
try {
projects = await fs.readdir(this.baseDir, { withFileTypes: true });
} catch {
return null;
}
for (const project of projects) {
if (!project.isDirectory()) {
continue;
}
const candidate = path.join(this.baseDir, project.name, `${agentId}.json`);
try {
await fs.access(candidate);
return candidate;
} catch {
// not here
}
}
return null;
}
}
function projectDirNameFromCwd(cwd: string): string {
const trimmed = cwd.replace(/^[\\/]+/, "").replace(/[\\/]+$/, "");
if (!trimmed) {
return "root";
}
return trimmed.replace(/[\\/]+/g, "-");
} }
async function writeFileAtomically(targetPath: string, payload: string) { async function writeFileAtomically(targetPath: string, payload: string) {
const directory = path.dirname(targetPath); const directory = path.dirname(targetPath);
const tempPath = path.join( const tempPath = path.join(
directory, directory,
`.agents.json.tmp-${process.pid}-${Date.now()}-${randomUUID()}` `.agent.tmp-${process.pid}-${Date.now()}-${randomUUID()}`
); );
await fs.writeFile(tempPath, payload, "utf8"); await fs.writeFile(tempPath, payload, "utf8");
await fs.rename(tempPath, targetPath); await fs.rename(tempPath, targetPath);
@@ -3,14 +3,14 @@ import { describe, expect, it, vi } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js"; import { createTestLogger } from "../../test-utils/test-logger.js";
import { createAgentMcpServer } from "./mcp-server.js"; import { createAgentMcpServer } from "./mcp-server.js";
import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentRegistry } from "./agent-registry.js"; import type { AgentStorage } from "./agent-storage.js";
type TestDeps = { type TestDeps = {
agentManager: AgentManager; agentManager: AgentManager;
agentRegistry: AgentRegistry; agentStorage: AgentStorage;
spies: { spies: {
agentManager: Record<string, any>; agentManager: Record<string, any>;
agentRegistry: Record<string, any>; agentStorage: Record<string, any>;
}; };
}; };
@@ -28,7 +28,7 @@ function createTestDeps(): TestDeps {
getPendingPermissions: vi.fn(), getPendingPermissions: vi.fn(),
}; };
const agentRegistrySpies = { const agentStorageSpies = {
get: vi.fn().mockResolvedValue(null), get: vi.fn().mockResolvedValue(null),
setTitle: vi.fn().mockResolvedValue(undefined), setTitle: vi.fn().mockResolvedValue(undefined),
applySnapshot: vi.fn(), applySnapshot: vi.fn(),
@@ -38,10 +38,10 @@ function createTestDeps(): TestDeps {
return { return {
agentManager: agentManagerSpies as unknown as AgentManager, agentManager: agentManagerSpies as unknown as AgentManager,
agentRegistry: agentRegistrySpies as unknown as AgentRegistry, agentStorage: agentStorageSpies as unknown as AgentStorage,
spies: { spies: {
agentManager: agentManagerSpies, agentManager: agentManagerSpies,
agentRegistry: agentRegistrySpies, agentStorage: agentStorageSpies,
}, },
}; };
} }
@@ -50,8 +50,8 @@ describe("create_agent MCP tool", () => {
const logger = createTestLogger(); const logger = createTestLogger();
it("requires a concise title no longer than 60 characters", async () => { it("requires a concise title no longer than 60 characters", async () => {
const { agentManager, agentRegistry } = createTestDeps(); const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({ agentManager, agentRegistry, logger }); const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = (server as any)._registeredTools["create_agent"]; const tool = (server as any)._registeredTools["create_agent"];
expect(tool).toBeDefined(); expect(tool).toBeDefined();
@@ -79,7 +79,7 @@ describe("create_agent MCP tool", () => {
}); });
it("passes caller-provided titles directly into createAgent", async () => { it("passes caller-provided titles directly into createAgent", async () => {
const { agentManager, agentRegistry, spies } = createTestDeps(); const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.createAgent.mockResolvedValue({ spies.agentManager.createAgent.mockResolvedValue({
id: "agent-123", id: "agent-123",
cwd: "/tmp/repo", cwd: "/tmp/repo",
@@ -88,7 +88,7 @@ describe("create_agent MCP tool", () => {
availableModes: [], availableModes: [],
} as ManagedAgent); } as ManagedAgent);
const server = await createAgentMcpServer({ agentManager, agentRegistry, logger }); const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = (server as any)._registeredTools["create_agent"]; const tool = (server as any)._registeredTools["create_agent"];
await tool.callback({ await tool.callback({
cwd: "/tmp/repo", cwd: "/tmp/repo",
@@ -104,14 +104,14 @@ describe("create_agent MCP tool", () => {
}); });
it("set_title trims and persists titles for caller agent", async () => { it("set_title trims and persists titles for caller agent", async () => {
const { agentManager, agentRegistry, spies } = createTestDeps(); const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({ spies.agentManager.getAgent.mockReturnValue({
id: "agent-1", id: "agent-1",
} as ManagedAgent); } as ManagedAgent);
const server = await createAgentMcpServer({ const server = await createAgentMcpServer({
agentManager, agentManager,
agentRegistry, agentStorage,
logger, logger,
callerAgentId: "agent-1", callerAgentId: "agent-1",
}); });
@@ -24,7 +24,7 @@ import {
import { toAgentPayload } from "./agent-projections.js"; import { toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js"; import { curateAgentActivity } from "./activity-curator.js";
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js"; import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import { AgentRegistry } from "./agent-registry.js"; import { AgentStorage } from "./agent-storage.js";
import { import {
createWorktree, createWorktree,
isPaseoOwnedWorktreeCwd, isPaseoOwnedWorktreeCwd,
@@ -36,7 +36,7 @@ import { injectLeadingPaseoInstructionTag } from "./paseo-instructions-tag.js";
export interface AgentMcpServerOptions { export interface AgentMcpServerOptions {
agentManager: AgentManager; agentManager: AgentManager;
agentRegistry: AgentRegistry; agentStorage: AgentStorage;
/** /**
* ID of the agent that is connecting to this MCP server. * ID of the agent that is connecting to this MCP server.
* When set, create_agent will auto-inject this as parentAgentId. * When set, create_agent will auto-inject this as parentAgentId.
@@ -240,12 +240,12 @@ function sanitizePermissionRequest(
} }
async function resolveAgentTitle( async function resolveAgentTitle(
agentRegistry: AgentRegistry, agentStorage: AgentStorage,
agentId: string, agentId: string,
logger: Logger logger: Logger
): Promise<string | null> { ): Promise<string | null> {
try { try {
const record = await agentRegistry.get(agentId); const record = await agentStorage.get(agentId);
return record?.title ?? null; return record?.title ?? null;
} catch (error) { } catch (error) {
logger.error( logger.error(
@@ -257,18 +257,18 @@ async function resolveAgentTitle(
} }
async function serializeSnapshotWithMetadata( async function serializeSnapshotWithMetadata(
agentRegistry: AgentRegistry, agentStorage: AgentStorage,
snapshot: ManagedAgent, snapshot: ManagedAgent,
logger: Logger logger: Logger
) { ) {
const title = await resolveAgentTitle(agentRegistry, snapshot.id, logger); const title = await resolveAgentTitle(agentStorage, snapshot.id, logger);
return serializeAgentSnapshot(snapshot, { title }); return serializeAgentSnapshot(snapshot, { title });
} }
export async function createAgentMcpServer( export async function createAgentMcpServer(
options: AgentMcpServerOptions options: AgentMcpServerOptions
): Promise<McpServer> { ): Promise<McpServer> {
const { agentManager, agentRegistry, callerAgentId, logger } = options; const { agentManager, agentStorage, callerAgentId, logger } = options;
const childLogger = logger.child({ module: "agent", component: "mcp-server" }); const childLogger = logger.child({ module: "agent", component: "mcp-server" });
const waitTracker = new WaitForAgentTracker(logger); const waitTracker = new WaitForAgentTracker(logger);
@@ -776,7 +776,7 @@ export async function createAgentMcpServer(
} }
const structuredSnapshot = await serializeSnapshotWithMetadata( const structuredSnapshot = await serializeSnapshotWithMetadata(
agentRegistry, agentStorage,
snapshot, snapshot,
childLogger childLogger
); );
@@ -804,7 +804,7 @@ export async function createAgentMcpServer(
const snapshots = agentManager.listAgents(); const snapshots = agentManager.listAgents();
const agents = await Promise.all( const agents = await Promise.all(
snapshots.map((snapshot) => snapshots.map((snapshot) =>
serializeSnapshotWithMetadata(agentRegistry, snapshot, childLogger) serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger)
) )
); );
return { return {
@@ -33,7 +33,7 @@ import type {
AgentTimelineItem, AgentTimelineItem,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { AgentManager } from "../agent-manager.js"; import { AgentManager } from "../agent-manager.js";
import { AgentRegistry } from "../agent-registry.js"; import { AgentStorage } from "../agent-storage.js";
import { createAgentMcpServer } from "../mcp-server.js"; import { createAgentMcpServer } from "../mcp-server.js";
const createHTTPServer = createServer; const createHTTPServer = createServer;
@@ -116,11 +116,11 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const httpServer = createHTTPServer(app); const httpServer = createHTTPServer(app);
const registryDir = mkdtempSync(path.join(os.tmpdir(), "agent-mcp-registry-")); const registryDir = mkdtempSync(path.join(os.tmpdir(), "agent-mcp-registry-"));
const registryPath = path.join(registryDir, "agents.json"); const storagePath = path.join(registryDir, "agents");
const agentRegistry = new AgentRegistry(registryPath, testLogger); const agentStorage = new AgentStorage(storagePath, testLogger);
const agentManager = new AgentManager({ const agentManager = new AgentManager({
clients: {}, clients: {},
registry: agentRegistry, registry: agentStorage,
logger: testLogger, logger: testLogger,
}); });
@@ -130,7 +130,7 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
const createAgentMcpTransport = async (callerAgentId?: string) => { const createAgentMcpTransport = async (callerAgentId?: string) => {
const agentMcpServer = await createAgentMcpServer({ const agentMcpServer = await createAgentMcpServer({
agentManager, agentManager,
agentRegistry, agentStorage,
callerAgentId, callerAgentId,
logger: testLogger, logger: testLogger,
}); });
+12 -12
View File
@@ -42,8 +42,8 @@ import { DownloadTokenStore } from "./file-download/token-store.js";
import { OpenAISTT, type STTConfig } from "./agent/stt-openai.js"; import { OpenAISTT, type STTConfig } from "./agent/stt-openai.js";
import { OpenAITTS, type TTSConfig } from "./agent/tts-openai.js"; import { OpenAITTS, type TTSConfig } from "./agent/tts-openai.js";
import { AgentManager } from "./agent/agent-manager.js"; import { AgentManager } from "./agent/agent-manager.js";
import { AgentRegistry } from "./agent/agent-registry.js"; import { AgentStorage } from "./agent/agent-storage.js";
import { attachAgentRegistryPersistence } from "./persistence-hooks.js"; import { attachAgentStoragePersistence } from "./persistence-hooks.js";
import { createAgentMcpServer } from "./agent/mcp-server.js"; import { createAgentMcpServer } from "./agent/mcp-server.js";
import { createAllClients, shutdownProviders } from "./agent/provider-registry.js"; import { createAllClients, shutdownProviders } from "./agent/provider-registry.js";
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js"; import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
@@ -77,7 +77,7 @@ export type PaseoDaemonConfig = {
staticDir: string; staticDir: string;
mcpDebug: boolean; mcpDebug: boolean;
agentClients: Partial<Record<AgentProvider, AgentClient>>; agentClients: Partial<Record<AgentProvider, AgentClient>>;
agentRegistryPath: string; agentStoragePath: string;
agentControlMcp: AgentControlMcpConfig; agentControlMcp: AgentControlMcpConfig;
relayEnabled?: boolean; relayEnabled?: boolean;
relayEndpoint?: string; relayEndpoint?: string;
@@ -89,7 +89,7 @@ export type PaseoDaemonConfig = {
export interface PaseoDaemon { export interface PaseoDaemon {
config: PaseoDaemonConfig; config: PaseoDaemonConfig;
agentManager: AgentManager; agentManager: AgentManager;
agentRegistry: AgentRegistry; agentStorage: AgentStorage;
terminalManager: TerminalManager; terminalManager: TerminalManager;
start(): Promise<void>; start(): Promise<void>;
stop(): Promise<void>; stop(): Promise<void>;
@@ -98,7 +98,7 @@ export interface PaseoDaemon {
export async function createPaseoDaemon( export async function createPaseoDaemon(
config: PaseoDaemonConfig, config: PaseoDaemonConfig,
rootLogger: Logger rootLogger: Logger
): Promise<PaseoDaemon> { ): Promise<PaseoDaemon> {
const logger = rootLogger.child({ module: "bootstrap" }); const logger = rootLogger.child({ module: "bootstrap" });
const connectionSessionId = randomUUID(); const connectionSessionId = randomUUID();
let relayTransport: RelayTransportController | null = null; let relayTransport: RelayTransportController | null = null;
@@ -204,21 +204,21 @@ export async function createPaseoDaemon(
const httpServer = createHTTPServer(app); const httpServer = createHTTPServer(app);
const agentRegistry = new AgentRegistry(config.agentRegistryPath, logger); const agentStorage = new AgentStorage(config.agentStoragePath, logger);
const agentManager = new AgentManager({ const agentManager = new AgentManager({
clients: { clients: {
...createAllClients(logger), ...createAllClients(logger),
...config.agentClients, ...config.agentClients,
}, },
registry: agentRegistry, registry: agentStorage,
agentControlMcp: config.agentControlMcp, agentControlMcp: config.agentControlMcp,
logger, logger,
}); });
const terminalManager = createTerminalManager(); const terminalManager = createTerminalManager();
attachAgentRegistryPersistence(logger, agentManager, agentRegistry); attachAgentStoragePersistence(logger, agentManager, agentStorage);
const persistedRecords = await agentRegistry.list(); const persistedRecords = await agentStorage.list();
logger.info( logger.info(
`Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand` `Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand`
); );
@@ -229,7 +229,7 @@ export async function createPaseoDaemon(
const createAgentMcpTransport = async (callerAgentId?: string) => { const createAgentMcpTransport = async (callerAgentId?: string) => {
const agentMcpServer = await createAgentMcpServer({ const agentMcpServer = await createAgentMcpServer({
agentManager, agentManager,
agentRegistry, agentStorage,
callerAgentId, callerAgentId,
logger, logger,
}); });
@@ -374,7 +374,7 @@ export async function createPaseoDaemon(
httpServer, httpServer,
logger, logger,
agentManager, agentManager,
agentRegistry, agentStorage,
downloadTokenStore, downloadTokenStore,
config.paseoHome, config.paseoHome,
{ {
@@ -470,7 +470,7 @@ export async function createPaseoDaemon(
return { return {
config, config,
agentManager, agentManager,
agentRegistry, agentStorage,
terminalManager, terminalManager,
start, start,
stop, stop,
+1 -1
View File
@@ -83,7 +83,7 @@ export function loadConfig(
agentControlMcp: { agentControlMcp: {
url: `http://${mcpListen}${DEFAULT_AGENT_MCP_ROUTE}`, url: `http://${mcpListen}${DEFAULT_AGENT_MCP_ROUTE}`,
}, },
agentRegistryPath: path.join(paseoHome, "agents.json"), agentStoragePath: path.join(paseoHome, "agents"),
staticDir: "public", staticDir: "public",
agentClients: {}, agentClients: {},
relayEnabled: true, relayEnabled: true,
@@ -56,7 +56,7 @@ async function startDaemon(options: {
staticDir, staticDir,
mcpDebug: false, mcpDebug: false,
agentClients: {}, agentClients: {},
agentRegistryPath: path.join(options.paseoHome, "agents.json"), agentStoragePath: path.join(options.paseoHome, "agents"),
agentControlMcp: { agentControlMcp: {
url: `http://127.0.0.1:${port}/mcp/agents`, url: `http://127.0.0.1:${port}/mcp/agents`,
}, },
@@ -184,9 +184,9 @@ describe("daemon restart and agent resume", () => {
await stopDaemon(currentDaemon); await stopDaemon(currentDaemon);
currentDaemon = null; currentDaemon = null;
// Verify agents.json was persisted // Verify agent storage was persisted
const agentsJsonPath = path.join(paseoHome, "agents.json"); const agentsDir = path.join(paseoHome, "agents");
expect(existsSync(agentsJsonPath)).toBe(true); expect(existsSync(agentsDir)).toBe(true);
// === PHASE 3: Start a NEW daemon with the SAME paseoHome === // === PHASE 3: Start a NEW daemon with the SAME paseoHome ===
currentDaemon = await startDaemon({ paseoHome, staticDir }); currentDaemon = await startDaemon({ paseoHome, staticDir });
@@ -1,9 +1,9 @@
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import type { ManagedAgent } from "./agent/agent-manager.js"; import type { ManagedAgent } from "./agent/agent-manager.js";
import type { StoredAgentRecord } from "./agent/agent-registry.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js";
import { import {
attachAgentRegistryPersistence, attachAgentStoragePersistence,
} from "./persistence-hooks.js"; } from "./persistence-hooks.js";
import type { import type {
AgentPermissionRequest, AgentPermissionRequest,
@@ -108,7 +108,7 @@ function createRecord(
} }
describe("persistence hooks", () => { describe("persistence hooks", () => {
test("attachAgentRegistryPersistence forwards agent snapshots", async () => { test("attachAgentStoragePersistence forwards agent snapshots", async () => {
const applySnapshot = vi.fn().mockResolvedValue(undefined); const applySnapshot = vi.fn().mockResolvedValue(undefined);
let subscriber: (event: any) => void = () => { let subscriber: (event: any) => void = () => {
throw new Error("Agent manager subscriber was not registered"); throw new Error("Agent manager subscriber was not registered");
@@ -123,7 +123,7 @@ describe("persistence hooks", () => {
}; };
}), }),
}; };
attachAgentRegistryPersistence(testLogger, agentManager as any, { attachAgentStoragePersistence(testLogger, agentManager as any, {
applySnapshot, applySnapshot,
list: vi.fn(), list: vi.fn(),
} as any); } as any);
@@ -4,9 +4,9 @@ import type {
AgentSessionConfig, AgentSessionConfig,
} from "./agent/agent-sdk-types.js"; } from "./agent/agent-sdk-types.js";
import type { import type {
AgentRegistry, AgentStorage,
StoredAgentRecord, StoredAgentRecord,
} from "./agent/agent-registry.js"; } from "./agent/agent-storage.js";
type LoggerLike = { type LoggerLike = {
child(bindings: Record<string, unknown>): LoggerLike; child(bindings: Record<string, unknown>): LoggerLike;
@@ -17,7 +17,7 @@ function getLogger(logger: LoggerLike): LoggerLike {
return logger.child({ module: "persistence" }); return logger.child({ module: "persistence" });
} }
type AgentRegistryPersistence = Pick<AgentRegistry, "applySnapshot" | "list">; type AgentStoragePersistence = Pick<AgentStorage, "applySnapshot" | "list">;
type AgentManagerStateSource = Pick<AgentManager, "subscribe">; type AgentManagerStateSource = Pick<AgentManager, "subscribe">;
function isKnownProvider(provider: string): provider is AgentProvider { function isKnownProvider(provider: string): provider is AgentProvider {
@@ -25,20 +25,20 @@ function isKnownProvider(provider: string): provider is AgentProvider {
} }
/** /**
* Attach AgentRegistry persistence to an AgentManager instance so every * Attach AgentStorage persistence to an AgentManager instance so every
* agent_state snapshot is flushed to disk. * agent_state snapshot is flushed to disk.
*/ */
export function attachAgentRegistryPersistence( export function attachAgentStoragePersistence(
logger: LoggerLike, logger: LoggerLike,
agentManager: AgentManagerStateSource, agentManager: AgentManagerStateSource,
registry: AgentRegistryPersistence storage: AgentStoragePersistence
): () => void { ): () => void {
const log = getLogger(logger); const log = getLogger(logger);
const unsubscribe = agentManager.subscribe((event) => { const unsubscribe = agentManager.subscribe((event) => {
if (event.type !== "agent_state") { if (event.type !== "agent_state") {
return; return;
} }
void registry.applySnapshot(event.agent).catch((error) => { void storage.applySnapshot(event.agent).catch((error) => {
log.error({ err: error, agentId: event.agent.id }, "Failed to persist agent snapshot"); log.error({ err: error, agentId: event.agent.id }, "Failed to persist agent snapshot");
}); });
}); });
+12 -12
View File
@@ -63,7 +63,7 @@ import type {
AgentPersistenceHandle, AgentPersistenceHandle,
AgentTimelineItem, AgentTimelineItem,
} from "./agent/agent-sdk-types.js"; } from "./agent/agent-sdk-types.js";
import { AgentRegistry, type StoredAgentRecord } from "./agent/agent-registry.js"; import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js"; import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
import { import {
listDirectoryEntries, listDirectoryEntries,
@@ -307,7 +307,7 @@ export class Session {
> | null = null; > | null = null;
private agentTools: ToolSet | null = null; private agentTools: ToolSet | null = null;
private agentManager: AgentManager; private agentManager: AgentManager;
private readonly agentRegistry: AgentRegistry; private readonly agentStorage: AgentStorage;
private readonly agentMcpConfig: AgentMcpClientConfig; private readonly agentMcpConfig: AgentMcpClientConfig;
private readonly downloadTokenStore: DownloadTokenStore; private readonly downloadTokenStore: DownloadTokenStore;
private readonly pushTokenStore: PushTokenStore; private readonly pushTokenStore: PushTokenStore;
@@ -329,7 +329,7 @@ export class Session {
downloadTokenStore: DownloadTokenStore, downloadTokenStore: DownloadTokenStore,
pushTokenStore: PushTokenStore, pushTokenStore: PushTokenStore,
agentManager: AgentManager, agentManager: AgentManager,
agentRegistry: AgentRegistry, agentStorage: AgentStorage,
agentMcpConfig: AgentMcpClientConfig, agentMcpConfig: AgentMcpClientConfig,
stt: OpenAISTT | null, stt: OpenAISTT | null,
tts: OpenAITTS | null, tts: OpenAITTS | null,
@@ -342,7 +342,7 @@ export class Session {
this.downloadTokenStore = downloadTokenStore; this.downloadTokenStore = downloadTokenStore;
this.pushTokenStore = pushTokenStore; this.pushTokenStore = pushTokenStore;
this.agentManager = agentManager; this.agentManager = agentManager;
this.agentRegistry = agentRegistry; this.agentStorage = agentStorage;
this.agentMcpConfig = agentMcpConfig; this.agentMcpConfig = agentMcpConfig;
this.terminalManager = terminalManager; this.terminalManager = terminalManager;
this.voiceConversationStore = voiceConversationStore; this.voiceConversationStore = voiceConversationStore;
@@ -653,7 +653,7 @@ export class Session {
} }
const initPromise = (async () => { const initPromise = (async () => {
const record = await this.agentRegistry.get(agentId); const record = await this.agentStorage.get(agentId);
if (!record) { if (!record) {
throw new Error(`Agent not found: ${agentId}`); throw new Error(`Agent not found: ${agentId}`);
} }
@@ -704,7 +704,7 @@ export class Session {
private async getStoredAgentTitle(agentId: string): Promise<string | null> { private async getStoredAgentTitle(agentId: string): Promise<string | null> {
try { try {
const record = await this.agentRegistry.get(agentId); const record = await this.agentStorage.get(agentId);
const title = record?.title ?? null; const title = record?.title ?? null;
return title; return title;
} catch (error) { } catch (error) {
@@ -1105,7 +1105,7 @@ export class Session {
} }
try { try {
await this.agentRegistry.remove(agentId); await this.agentStorage.remove(agentId);
} catch (error: any) { } catch (error: any) {
this.sessionLogger.error( this.sessionLogger.error(
{ err: error, agentId }, { err: error, agentId },
@@ -1748,7 +1748,7 @@ export class Session {
snapshot = existing; snapshot = existing;
} }
} else { } else {
const record = await this.agentRegistry.get(agentId); const record = await this.agentStorage.get(agentId);
if (!record) { if (!record) {
throw new Error(`Agent not found: ${agentId}`); throw new Error(`Agent not found: ${agentId}`);
} }
@@ -3133,19 +3133,19 @@ export class Session {
// ignore cleanup errors // ignore cleanup errors
} }
try { try {
await this.agentRegistry.remove(agent.id); await this.agentStorage.remove(agent.id);
} catch { } catch {
// ignore cleanup errors // ignore cleanup errors
} }
} }
} }
const registryRecords = await this.agentRegistry.list(); const registryRecords = await this.agentStorage.list();
for (const record of registryRecords) { for (const record of registryRecords) {
if (this.isPathWithinRoot(targetPath, record.cwd)) { if (this.isPathWithinRoot(targetPath, record.cwd)) {
removedAgents.add(record.id); removedAgents.add(record.id);
try { try {
await this.agentRegistry.remove(record.id); await this.agentStorage.remove(record.id);
} catch { } catch {
// ignore cleanup errors // ignore cleanup errors
} }
@@ -3604,7 +3604,7 @@ export class Session {
); );
// Add persisted agents that have not been lazily initialized yet // Add persisted agents that have not been lazily initialized yet
const registryRecords = await this.agentRegistry.list(); const registryRecords = await this.agentStorage.list();
const liveIds = new Set(agentSnapshots.map((a) => a.id)); const liveIds = new Set(agentSnapshots.map((a) => a.id));
const persistedAgents = registryRecords const persistedAgents = registryRecords
.filter((record) => !liveIds.has(record.id)) .filter((record) => !liveIds.has(record.id))
@@ -61,7 +61,7 @@ export async function createTestPaseoDaemon(
staticDir, staticDir,
mcpDebug: false, mcpDebug: false,
agentClients: {}, agentClients: {},
agentRegistryPath: path.join(paseoHome, "agents.json"), agentStoragePath: path.join(paseoHome, "agents"),
agentControlMcp: { agentControlMcp: {
url: `http://127.0.0.1:${port}/mcp/agents`, url: `http://127.0.0.1:${port}/mcp/agents`,
}, },
@@ -1,7 +1,7 @@
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import type { Server as HTTPServer } from "http"; import type { Server as HTTPServer } from "http";
import type { AgentManager } from "./agent/agent-manager.js"; import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentRegistry } from "./agent/agent-registry.js"; import type { AgentStorage } from "./agent/agent-storage.js";
import type { DownloadTokenStore } from "./file-download/token-store.js"; import type { DownloadTokenStore } from "./file-download/token-store.js";
import type { OpenAISTT } from "./agent/stt-openai.js"; import type { OpenAISTT } from "./agent/stt-openai.js";
import type { OpenAITTS } from "./agent/tts-openai.js"; import type { OpenAITTS } from "./agent/tts-openai.js";
@@ -31,7 +31,7 @@ export class VoiceAssistantWebSocketServer {
server: HTTPServer, server: HTTPServer,
logger: pino.Logger, logger: pino.Logger,
agentManager: AgentManager, agentManager: AgentManager,
agentRegistry: AgentRegistry, agentStorage: AgentStorage,
downloadTokenStore: DownloadTokenStore, downloadTokenStore: DownloadTokenStore,
paseoHome: string, paseoHome: string,
agentMcpConfig: AgentMcpClientConfig, agentMcpConfig: AgentMcpClientConfig,
@@ -43,7 +43,7 @@ export class VoiceAssistantWebSocketServer {
this.bridge = new WebSocketSessionBridge( this.bridge = new WebSocketSessionBridge(
this.logger, this.logger,
agentManager, agentManager,
agentRegistry, agentStorage,
downloadTokenStore, downloadTokenStore,
paseoHome, paseoHome,
agentMcpConfig, agentMcpConfig,
@@ -8,7 +8,7 @@ import {
} from "./messages.js"; } from "./messages.js";
import { Session } from "./session.js"; import { Session } from "./session.js";
import { AgentManager } from "./agent/agent-manager.js"; import { AgentManager } from "./agent/agent-manager.js";
import { AgentRegistry } from "./agent/agent-registry.js"; import { AgentStorage } from "./agent/agent-storage.js";
import type { AgentProvider } from "./agent/agent-sdk-types.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js";
import { DownloadTokenStore } from "./file-download/token-store.js"; import { DownloadTokenStore } from "./file-download/token-store.js";
import { PushTokenStore } from "./push/token-store.js"; import { PushTokenStore } from "./push/token-store.js";
@@ -29,7 +29,7 @@ export class WebSocketSessionBridge {
private readonly sessions: Map<WebSocket, Session> = new Map(); private readonly sessions: Map<WebSocket, Session> = new Map();
private clientIdCounter = 0; private clientIdCounter = 0;
private readonly agentManager: AgentManager; private readonly agentManager: AgentManager;
private readonly agentRegistry: AgentRegistry; private readonly agentStorage: AgentStorage;
private readonly downloadTokenStore: DownloadTokenStore; private readonly downloadTokenStore: DownloadTokenStore;
private readonly pushTokenStore: PushTokenStore; private readonly pushTokenStore: PushTokenStore;
private readonly pushService: PushService; private readonly pushService: PushService;
@@ -42,7 +42,7 @@ export class WebSocketSessionBridge {
constructor( constructor(
logger: pino.Logger, logger: pino.Logger,
agentManager: AgentManager, agentManager: AgentManager,
agentRegistry: AgentRegistry, agentStorage: AgentStorage,
downloadTokenStore: DownloadTokenStore, downloadTokenStore: DownloadTokenStore,
paseoHome: string, paseoHome: string,
agentMcpConfig: AgentMcpClientConfig, agentMcpConfig: AgentMcpClientConfig,
@@ -51,7 +51,7 @@ export class WebSocketSessionBridge {
) { ) {
this.logger = logger.child({ module: "websocket-session-bridge" }); this.logger = logger.child({ module: "websocket-session-bridge" });
this.agentManager = agentManager; this.agentManager = agentManager;
this.agentRegistry = agentRegistry; this.agentStorage = agentStorage;
this.downloadTokenStore = downloadTokenStore; this.downloadTokenStore = downloadTokenStore;
this.agentMcpConfig = agentMcpConfig; this.agentMcpConfig = agentMcpConfig;
this.stt = speech?.stt ?? null; this.stt = speech?.stt ?? null;
@@ -87,7 +87,7 @@ export class WebSocketSessionBridge {
this.downloadTokenStore, this.downloadTokenStore,
this.pushTokenStore, this.pushTokenStore,
this.agentManager, this.agentManager,
this.agentRegistry, this.agentStorage,
this.agentMcpConfig, this.agentMcpConfig,
this.stt, this.stt,
this.tts, this.tts,