From dbd2c0d7ac51b3856182f081147da78c16cc68bf Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 16 Oct 2026 15:18:04 -0400 Subject: [PATCH 01/17] feat(agent-host): run a saved agent without the desktop app Add buzz-agent-host, a Node script that runs one agent this machine's app saved, with the same AgentRunners and Claude/Codex run code the app uses. It reads the agent's identity from identities.json and its key from BUZZ_AGENT_KEY or the app's Keychain bundle, listens on the agent's own relay connection, reads the community as the agent, and follows the app's hosts/ flock protocol so only one copy runs the agent. It uses the app's agent storage (agents/state/): the agent runs with the settings, team prompts and Claude or Codex sessions the app saved, reads its record again every tick so edits made in the app apply, and writes only its own agent's record. Build with `pnpm agent-host`; run with `node dist-agent-host/buzz-agent-host.mjs run `. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .gitignore | 1 + biome.json | 1 + package.json | 1 + src-tauri/src/agent_state.rs | 5 + src/agent-host/claim.test.ts | 91 +++++ src/agent-host/claim.ts | 72 ++++ src/agent-host/feed.test.ts | 197 +++++++++++ src/agent-host/feed.ts | 221 +++++++++++++ src/agent-host/host.test.ts | 59 ++++ src/agent-host/host.ts | 493 ++++++++++++++++++++++++++++ src/agent-host/identity.test.ts | 51 +++ src/agent-host/identity.ts | 142 ++++++++ src/agent-host/main.ts | 57 ++++ src/agent-host/reads.test.ts | 107 ++++++ src/agent-host/reads.ts | 159 +++++++++ src/agent-host/spawn.test.ts | 42 +++ src/agent-host/spawn.ts | 132 ++++++++ src/agent-host/storage.test.ts | 83 +++++ src/agent-host/storage.ts | 131 ++++++++ src/bundled/claude-code/defaults.ts | 82 +++-- src/bundled/codex/defaults.ts | 67 ++++ src/bundled/codex/tabs.tsx | 54 +-- src/buzz-mcp/client.ts | 2 +- src/buzz-mcp/node.ts | 9 +- src/features/agents/runner.test.ts | 159 +++++++++ src/features/agents/store.ts | 10 + vite.agent-host.config.ts | 16 + 27 files changed, 2361 insertions(+), 83 deletions(-) create mode 100644 src/agent-host/claim.test.ts create mode 100644 src/agent-host/claim.ts create mode 100644 src/agent-host/feed.test.ts create mode 100644 src/agent-host/feed.ts create mode 100644 src/agent-host/host.test.ts create mode 100644 src/agent-host/host.ts create mode 100644 src/agent-host/identity.test.ts create mode 100644 src/agent-host/identity.ts create mode 100644 src/agent-host/main.ts create mode 100644 src/agent-host/reads.test.ts create mode 100644 src/agent-host/reads.ts create mode 100644 src/agent-host/spawn.test.ts create mode 100644 src/agent-host/spawn.ts create mode 100644 src/agent-host/storage.test.ts create mode 100644 src/agent-host/storage.ts create mode 100644 src/bundled/codex/defaults.ts create mode 100644 src/features/agents/runner.test.ts create mode 100644 vite.agent-host.config.ts diff --git a/.gitignore b/.gitignore index b3b51ab8d2..a7a0fe8ff9 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ dist-buzz-mcp/ # its bundled resources. src-tauri/resources/bin/* !src-tauri/resources/bin/.gitkeep +dist-agent-host/ diff --git a/biome.json b/biome.json index e3b00792d0..070d89fc44 100644 --- a/biome.json +++ b/biome.json @@ -15,6 +15,7 @@ "!!dist-author", "!!dist-plugins", "!!dist-buzz-mcp", + "!!dist-agent-host", "!!.profiles", "!!src-tauri/target", "!!src-tauri/gen", diff --git a/package.json b/package.json index afbfa03488..143bd74b4d 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "preview": "vite preview", "author:build": "node scripts/build-author.mjs", "buzz-mcp": "vite build --config vite.buzz-mcp.config.ts", + "agent-host": "vite build --config vite.agent-host.config.ts", "typecheck": "tsc --noEmit", "format": "biome format --write .", "format:check": "biome format .", diff --git a/src-tauri/src/agent_state.rs b/src-tauri/src/agent_state.rs index 43b5e4927c..2d6fe40168 100644 --- a/src-tauri/src/agent_state.rs +++ b/src-tauri/src/agent_state.rs @@ -305,6 +305,11 @@ mod tests { ); assert_eq!(key.len(), 251); assert!(encode(&key).len() > NAME_LIMIT); + // The name the agent host's `fileName` gives it (storage.test.ts). + assert_eq!( + file_name(&key), + "~2418a80781b72ec15bca6c4423fa41f0937d29e13b19fe1247bf0daaa571773f" + ); for key in [key.as_str(), &":".repeat(86), &"k".repeat(NAME_LIMIT + 1)] { state.set(key, Some("{\"a\":1}")).unwrap(); let name = file_name(key); diff --git a/src/agent-host/claim.test.ts b/src/agent-host/claim.test.ts new file mode 100644 index 0000000000..74ceb67b1d --- /dev/null +++ b/src/agent-host/claim.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment node +import { type ChildProcess, spawn } from "node:child_process"; +import { closeSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Claims, hold } from "./claim"; + +const agent = "ab".repeat(32); +let dir: string; +const claims: Claims[] = []; +const children: ChildProcess[] = []; +const make = () => { + const made = new Claims(dir); + claims.push(made); + return made; +}; + +/** Another process that holds `path` with flock(2), as the app's + * `File::try_lock` does, until it is killed. */ +async function flockHolder(path: string, mode: "LOCK_EX" | "LOCK_SH") { + const child = spawn( + "python3", + [ + "-c", + `import fcntl,sys,time\nf=open(sys.argv[1],"a")\nfcntl.flock(f,fcntl.${mode})\nprint("held",flush=True)\ntime.sleep(60)`, + path, + ], + { stdio: ["ignore", "pipe", "inherit"] }, + ); + children.push(child); + await new Promise((resolve, reject) => { + child.stdout?.once("data", () => resolve()); + child.once("exit", () => reject(new Error("The holder did not start"))); + }); + return child; +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "agent-host-claim-")); +}); +afterEach(() => { + for (const made of claims.splice(0)) made.dispose(); + for (const child of children.splice(0)) child.kill("SIGKILL"); + rmSync(dir, { recursive: true, force: true }); +}); + +describe.skipIf(process.platform !== "darwin")("on macOS", () => { + it("lets one copy run the agent until it lets go", async () => { + const first = make(); + const second = make(); + expect(await first.claim(agent)).toBe(true); + expect(await first.claim(agent)).toBe(true); + expect(await second.claim(agent)).toBe(false); + await first.release(agent); + expect(await second.claim(agent)).toBe(true); + }); + + it("lets the agent go while the installed app holds it", async () => { + const host = make(); + expect(await host.claim(agent)).toBe(true); + const packaged = hold(join(dir, `${agent}.packaged`), "exclusive"); + expect(packaged).toBeDefined(); + expect(await host.claim(agent)).toBe(false); + // The lock is free again, so another copy can take over after the app. + if (packaged !== undefined) closeSync(packaged); + expect(await make().claim(agent)).toBe(true); + }); + + it("shares the app's flock(2) locks, and gets them back when a holder dies", async () => { + const host = make(); + // The installed app holds the agent. + const app = await flockHolder(join(dir, `${agent}.packaged`), "LOCK_EX"); + expect(await host.claim(agent)).toBe(false); + app.kill("SIGKILL"); + await expect.poll(() => host.claim(agent)).toBe(true); + await host.release(agent); + // A dev build holds it. + const dev = await flockHolder(join(dir, `${agent}.lock`), "LOCK_EX"); + expect(await host.claim(agent)).toBe(false); + dev.kill("SIGKILL"); + await expect.poll(() => host.claim(agent)).toBe(true); + }); +}); + +it.skipIf(process.platform === "darwin")( + "runs no agent where it cannot check", + async () => { + await expect(make().claim(agent)).rejects.toThrow(/only on macOS/); + }, +); diff --git a/src/agent-host/claim.ts b/src/agent-host/claim.ts new file mode 100644 index 0000000000..63f2c934fa --- /dev/null +++ b/src/agent-host/claim.ts @@ -0,0 +1,72 @@ +// The claim that lets one copy on this machine run each agent, made the way a +// dev build of the desktop app makes it (`Hosting::claim` in app_agents.rs): +// while the installed app holds `.packaged` this host lets the agent +// go; otherwise it runs the agent only while it holds `.lock`. Both are +// flock(2) locks in the app's `hosts` directory, so the OS lets go of them when +// a holder exits, however it exits. +// +// Node has no flock call, but macOS open(2) takes the same lock when it is +// given O_EXLOCK or O_SHLOCK, and Node passes those flags through. The lock is +// this process's own, held while its file stays open. +import { closeSync, constants, mkdirSync, openSync } from "node:fs"; +import { join } from "node:path"; + +/** macOS ; Node does not name them. */ +const O_SHLOCK = 0x10; +const O_EXLOCK = 0x20; + +/** Takes the flock at `path` without waiting: the open file that holds it, or + * undefined when another holder has it. */ +export function hold( + path: string, + mode: "shared" | "exclusive", +): number | undefined { + if (process.platform !== "darwin") + throw new Error("The agent host can check who runs an agent only on macOS"); + try { + return openSync( + path, + constants.O_RDWR | + constants.O_CREAT | + constants.O_NONBLOCK | + (mode === "shared" ? O_SHLOCK : O_EXLOCK), + 0o600, + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EAGAIN") return undefined; + throw error; + } +} + +/** One host's claims, kept for as long as it runs. */ +export class Claims { + private readonly held = new Map(); + constructor(private readonly dir: string) {} + + /** Whether this host runs the agent now; asked on every wake, so it takes + * the agent over within a tick of the holder quitting or letting go. */ + async claim(pubkey: string): Promise { + if (!/^[0-9a-f]{64}$/.test(pubkey)) throw new Error("Invalid agent"); + mkdirSync(this.dir, { recursive: true }); + const packaged = hold(join(this.dir, `${pubkey}.packaged`), "shared"); + if (packaged === undefined) { + await this.release(pubkey); + return false; + } + closeSync(packaged); + if (!this.held.has(pubkey)) { + const lock = hold(join(this.dir, `${pubkey}.lock`), "exclusive"); + if (lock !== undefined) this.held.set(pubkey, lock); + } + return this.held.has(pubkey); + } + async release(pubkey: string): Promise { + const lock = this.held.get(pubkey); + if (lock !== undefined) closeSync(lock); + this.held.delete(pubkey); + } + dispose() { + for (const lock of this.held.values()) closeSync(lock); + this.held.clear(); + } +} diff --git a/src/agent-host/feed.test.ts b/src/agent-host/feed.test.ts new file mode 100644 index 0000000000..943021638b --- /dev/null +++ b/src/agent-host/feed.test.ts @@ -0,0 +1,197 @@ +// @vitest-environment node +import { + finalizeEvent, + generateSecretKey, + getPublicKey, +} from "nostr-tools/pure"; +import { afterEach, expect, it, vi } from "vitest"; +import { AgentFeed } from "./feed"; + +/** A relay connection the test answers by hand. */ +class FakeSocket { + static readonly OPEN = 1; + static last: FakeSocket | undefined; + readyState = 0; + readonly sent: unknown[][] = []; + private readonly listeners = new Map void)[]>(); + constructor(readonly url: string) { + FakeSocket.last = this; + } + addEventListener(type: string, listener: (event: unknown) => void) { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + send(text: string) { + this.sent.push(JSON.parse(text) as unknown[]); + } + close() {} + emit(type: string, event: unknown = {}) { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + opened() { + this.readyState = FakeSocket.OPEN; + this.emit("open"); + } + receive(message: unknown[]) { + this.emit("message", { data: JSON.stringify(message) }); + } + reqs() { + return this.sent.filter((message) => message[0] === "REQ"); + } +} + +/** The id of the login the feed sent. */ +const loginId = (socket: FakeSocket) => { + const login = socket.sent.find((message) => message[0] === "AUTH"); + if (!login) throw new Error("No login sent"); + return (login[1] as { id: string }).id; +}; + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +it("opens again the subscriptions a relay closed until a late login, and is not listening meanwhile", async () => { + vi.useFakeTimers(); + vi.stubGlobal("WebSocket", FakeSocket); + const secret = generateSecretKey(); + const statuses: boolean[] = []; + const feed = new AgentFeed({ + relay: "wss://relay.example.test", + secret, + pubkey: getPublicKey(secret), + onEvents: () => {}, + onStatus: (live) => statuses.push(live), + }); + feed.start(); + const socket = FakeSocket.last as FakeSocket; + socket.opened(); + // No login asked for in two seconds: it subscribes as it is. + await vi.advanceTimersByTimeAsync(2_000); + expect(socket.reqs().map((message) => message[1])).toEqual([ + "mentions", + "rosters", + ]); + expect(statuses.at(-1)).toBe(true); + // Then the relay asks for a login, and closes both until it has one. + socket.receive(["AUTH", "challenge"]); + socket.receive(["CLOSED", "mentions", "auth-required: log in first"]); + socket.receive(["CLOSED", "rosters", "auth-required: log in first"]); + expect(statuses.at(-1)).toBe(false); + socket.receive(["OK", loginId(socket), true, ""]); + expect(socket.reqs().map((message) => message[1])).toEqual([ + "mentions", + "rosters", + "mentions", + "rosters", + ]); + expect(statuses.at(-1)).toBe(true); + feed.stop(); +}); + +it("is not listening while the relay has closed its channel subscription", async () => { + vi.stubGlobal("WebSocket", FakeSocket); + const secret = generateSecretKey(); + const pubkey = getPublicKey(secret); + const statuses: boolean[] = []; + const feed = new AgentFeed({ + relay: "wss://relay.example.test", + secret, + pubkey, + onEvents: () => {}, + onStatus: (live) => statuses.push(live), + }); + feed.start(); + const socket = FakeSocket.last as FakeSocket; + socket.opened(); + socket.receive(["AUTH", "challenge"]); + socket.receive(["OK", loginId(socket), true, ""]); + expect(statuses.at(-1)).toBe(true); + // The relay's roster of a channel the agent is in. + const roster = finalizeEvent( + { + kind: 39002, + created_at: 100, + tags: [ + ["d", "channel-1"], + ["p", pubkey], + ], + content: "", + }, + generateSecretKey(), + ); + socket.receive(["EVENT", "rosters", roster]); + expect(socket.reqs().map((message) => message[1])).toEqual([ + "mentions", + "rosters", + "channels", + "metadata", + ]); + socket.receive(["CLOSED", "channels", "error: too many"]); + expect(statuses.at(-1)).toBe(false); + feed.stop(); +}); + +it("knows a channel is a DM of the agent and one other from its roster and type", () => { + vi.stubGlobal("WebSocket", FakeSocket); + const secret = generateSecretKey(); + const pubkey = getPublicKey(secret); + const other = getPublicKey(generateSecretKey()); + const third = getPublicKey(generateSecretKey()); + const feed = new AgentFeed({ + relay: "wss://relay.example.test", + secret, + pubkey, + onEvents: () => {}, + onStatus: () => {}, + }); + feed.start(); + const socket = FakeSocket.last as FakeSocket; + socket.opened(); + socket.receive(["AUTH", "challenge"]); + socket.receive(["OK", loginId(socket), true, ""]); + const relay = generateSecretKey(); + const roster = (channel: string, at: number, members: string[]) => + finalizeEvent( + { + kind: 39002, + created_at: at, + tags: [["d", channel], ...members.map((member) => ["p", member])], + content: "", + }, + relay, + ); + const metadata = (channel: string, type: string) => + finalizeEvent( + { + kind: 39000, + created_at: 100, + tags: [ + ["d", channel], + ["t", type], + ["t", "label"], + ], + content: "", + }, + relay, + ); + socket.receive(["EVENT", "rosters", roster("dm", 100, [pubkey, other])]); + socket.receive(["EVENT", "rosters", roster("group", 100, [pubkey, other])]); + socket.receive(["EVENT", "rosters", roster("stream", 100, [pubkey, other])]); + // Its type is unknown until the channel's metadata arrives. + expect(feed.dm("dm")).toBe(false); + socket.receive(["EVENT", "metadata", metadata("dm", "dm")]); + socket.receive(["EVENT", "metadata", metadata("group", "dm")]); + socket.receive(["EVENT", "metadata", metadata("stream", "stream")]); + expect(feed.dm("dm")).toBe(true); + expect(feed.dm("stream")).toBe(false); + // A newer roster with a third member makes it a group DM; an older one is ignored. + socket.receive([ + "EVENT", + "rosters", + roster("group", 200, [pubkey, other, third]), + ]); + socket.receive(["EVENT", "rosters", roster("group", 150, [pubkey, other])]); + expect(feed.dm("group")).toBe(false); + feed.stop(); +}); diff --git a/src/agent-host/feed.ts b/src/agent-host/feed.ts new file mode 100644 index 0000000000..a11ade4078 --- /dev/null +++ b/src/agent-host/feed.ts @@ -0,0 +1,221 @@ +// The agent's live feed on a standalone host: one WebSocket to its community, +// logged in as the agent (NIP-42, carrying its owner's attestation), that +// hears what names the agent and everything in the channels it is in. It +// reconnects after a drop and picks up from the last event it heard. +import { finalizeEvent } from "nostr-tools/pure"; +import { CHAT_KINDS } from "../features/agents/attention"; +import { eventsDto, type RelayEvent } from "../features/relay/events"; +import type { LiveBatch } from "../features/relay/incoming"; + +const KINDS = [...new Set([...CHAT_KINDS, 4, 5, 7, 40002])]; +const RETRY_MS = [1_000, 2_000, 5_000, 10_000, 30_000]; +/** Catch-up after a drop reaches back this far before the last event heard. */ +const OVERLAP_S = 30; + +export type FeedOptions = Readonly<{ + relay: string; + secret: Uint8Array; + pubkey: string; + /** The owner's NIP-OA `auth` tag. */ + auth?: readonly string[]; + /** Live events, one batch per event, with the event's channel. */ + onEvents(batch: LiveBatch): void; + /** Whether the feed is live now: the relay keeps its subscriptions open. */ + onStatus(live: boolean): void; + log?(message: string): void; +}>; + +export class AgentFeed { + /** The channels the relay lists the agent in. */ + readonly channels = new Set(); + /** Each channel's members, from its newest roster. */ + private readonly rosters = new Map< + string, + { at: number; members: readonly string[] } + >(); + /** Each channel's type (the first `t` tag of its newest metadata). */ + private readonly types = new Map(); + private socket: WebSocket | undefined; + private since = Math.floor(Date.now() / 1000); + private attempt = 0; + private stopped = false; + private timer: ReturnType | undefined; + constructor(private readonly options: FeedOptions) {} + + /** Whether the channel is a DM of exactly the agent and one other, by its + * roster and metadata. A group DM, or one either is unknown for, is not. */ + dm(channel: string) { + const members = this.rosters.get(channel)?.members; + return ( + this.types.get(channel)?.type === "dm" && + members?.length === 2 && + members.includes(this.options.pubkey) + ); + } + start() { + this.stopped = false; + this.connect(); + } + stop() { + this.stopped = true; + clearTimeout(this.timer); + this.socket?.close(); + this.socket = undefined; + } + + private connect() { + const { relay, log } = this.options; + const socket = new WebSocket(relay); + this.socket = socket; + let authed = false; + /** The subscriptions the relay has not closed. */ + const open = new Set(); + const send = (message: unknown[]) => socket.send(JSON.stringify(message)); + const req = (id: string, filter: Record) => { + open.add(id); + send(["REQ", id, filter]); + }; + // Live while the relay keeps the mentions and channel subscriptions. + const status = () => + this.options.onStatus( + open.has("mentions") && + open.has("rosters") && + (!this.channels.size || open.has("channels")), + ); + /** Opens each subscription that is not open: all at first, and after a + * login the ones the relay closed until the agent logged in. */ + const subscribe = () => { + const since = Math.max(0, this.since - OVERLAP_S); + if (!open.has("mentions")) + req("mentions", { kinds: KINDS, "#p": [this.options.pubkey], since }); + if (!open.has("rosters")) + req("rosters", { kinds: [39002], "#p": [this.options.pubkey] }); + if (!open.has("channels")) this.channelsChanged(req, since); + if (!open.has("metadata")) this.metadataChanged(req); + status(); + }; + socket.addEventListener("open", () => { + log?.(`Connected to ${relay}`); + // A relay that asks for no login hears the subscriptions as they are. + setTimeout(() => { + if (!authed && socket.readyState === WebSocket.OPEN) subscribe(); + }, 2_000); + }); + socket.addEventListener("message", (message) => { + let data: unknown[]; + try { + data = JSON.parse(String(message.data)) as unknown[]; + } catch { + return; + } + const [type] = data; + if (type === "AUTH" && typeof data[1] === "string") { + const event = finalizeEvent( + { + kind: 22242, + created_at: Math.floor(Date.now() / 1000), + content: "", + tags: [ + ["relay", relay], + ["challenge", data[1]], + ...(this.options.auth ? [[...this.options.auth]] : []), + ], + }, + this.options.secret, + ); + send(["AUTH", event]); + return; + } + if (type === "OK" && !authed) { + if (data[2] === true) { + authed = true; + this.attempt = 0; + subscribe(); + } else log?.(`Login refused: ${String(data[3])}`); + return; + } + if (type === "CLOSED") { + log?.(`Subscription ${String(data[1])} closed: ${String(data[2])}`); + // One closed until the agent logs in opens again after the login. + open.delete(String(data[1])); + status(); + return; + } + if (type === "NOTICE") { + log?.(`Notice: ${String(data[1])}`); + return; + } + if (type !== "EVENT") return; + let event: RelayEvent; + try { + [event] = eventsDto([data[2]]) as [RelayEvent]; + } catch { + return; + } + if (data[1] === "metadata") { + const id = event.tags.find((tag) => tag[0] === "d")?.[1]; + if (id && event.created_at >= (this.types.get(id)?.at ?? 0)) { + const type = event.tags.find((tag) => tag[0] === "t")?.[1]; + this.types.set(id, { + at: event.created_at, + ...(type ? { type } : {}), + }); + } + return; + } + if (data[1] === "rosters") { + const id = event.tags.find((tag) => tag[0] === "d")?.[1]; + if (!id) return; + if (event.created_at >= (this.rosters.get(id)?.at ?? 0)) + this.rosters.set(id, { + at: event.created_at, + members: [ + ...new Set( + event.tags.flatMap((tag) => + tag[0] === "p" && tag[1] ? [tag[1]] : [], + ), + ), + ], + }); + if (!this.channels.has(id)) { + this.channels.add(id); + this.channelsChanged(req, Math.max(0, this.since - OVERLAP_S)); + this.metadataChanged(req); + status(); + } + return; + } + this.since = Math.max(this.since, event.created_at); + const channelId = event.tags.find((tag) => tag[0] === "h")?.[1]; + this.options.onEvents({ + events: [event], + ...(channelId ? { channelId } : {}), + }); + }); + socket.addEventListener("close", () => { + if (this.socket === socket) this.socket = undefined; + this.options.onStatus(false); + if (this.stopped) return; + const wait = + RETRY_MS[Math.min(this.attempt++, RETRY_MS.length - 1)] ?? 30_000; + log?.(`Disconnected; trying again in ${wait / 1000}s`); + this.timer = setTimeout(() => this.connect(), wait); + }); + socket.addEventListener("error", () => {}); + } + /** Hears every channel the agent is in, as of now. */ + private channelsChanged( + req: (id: string, filter: Record) => void, + since: number, + ) { + if (!this.channels.size) return; + req("channels", { kinds: KINDS, "#h": [...this.channels], since }); + } + /** Hears the metadata, and so the type, of every channel the agent is in. */ + private metadataChanged( + req: (id: string, filter: Record) => void, + ) { + if (!this.channels.size) return; + req("metadata", { kinds: [39000], "#d": [...this.channels] }); + } +} diff --git a/src/agent-host/host.test.ts b/src/agent-host/host.test.ts new file mode 100644 index 0000000000..c69d87b3bf --- /dev/null +++ b/src/agent-host/host.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment node +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { DEFAULT_CONFIG } from "../bundled/claude-code/runtime"; +import { defaults as codexDefaults } from "../bundled/codex/config"; +import { localStore } from "../features/agents/store"; +import { checkKind, claudeResolver, codexResolver, KINDS } from "./host"; +import { stateStorage } from "./storage"; + +it("publishes the kinds the app's custody signs, and no others", () => { + const source = readFileSync( + join(__dirname, "../../crates/agent-controller/src/app_agent.rs"), + "utf8", + ); + const native = /const KINDS: \[u16; \d+\] = \[([^\]]*)\]/.exec(source)?.[1]; + expect(native).toBeDefined(); + expect(KINDS).toEqual( + native + ?.split(",") + .map((kind) => kind.trim()) + .filter(Boolean) + .map(Number), + ); + // A channel tool's creation and edit, and typing. + for (const kind of [9007, 9002, 20002]) + expect(() => checkKind(kind)).not.toThrow(); + expect(() => checkKind(0)).toThrow("Agents cannot publish kind 0"); + expect(() => checkKind(30023)).toThrow("Agents cannot publish kind 30023"); +}); + +it("resolves blank settings from the owner's saved defaults, as they are now", () => { + const storage = stateStorage(mkdtempSync(join(tmpdir(), "agent-host-"))); + const claude = claudeResolver(localStore(storage)); + const codex = codexResolver(localStore(storage)); + // Without saved defaults, the CLI's own. + expect(claude(DEFAULT_CONFIG).model).toBe(""); + expect(codex(codexDefaults)).toMatchObject({ model: "", effort: "" }); + // The app saves defaults while the host runs. + storage.setItem( + "buzz.claude-code.provider-defaults.v1", + JSON.stringify({ version: 1, model: "sonnet" }), + ); + storage.setItem( + "buzz.codex.provider-defaults.v1", + JSON.stringify({ version: 1, model: "gpt-5", effort: "high" }), + ); + expect(claude(DEFAULT_CONFIG).model).toBe("sonnet"); + expect(codex(codexDefaults)).toMatchObject({ + model: "gpt-5", + effort: "high", + }); + // An agent's own choice wins. + expect(claude({ ...DEFAULT_CONFIG, model: "opus" }).model).toBe("opus"); + expect(codex({ ...codexDefaults, model: "o3", effort: "low" })).toMatchObject( + { model: "o3", effort: "low" }, + ); +}); diff --git a/src/agent-host/host.ts b/src/agent-host/host.ts new file mode 100644 index 0000000000..ecea5e00f4 --- /dev/null +++ b/src/agent-host/host.ts @@ -0,0 +1,493 @@ +// A standalone agent host: runs one of the agents the desktop app saved on +// this machine, with the same runner and agent type code the app runs, while +// the app does not run it. The app's own claim protocol decides which copy +// runs the agent, so opening the installed app takes it over, and quitting +// the app lets this host take it back. +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { getPublicKey } from "nostr-tools/pure"; +import claudeManifest from "../bundled/claude-code/manifest.json"; +import { claudeRun } from "../bundled/claude-code/run"; +import { withScript as claudeScript } from "../bundled/claude-code/setup"; +import { + readDefaults as claudeSaved, + resolveConfig as claudeConfig, +} from "../bundled/claude-code/defaults"; +import { ClaudeRuntime, DEFAULT_CONFIG } from "../bundled/claude-code/runtime"; +import { defaults as codexDefaults } from "../bundled/codex/config"; +import { + readDefaults as codexSaved, + resolveConfig as codexConfig, +} from "../bundled/codex/defaults"; +import codexManifest from "../bundled/codex/manifest.json"; +import { codexRun } from "../bundled/codex/run"; +import { withScript as codexScript } from "../bundled/codex/setup"; +import { CodexRuntime } from "../bundled/codex/runtime"; +import { nodeClient } from "../buzz-mcp/node"; +import { CoreMemoryCache } from "../features/agents/core-memory-cache"; +import { createLocalAgentActivity } from "../features/agents/local-activity"; +import type { AgentReads } from "../features/agents/reads"; +import { communityDestination } from "../features/communities/destination"; +import type { Checkpoint } from "../features/agents/catch-up"; +import { AgentRunners } from "../features/agents/runner"; +import { + type Agent, + type RegisteredAgentType, + teamPrompt, +} from "../features/agents/service"; +import { + type AgentRecord, + type AgentStore, + localStore, + readOne, + readTeams, + recordKey, + setAttention, + withProgress, + writeRecord, +} from "../features/agents/store"; +import { eventsDto, type RelayEvent } from "../features/relay/events"; +import { relayPartition } from "../features/relay/partition"; +import { Claims } from "./claim"; +import { AgentFeed } from "./feed"; +import { checkSaved, type Identity, readNames } from "./identity"; +import { agentReads } from "./reads"; +import { type Declared, processHost } from "./spawn"; +import { stateStorage } from "./storage"; + +/** The kinds the app's custody signs for an agent, with its owner + * attestation: agent-controller app_agent.rs `KINDS`, which a test keeps this + * list equal to. The relay decides which channel changes it may make. */ +export const KINDS: readonly number[] = Object.freeze([ + 5, 7, 9, 9000, 9001, 9002, 9007, 9008, 9021, 9022, 20002, 40003, 40100, 41010, +]); +/** Throws for a kind the app's custody would not sign. */ +export function checkKind(kind: number) { + if (!KINDS.includes(kind)) + throw new Error(`Agents cannot publish kind ${kind}`); +} +/** How often due timers are looked for, as in the app. */ +const TICK_MS = 5_000; + +/** Events checked as the app checks what custody gives back. */ +const verified = (events: readonly unknown[]): RelayEvent[] => + eventsDto([...events]); + +/** An agent's `core` memory, as `Agents.core` gives it. */ +type Core = ( + pubkey: string, + signal?: AbortSignal, +) => Promise; + +type TypeRun = Readonly<{ + type: RegisteredAgentType; + sync(agents: readonly Agent[]): void; + /** Settles once every process the type started has exited. */ + dispose(): Promise; +}>; + +export type HostOptions = Readonly<{ + identity: Identity; + secret: Uint8Array; + /** The app's agents directory: `identities.json`, the `hosts` locks, and + * the agent storage the app and this host share. */ + dir: string; + log(message: string): void; + /** Called once the agent is deleted, erased or changed in the app: this + * host has stopped hosting it, and must stop. */ + onGone(reason: string): void; +}>; + +/** Runs the agent until the returned stop is called. */ +export function host(options: HostOptions): () => Promise { + const { identity, secret, dir, log, onGone } = options; + const { pubkey } = identity; + if (getPublicKey(secret) !== pubkey) + throw new Error("The key is not this agent's"); + const home = join(dir, "standalone", pubkey); + mkdirSync(home, { recursive: true, mode: 0o700 }); + // The app's agent storage: the agent's settings, teams and sessions are the + // ones the app shows and saves. Each write here is saved before it settles. + const storage = localStore(stateStorage(join(dir, "state"))); + const client = nodeClient({ + relay: identity.relay, + key: Buffer.from(secret).toString("hex"), + ...(identity.auth ? { auth: identity.auth } : {}), + root: home, + }); + const reads: AgentReads = agentReads(client, identity.relay); + let live = false; + const readsOf = (agent: string) => + live && agent === pubkey ? reads : undefined; + + // The agent's core memory, read through its own reads, as the app keeps it. + const coreMemory = new CoreMemoryCache(readsOf); + const core: Core = (agent, signal) => coreMemory.core(agent, signal); + + const [pluginId = "", typeId = ""] = identity.type.split("/"); + const run = typeRun(typeId, pluginId, identity.type, readsOf, core, storage); + const claims = new Claims(join(dir, "hosts")); + const activity = createLocalAgentActivity(); + + // The agent's record as last read, and the stored text it was read from: + // the app may save a change at any time, and each tick reads it again. + let saved = storage.get(recordKey(pubkey)); + let stored = readOne(storage, pubkey); + let view: Agent | undefined; + const recordOf = (): AgentRecord => { + if (!stored) { + const made = run.type.defaults(); + let record: AgentRecord = { + pubkey, + attention: {}, + config: made.config, + }; + for (const [slug, value] of Object.entries(made.attention ?? {})) + record = setAttention(record, slug, value); + stored = record; + } + return stored; + }; + const reread = () => { + const now = storage.get(recordKey(pubkey)); + if (now === saved) return; + saved = now; + stored = readOne(storage, pubkey); + view = undefined; + run.sync([agentView()]); + }; + // One object while nothing changes: the runner compares it to see edits. + const agent = (): Agent => { + if (view) return view; + const record = recordOf(); + view = Object.freeze({ + pubkey, + name: identity.name, + type: identity.type, + owner: identity.owner, + relay: identity.relay, + place: "here", + attention: record.attention, + skipped: record.skipped ?? {}, + timers: record.timers ?? {}, + attentionEnabled: record.attentionEnabled === true, + config: record.config, + }); + return view; + }; + /** Keeps `record` as the agent's: saved, when `write`. */ + const keep = async (record: AgentRecord, write = true) => { + mine(record.pubkey); + if (write) { + await writeRecord(storage, record); + saved = storage.get(recordKey(pubkey)); + } + stored = record; + view = undefined; + run.sync([agentView()]); + }; + // As the app's agents service: the record's changes run in order, each + // from the record the one before it kept, and its timers wait for them. + let changes: Promise = Promise.resolve(); + let changing = 0; + const feed = new AgentFeed({ + relay: identity.relay, + secret, + pubkey, + ...(identity.auth ? { auth: JSON.parse(identity.auth) as string[] } : {}), + // The feed gives no replays (`batch.replay`), so `since` filters none. + onEvents: (batch) => runners.dispatch(batch, () => true, started), + onStatus: (now) => { + if (now === live) return; + live = now; + log(now ? "Listening" : "Not listening"); + runners.resume(new Set(now ? [pubkey] : [])); + }, + log, + }); + // Whether the app deleted, erased or changed the agent: then this host + // stops, and tells its caller why. + let gone = false; + const isGone = () => { + let reason: string | undefined; + try { + reason = checkSaved(dir, identity); + } catch (error) { + log(`Could not read the saved agent: ${String(error)}`); + } + if (reason && !gone) { + gone = true; + // After this function has set the host up, if it is still doing so. + queueMicrotask(() => void stop().finally(() => onGone(reason))); + } + return gone; + }; + const mine = (agent: string) => { + if (agent !== pubkey) throw new Error("Not this host's agent"); + }; + // As the app scopes the agent's community, so saved sessions match. + const scope = relayPartition( + communityDestination(identity.relay).url, + identity.owner, + ); + const started = Math.floor(Date.now() / 1000); + // Catch-up progress, kept only while this host runs. The app saves every + // agent's progress under one key, rewritten whole from its own copy, so + // this host neither writes it nor catches up: as before catch-up, it hears + // what happens while it is connected. + let checkpoints: Readonly> = {}; + const runners = new AgentRunners({ + ready: (agent) => (live && agent === pubkey ? agentView() : undefined), + agent: (agent) => (agent === pubkey ? agentView() : undefined), + member: (_, event) => { + const channel = event.tags.find((tag) => tag[0] === "h")?.[1]; + return !!channel && feed.channels.has(channel); + }, + dm: (_, event) => { + const channel = event.tags.find((tag) => tag[0] === "h")?.[1]; + return !!channel && feed.dm(channel); + }, + // No catch-up: see `checkpoints`. + community: () => undefined, + records: () => [recordOf()], + checkpoints: { + read: () => checkpoints, + write: (next) => { + checkpoints = next; + }, + }, + scope: () => scope, + saved: (agent) => (agent === pubkey ? stored : undefined), + record: (agent) => { + mine(agent); + return recordOf(); + }, + change: (agent, apply) => { + mine(agent); + changing++; + const next = changes.then(async () => { + try { + const record = await apply(recordOf()); + await keep(record); + return record; + } finally { + if (!--changing) runners.wake(pubkey); + } + }); + changes = next.catch(() => {}); + return next; + }, + changing: () => changing > 0, + timers: () => recordOf().timers ?? {}, + // Timers wait while a change is pending, and a save here finishes as it + // is made, so progress applies to the newest record. Kept even when its + // save fails, so a timer does not run again at once. + progress: (agent, ran) => { + mine(agent); + const next = withProgress(recordOf(), ran); + if (!next) return; + keep(next).catch((error) => { + log(`Could not save the timers' progress: ${String(error)}`); + void keep(next, false); + }); + }, + // Each run starts from its saved record, as the app's agents do while the + // app binds no synced config to them: the synced copies are in the app's + // web storage, which this host cannot read. + start: (agent) => ({ ok: true, config: agent.config, revisions: [] }), + team: (agent) => teamPrompt(readTeams(storage), readNames(dir), agent), + // Channel templates live in the app's web storage, which this host + // cannot read; its agent offers none. + templates: () => [], + // Thread tabs are the owner's, in the app. + tabs: () => undefined, + // The classifier key is in the app's Keychain entry: no event watch here + // is checked by a classifier, as in an app without one. + classifier: () => "unavailable", + jev: () => undefined, + jevStatus: () => undefined, + classifierKey: () => "missing", + classifierKeyGeneration: () => 0, + setClassifierKey: () => {}, + context: async () => { + throw new Error("This host runs no classifier"); + }, + displayName: (_, person) => reads.name(person) ?? null, + custody: { + claim: async (agent) => { + mine(agent); + // Checked here too, not only each tick, so an event cannot start a + // turn for an agent the app has deleted. + if (gone || isGone()) return false; + try { + return await claims.claim(agent); + } catch (error) { + // Unlike the app, which always runs its agents, this host runs one + // only when it knows no other copy does. + log(`Could not check who runs the agent: ${String(error)}`); + return false; + } + }, + release: (agent) => claims.release(agent), + publish: async (agent, event) => { + mine(agent); + checkKind(event.kind); + const posted = await client.publish({ + kind: event.kind, + content: event.content, + tags: event.tags ?? [], + }); + return verified([posted])[0] as RelayEvent; + }, + query: async (agent, filters) => { + mine(agent); + return verified( + await client.query(filters as readonly Record[]), + ); + }, + searchProfiles: async (agent, query, limit) => { + mine(agent); + const found = await client.searchProfiles?.(query, limit); + return found && verified(found); + }, + upload: (agent, data, mime) => { + mine(agent); + return client.upload(Buffer.from(data, "base64"), mime); + }, + media: (agent, url) => { + mine(agent); + return client.media(url); + }, + remember: async (agent, slug, body, after) => { + mine(agent); + return verified([ + await client.remember(slug, body, after), + ])[0] as RelayEvent; + }, + }, + activity, + // As in its reads: only the owner's connection sees Solo chats, so a + // mention's turn, and its typing, keep the thread. + soloChat: async () => false, + remembered: (agent, slug, body) => coreMemory.wrote(agent, slug, body), + finished: (agent) => void coreMemory.refresh(agent), + // Only the owner's connection sees Solo chats, so there is none to name. + named: () => {}, + }); + function agentView() { + return agent(); + } + + runners.reconcile( + [{ pubkey, type: identity.type }], + new Map([[identity.type, run.type]]), + ); + run.sync([agentView()]); + feed.start(); + const tick = setInterval(() => { + if (gone || isGone()) return; + try { + reread(); + } catch (error) { + log(`Could not read the agent's settings: ${String(error)}`); + } + runners.tick(); + }, TICK_MS); + log(`Running ${identity.name} (${identity.type}) from ${home}`); + + let stopped: Promise | undefined; + const stop = () => (stopped ??= stopHosting()); + return stop; + + async function stopHosting() { + clearInterval(tick); + feed.stop(); + runners.clear(); + // Only once its processes have exited may another copy run the agent. + await run.dispose(); + await claims.release(pubkey); + claims.dispose(); + } +} + +/** Claude settings as the app resolves them: blank ones inherit the owner's + * defaults, read as they are saved now, since the app may change them. */ +export const claudeResolver = (storage: AgentStore) => (raw: unknown) => + claudeConfig(raw, claudeSaved(storage)); +/** Codex settings as the app resolves them, without the app's model list: a + * default model Codex no longer lists is left to Codex to report. */ +export const codexResolver = (storage: AgentStore) => (raw: unknown) => + codexConfig(raw, codexSaved(storage)); + +function typeRun( + typeId: string, + pluginId: string, + key: string, + reads: (pubkey: string) => AgentReads | undefined, + core: Core, + storage: AgentStore, +): TypeRun { + const registered = ( + type: Pick & + Partial, + ) => + Object.freeze({ + ...type, + key, + pluginId, + revision: "standalone", + }) as RegisteredAgentType; + if (typeId === "claude-code") { + const processes = processHost( + claudeManifest.host.processes as readonly Declared[], + ); + // Runs Buzz's own copy of Claude, which the desktop app keeps current. + const runtime = new ClaudeRuntime( + { spawn: claudeScript(processes.spawn) }, + reads, + core, + storage, + claudeResolver(storage), + ); + return { + type: registered({ + id: typeId, + title: "Claude", + defaults: () => ({ config: DEFAULT_CONFIG }), + ...claudeRun(runtime), + }), + sync: (agents) => runtime.sync(agents), + dispose: () => { + runtime.dispose(); + return processes.stopAll(); + }, + }; + } + if (typeId === "codex") { + const processes = processHost( + codexManifest.host.processes as readonly Declared[], + ); + // Runs Buzz's own copy of Codex, which the desktop app keeps current. + const runtime = new CodexRuntime( + codexScript(processes.spawn), + reads, + core, + storage, + codexResolver(storage), + ); + return { + type: registered({ + id: typeId, + title: "Codex", + defaults: () => ({ config: { ...codexDefaults } }), + ...codexRun(runtime), + }), + sync: (agents) => runtime.sync(agents), + dispose: () => { + runtime.dispose(); + return processes.stopAll(); + }, + }; + } + throw new Error(`This host cannot run ${key} agents`); +} diff --git a/src/agent-host/identity.test.ts b/src/agent-host/identity.test.ts new file mode 100644 index 0000000000..424b538058 --- /dev/null +++ b/src/agent-host/identity.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment node +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, it } from "vitest"; +import { checkSaved, readIdentity } from "./identity"; + +const saved = { + pubkey: "ab".repeat(32), + name: "Bot", + type: "buzz.claude-code/claude-code", + owner: "cd".repeat(32), + relay: "wss://relay.example", +}; +let dir: string; +const save = (list: unknown[]) => + writeFileSync(join(dir, "identities.json"), JSON.stringify(list)); + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "agent-host-identity-")); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +it("keeps hosting while the agent is saved as it started", () => { + save([saved]); + const identity = readIdentity(dir, saved.pubkey); + expect(checkSaved(dir, identity)).toBeUndefined(); + // A new name does not change who the agent is. + save([{ ...saved, name: "Renamed" }]); + expect(checkSaved(dir, identity)).toBeUndefined(); +}); + +it("stops hosting once the app deletes, erases or moves the agent", () => { + save([saved]); + const identity = readIdentity(dir, saved.pubkey); + save([{ ...saved, deleted: true }]); + expect(checkSaved(dir, identity)).toMatch(/no longer saved/); + save([]); + expect(checkSaved(dir, identity)).toMatch(/no longer saved/); + rmSync(join(dir, "identities.json")); + expect(checkSaved(dir, identity)).toMatch(/no longer saved/); + save([{ ...saved, owner: "ef".repeat(32) }]); + expect(checkSaved(dir, identity)).toMatch(/owner changed/); +}); + +it("keeps hosting through a list it cannot read", () => { + save([saved]); + const identity = readIdentity(dir, saved.pubkey); + writeFileSync(join(dir, "identities.json"), "{"); + expect(() => checkSaved(dir, identity)).toThrow(); +}); diff --git a/src/agent-host/identity.ts b/src/agent-host/identity.ts new file mode 100644 index 0000000000..42642ecdb2 --- /dev/null +++ b/src/agent-host/identity.ts @@ -0,0 +1,142 @@ +// The agent as the desktop app saved it on this machine: its identity from +// the app's `identities.json`, and its key from the environment or the app's +// Keychain item. +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { decode } from "nostr-tools/nip19"; +import { getPublicKey } from "nostr-tools/pure"; +import { hexToBytes } from "nostr-tools/utils"; + +/** The app's identifier: installed and dev builds share it, and its data. */ +const APP = "dev.local.buzz.foundation"; +/** The app's Keychain item that holds every agent's key (agent-controller). */ +const KEYCHAIN_SERVICE = `${APP}.agents`; +const KEYCHAIN_ACCOUNT = "agent-bundle-v1"; + +export type Identity = Readonly<{ + pubkey: string; + name: string; + /** The agent type's key, `pluginId/typeId`. */ + type: string; + owner: string; + relay: string; + /** The owner's NIP-OA `auth` tag, as JSON. */ + auth?: string; +}>; + +/** Where the app keeps its agents: `identities.json` and the `hosts` locks. */ +export function agentsDir(env = process.env) { + if (env.BUZZ_AGENTS_DIR) return env.BUZZ_AGENTS_DIR; + const data = + process.platform === "darwin" + ? join(homedir(), "Library/Application Support") + : (env.XDG_DATA_HOME ?? join(homedir(), ".local/share")); + return join(data, APP, "agents"); +} + +type Saved = Record; + +/** The agents saved on this machine that are not deleted. */ +function readList(dir: string): Saved[] { + const saved = JSON.parse( + readFileSync(join(dir, "identities.json"), "utf8"), + ) as unknown; + return (Array.isArray(saved) ? saved : []).filter( + (item): item is Saved => + !!item && typeof item === "object" && item.deleted !== true, + ); +} + +/** The name of each agent saved on this machine, for team prompts. */ +export function readNames(dir: string): { pubkey: string; name: string }[] { + return readList(dir).flatMap(({ pubkey, name }) => + typeof pubkey === "string" && typeof name === "string" + ? [{ pubkey, name }] + : [], + ); +} + +export function readIdentity(dir: string, pubkey: string): Identity { + const found = readList(dir).find((item) => item.pubkey === pubkey); + if (!found) throw new Error(`No agent ${pubkey} is saved on this machine`); + const text = (name: string) => { + const value = found[name]; + if (typeof value !== "string" || !value) + throw new Error(`The saved agent has no ${name}`); + return value; + }; + const auth = found.auth; + return { + pubkey, + name: text("name"), + type: text("type"), + owner: text("owner"), + relay: text("relay"), + ...(typeof auth === "string" && auth ? { auth } : {}), + }; +} + +/** Whether the agent is still saved here as `identity`: undefined while it is, + * or why this host must stop running it. The app marks a deleted agent in + * `identities.json`, and an erase that removes agents deletes the file. */ +export function checkSaved( + dir: string, + identity: Identity, +): string | undefined { + let now: Identity | undefined; + try { + now = readList(dir).some((item) => item.pubkey === identity.pubkey) + ? readIdentity(dir, identity.pubkey) + : undefined; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (!now) return "The agent is no longer saved on this machine"; + for (const field of ["type", "owner", "relay", "auth"] as const) + if (now[field] !== identity[field]) + return `The agent's ${field} changed on this machine`; + return undefined; +} + +/** The agent's secret key: `BUZZ_AGENT_KEY` (hex or nsec), or else the + * app's Keychain item, which macOS may ask the user to allow. */ +export function readKey(identity: Identity, env = process.env): Uint8Array { + let text = env.BUZZ_AGENT_KEY?.trim(); + if (!text) { + if (process.platform !== "darwin") + throw new Error("Set BUZZ_AGENT_KEY to the agent's key"); + const bundle = execFileSync( + "security", + [ + "find-generic-password", + "-s", + KEYCHAIN_SERVICE, + "-a", + KEYCHAIN_ACCOUNT, + "-w", + ], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + // macOS may ask the user to allow it; nobody may be there to answer. + timeout: 60_000, + }, + ).trim(); + const account = `agent:${identity.pubkey}-${createHash("sha256") + .update(identity.relay) + .digest("hex")}`; + const value = (JSON.parse(bundle) as Record)[account]; + if (typeof value !== "string") + throw new Error("The app's Keychain has no key for this agent"); + text = value; + } + const secret = text.startsWith("nsec1") + ? (decode(text as `nsec1${string}`).data as Uint8Array) + : hexToBytes(text); + if (getPublicKey(secret) !== identity.pubkey) + throw new Error("The key is not this agent's"); + return secret; +} diff --git a/src/agent-host/main.ts b/src/agent-host/main.ts new file mode 100644 index 0000000000..379ba78b49 --- /dev/null +++ b/src/agent-host/main.ts @@ -0,0 +1,57 @@ +// buzz-agent-host: runs one of this machine's Buzz agents without the desktop app. +// +// buzz-agent-host run +// +// The agent is one the app saved here, and it runs with the settings, teams +// and sessions the app saved for it. Its key comes from BUZZ_AGENT_KEY or the +// app's Keychain item; BUZZ_AGENTS_DIR overrides where the app keeps its +// agents. While the installed app runs the agent, this host waits. It runs +// agents only on macOS, where it can take the app's hosting locks. +import { host } from "./host"; +import { agentsDir, readIdentity, readKey } from "./identity"; + +const usage = "Usage: buzz-agent-host run "; +const log = (message: string) => + console.error(`${new Date().toISOString()} ${message}`); + +function main(args: readonly string[]) { + const [command, pubkey, ...rest] = args; + if ( + command !== "run" || + !pubkey || + !/^[0-9a-f]{64}$/.test(pubkey) || + rest.length + ) { + console.error(usage); + process.exit(2); + } + const dir = agentsDir(); + const identity = readIdentity(dir, pubkey); + const secret = readKey(identity); + const stop = host({ + identity, + secret, + dir, + log, + onGone: (reason) => { + log(`${reason}; stopped`); + process.exit(0); + }, + }); + let stopping = false; + const quit = () => { + if (stopping) process.exit(1); + stopping = true; + log("Stopping"); + void stop().finally(() => process.exit(0)); + }; + process.on("SIGINT", quit); + process.on("SIGTERM", quit); +} + +try { + main(process.argv.slice(2)); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/src/agent-host/reads.test.ts b/src/agent-host/reads.test.ts new file mode 100644 index 0000000000..831daf3a49 --- /dev/null +++ b/src/agent-host/reads.test.ts @@ -0,0 +1,107 @@ +// @vitest-environment node +import { createHash } from "node:crypto"; +import { schnorr } from "@noble/curves/secp256k1.js"; +import { bytesToHex } from "nostr-tools/utils"; +import { expect, it, vi } from "vitest"; +import type { BuzzClient } from "../buzz-mcp/client"; +import { fromOwner, workflowOf } from "../features/agents/owner-attestation"; +import { eventDto } from "../features/relay/events"; +import { keypair, signed } from "../features/relay/testing"; +import { agentReads } from "./reads"; + +const RELAY = "wss://relay.example"; + +function setup() { + const relay = keypair(), + owner = keypair(), + sibling = keypair(), + agent = keypair(); + const attestation = [ + "auth", + owner.pubkey, + "", + bytesToHex( + schnorr.sign( + createHash("sha256") + .update(`nostr:agent-auth:${sibling.pubkey}:`) + .digest(), + owner.secret, + ), + ), + ]; + const profile = signed(sibling, { + kind: 0, + content: JSON.stringify({ name: "Sibling" }), + tags: [attestation], + }); + const query = vi.fn(async () => [profile]); + const client = { pubkey: agent.pubkey, query } as unknown as BuzzClient; + const mention = eventDto( + signed(relay, { + kind: 9, + content: "@Agent workflow", + tags: [ + ["h", "channel"], + ["p", agent.pubkey], + ["buzz:workflow", "true"], + ["buzz:workflow-owner", sibling.pubkey], + ["buzz:workflow-mention", agent.pubkey], + ], + }), + ); + return { relay, owner, sibling, agent, client, query, mention }; +} + +it("accepts a workflow mention from the owner's attested agent, as the app does", async () => { + const f = setup(); + const reads = agentReads(f.client, RELAY, async () => f.relay.pubkey); + await vi.waitFor(() => + expect(reads.workflowAuthority()).toBe(f.relay.pubkey), + ); + expect( + await fromOwner( + f.mention, + f.owner.pubkey, + workflowOf(reads, f.agent.pubkey), + ), + ).toBe(true); + expect(f.query).toHaveBeenCalledWith([ + { kinds: [0], authors: [f.sibling.pubkey], limit: 1 }, + ]); + expect(reads.name(f.sibling.pubkey, [])).toBe("Sibling"); +}); + +it("ignores workflow mentions until the community gives its key, then asks again", async () => { + const f = setup(); + const self = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValue(f.relay.pubkey); + const reads = agentReads(f.client, RELAY, self); + await vi.waitFor(() => expect(self).toHaveBeenCalledOnce()); + await Promise.resolve(); + expect( + await fromOwner( + f.mention, + f.owner.pubkey, + workflowOf(reads, f.agent.pubkey), + ), + ).toBe(false); + await vi.waitFor(() => + expect(reads.workflowAuthority()).toBe(f.relay.pubkey), + ); + expect(self).toHaveBeenCalledTimes(2); +}); + +it("does not trust a workflow message signed by another key", async () => { + const f = setup(); + const reads = agentReads(f.client, RELAY, async () => keypair().pubkey); + await vi.waitFor(() => expect(reads.workflowAuthority()).toBeDefined()); + expect( + await fromOwner( + f.mention, + f.owner.pubkey, + workflowOf(reads, f.agent.pubkey), + ), + ).toBe(false); +}); diff --git a/src/agent-host/reads.ts b/src/agent-host/reads.ts new file mode 100644 index 0000000000..9a8be41122 --- /dev/null +++ b/src/agent-host/reads.ts @@ -0,0 +1,159 @@ +// What an agent's turns read of its community on a standalone host: every read +// is made as the agent, through its own signed requests, so a turn sees only +// what the agent may see. +import type { BuzzClient, Filter } from "../buzz-mcp/client"; +import type { AgentReads, ChannelFacts } from "../features/agents/reads"; +import { + type EventData, + eventsDto, + newer, + type RelayEvent, +} from "../features/relay/events"; + +const tagOf = (event: EventData, name: string) => + event.tags.find((tag) => tag[0] === name)?.[1]; +const newest = (events: readonly EventData[], kind: number) => + events + .filter((event) => event.kind === kind) + .sort((a, b) => b.created_at - a.created_at)[0]; + +/** Channel metadata's name: its content body overrides the tag. */ +function channelName(event: EventData) { + try { + const body = JSON.parse(event.content) as { name?: unknown }; + if (typeof body?.name === "string" && body.name) return body.name; + } catch {} + return tagOf(event, "name") || undefined; +} + +const abortable = (work: Promise, signal?: AbortSignal) => { + if (!signal) return work; + signal.throwIfAborted(); + return Promise.race([ + work, + new Promise((_, reject) => + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }), + ), + ]); +}; + +/** The key the community signs workflow messages with: its NIP-11 `self`, as + * the app reads it (`connectNativeTransport`). */ +export async function relaySelf(relay: string) { + const response = await fetch(relay.replace(/^ws/, "http"), { + headers: { accept: "application/nostr+json" }, + }); + if (!response.ok) + throw new Error(`Community information: ${response.status}`); + const self = ((await response.json()) as { self?: unknown } | null)?.self; + if (typeof self !== "string" || !/^[a-f0-9]{64}$/.test(self)) + throw new Error("The community did not give its identity"); + return self; +} + +export function agentReads( + client: BuzzClient, + scope: string, + self: () => Promise = () => relaySelf(scope), +): AgentReads { + // Signed profiles, verified as read; names come from them. + const profiles = new Map(); + const names = new Map(); + const channels = new Map(); + // Until the community gives its key, only the owner's own mentions count, + // as in the app before it connects. A failed read is tried again later. + let authority: string | undefined; + let asking = false; + const ask = () => { + if (asking) return; + asking = true; + self().then( + (key) => { + authority = key; + }, + () => { + asking = false; + }, + ); + }; + ask(); + return Object.freeze({ + scope, + connection: client, + async channel(id: string): Promise { + const events = eventsDto([ + ...(await client.query([ + { kinds: [39000], "#d": [id], limit: 1 }, + { kinds: [39002], "#d": [id], limit: 1 }, + ])), + ]); + const metadata = newest(events, 39000); + const roster = newest(events, 39002); + if (!metadata && !roster) return undefined; + const name = metadata && channelName(metadata); + if (name) channels.set(id, name); + return { + ...(name ? { name } : {}), + dm: !!metadata?.tags.some((tag) => tag[0] === "t" && tag[1] === "dm"), + members: [ + ...new Set( + roster?.tags.flatMap(([name, value]) => + name === "p" && value && /^[0-9a-f]{64}$/.test(value) + ? [value] + : [], + ) ?? [], + ), + ].sort(), + }; + }, + read: (filters, signal) => + abortable( + client + .query(filters as readonly Filter[]) + .then((events) => eventsDto([...events])), + signal, + ), + async profiles(pubkeys: readonly string[]) { + const missing = pubkeys.filter((pubkey) => !profiles.has(pubkey)); + if (!missing.length) return; + const events = eventsDto([ + ...(await client.query([ + { kinds: [0], authors: missing, limit: missing.length }, + ])), + ]); + for (const event of events) { + if (event.kind !== 0 || !missing.includes(event.pubkey)) continue; + const previous = profiles.get(event.pubkey); + if (newer(previous, event) === previous) continue; + profiles.set(event.pubkey, event); + try { + const profile = JSON.parse(event.content) as Record; + const name = profile.display_name || profile.name; + if (typeof name === "string" && name) names.set(event.pubkey, name); + else names.delete(event.pubkey); + } catch { + names.delete(event.pubkey); + } + } + }, + name: (pubkey: string) => names.get(pubkey), + profile: (pubkey: string) => profiles.get(pubkey), + channelName: (id: string) => channels.get(id), + workflowAuthority: () => { + if (!authority) ask(); + return authority; + }, + // The client skips entries it cannot decrypt, so it cannot show that a + // listing is complete; a turn then never takes a missing core as none. + memoryListing: async (signal: AbortSignal, slug?: string) => ({ + entries: (await abortable(client.memories(slug), signal)).entries, + partial: true, + }), + // Solo chats are the owner's own filing, which only the owner's connection + // reads; as the agent, a mention keeps its thread, as in the app when the + // owner is not the one signed in. + soloChat: async () => false, + }); +} diff --git a/src/agent-host/spawn.test.ts b/src/agent-host/spawn.test.ts new file mode 100644 index 0000000000..a90fab1cbb --- /dev/null +++ b/src/agent-host/spawn.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment node +import { expect, it } from "vitest"; +import { processHost } from "./spawn"; + +const alive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +it("settles stopAll only once a process group that ignores SIGTERM is gone", async () => { + const processes = processHost([ + { id: "sh", program: "/bin/sh", args: ["-c"] }, + ]); + let out = ""; + let ready!: () => void; + const started = new Promise((resolve) => (ready = resolve)); + // The shell and the sleep it starts both ignore SIGTERM. + const child = await processes.spawn("sh", { + args: ["trap '' TERM; sleep 60 & echo $$ $!; wait"], + onStdout: (data) => { + out += data; + if (out.includes("\n")) ready(); + }, + }); + await started; + const pids = out.trim().split(" ").map(Number); + expect(pids).toHaveLength(2); + const before = Date.now(); + await processes.stopAll(); + // After the grace period's SIGKILL, not at once. + expect(Date.now() - before).toBeGreaterThanOrEqual(2_900); + for (const pid of pids) expect(alive(pid)).toBe(false); + expect(await child.exited).toBeNull(); +}, 10_000); + +it("settles stopAll with nothing running", async () => { + await expect(processHost([]).stopAll()).resolves.toBeUndefined(); +}); diff --git a/src/agent-host/spawn.ts b/src/agent-host/spawn.ts new file mode 100644 index 0000000000..dc9b62cb7a --- /dev/null +++ b/src/agent-host/spawn.ts @@ -0,0 +1,132 @@ +// The desktop app's process host (host_process.rs), for Node: starts only the +// processes a type's manifest declares, with their declared arguments first, +// without any Buzz identity in their environment, each in its own process +// group so stopping one stops what it started. +import { spawn as start } from "node:child_process"; +import { mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { delimiter, join } from "node:path"; +import type { Host, HostProcess } from "../features/host/service"; + +export type Declared = Readonly<{ + id: string; + program: string; + args?: readonly string[]; +}>; + +const STOP_GRACE_MS = 3_000; +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms).unref()); + +/** PATH with per-user installs first and Homebrew's last, as the app has it. */ +export function effectivePath(path = process.env.PATH ?? "") { + const directories = [ + join(homedir(), ".local/bin"), + ...(path || "/usr/bin:/bin").split(delimiter).filter(Boolean), + ]; + if (process.platform === "darwin") + directories.push("/opt/homebrew/bin", "/usr/local/bin"); + return directories.join(delimiter); +} + +function directory(cwd: string) { + if (cwd === "~") return homedir(); + if (cwd.startsWith("~/")) return join(homedir(), cwd.slice(2)); + if (!cwd.startsWith("/")) throw new Error("Invalid process directory"); + return cwd; +} + +/** Starts declared processes; `stopAll` stops every one still running, and + * settles once each has exited. */ +export function processHost(declared: readonly Declared[]) { + const live = new Set<() => Promise>(); + const spawn: NonNullable = async (id, options = {}) => { + const entry = declared.find((item) => item.id === id); + if (!entry) throw new Error("Process is not declared"); + const args = options.args ?? []; + if (args.length > 256 || args.some((arg) => arg.includes("\0"))) + throw new Error("Invalid process arguments"); + const env: Record = {}; + for (const [name, value] of Object.entries(process.env)) + if ( + value !== undefined && + !name.startsWith("BUZZ_") && + !name.startsWith("NOSTR_") + ) + env[name] = value; + env.PATH = effectivePath(); + for (const [name, value] of Object.entries(options.env ?? {})) { + if (!name || /[=\0]/.test(name)) + throw new Error("Invalid environment variable"); + if (value === null) delete env[name]; + else env[name] = value; + } + const cwd = options.cwd ? directory(options.cwd) : undefined; + if (cwd) mkdirSync(cwd, { recursive: true }); + const child = start(entry.program, [...(entry.args ?? []), ...args], { + cwd, + env, + stdio: ["pipe", "pipe", "pipe"], + detached: true, + }); + const started = new Promise((resolve, reject) => { + child.once("spawn", resolve); + child.once("error", reject); + }); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (data: string) => options.onStdout?.(data)); + child.stderr.on("data", (data: string) => options.onStderr?.(data)); + child.stdin.on("error", () => {}); + const signal = (name: NodeJS.Signals) => { + try { + if (child.pid) process.kill(-child.pid, name); + } catch {} + }; + const exited = new Promise((resolve) => + child.once("close", (code) => resolve(code)), + ); + let stopping: Promise | undefined; + // SIGTERM to its process group, then SIGKILL to what is still there. + const kill = () => { + stopping ??= (async () => { + signal("SIGTERM"); + let timer: ReturnType | undefined; + const killed = new Promise((resolve) => { + timer = setTimeout(() => { + signal("SIGKILL"); + resolve(); + }, STOP_GRACE_MS); + }); + await Promise.race([exited, killed]); + clearTimeout(timer); + // A killed group is gone; only its pipes may still be closing. + await Promise.race([exited, sleep(STOP_GRACE_MS)]); + })(); + return stopping; + }; + live.add(kill); + void exited.then(() => live.delete(kill)); + await started; + const write = (data: string) => + new Promise((resolve, reject) => + child.stdin.write(data, (error) => (error ? reject(error) : resolve())), + ); + const handle: HostProcess = Object.freeze({ + write, + end: async (data?: string) => { + if (data) await write(data); + child.stdin.end(); + }, + kill, + exited, + }); + return handle; + }; + return { + spawn, + stopAll: async () => { + await Promise.all([...live].map((kill) => kill())); + }, + }; +} diff --git a/src/agent-host/storage.test.ts b/src/agent-host/storage.test.ts new file mode 100644 index 0000000000..7ecbd39cfb --- /dev/null +++ b/src/agent-host/storage.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment node +import { mkdtempSync, readdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { + localStore, + readRecords, + recordKey, + writeRecord, +} from "../features/agents/store"; +import { decodeKey, encodeKey, fileName, stateStorage } from "./storage"; + +it("names files as the app does", () => { + // The same names as agent_state.rs `names_round_trip`. + expect(encodeKey("buzz.agents.v1")).toBe("buzz.agents.v1"); + expect(encodeKey("buzz.codex.sessions.v4:wss://relay.example/a:ab")).toBe( + "buzz.codex.sessions.v4%3Awss%3A%2F%2Frelay.example%2Fa%3Aab", + ); + expect(encodeKey("a/b c%d")).toBe("a%2Fb%20c%25d"); + for (const key of ["buzz.agent.v1:ab", "a/b c%d", "é"]) + expect(decodeKey(encodeKey(key))).toBe(key); + expect(decodeKey("x.tmp")).toBeUndefined(); + expect(decodeKey(".hidden")).toBeUndefined(); + expect(decodeKey("bad%Z")).toBeUndefined(); + expect(decodeKey("has space")).toBeUndefined(); +}); + +it("reads what another program saved, and skips writes in progress", async () => { + const dir = join(mkdtempSync(join(tmpdir(), "agent-state-")), "state"); + const storage = stateStorage(dir); + expect(storage.getItem("missing")).toBeNull(); + expect(storage.length).toBe(0); + const pubkey = "a".repeat(64); + await writeRecord(localStore(storage), { + pubkey, + attention: {}, + config: { word: "one" }, + }); + // The app saves a change to the same file. + writeFileSync( + join(dir, encodeKey(recordKey(pubkey))), + JSON.stringify({ pubkey, attention: {}, config: { word: "two" } }), + ); + writeFileSync(join(dir, ".123.tmp"), "partial"); + expect(readRecords(localStore(storage))[pubkey]?.config).toEqual({ + word: "two", + }); + expect(storage.length).toBe(1); + storage.removeItem(recordKey(pubkey)); + storage.removeItem(recordKey(pubkey)); + expect(readdirSync(dir)).toEqual([".123.tmp"]); +}); + +it("saves a key whose name is too long in a file named by its hash, as the app does", () => { + const dir = join(mkdtempSync(join(tmpdir(), "agent-state-")), "state"); + const storage = stateStorage(dir); + // 251 bytes, as Codex names it for a community on a 95-character host; + // encoded, 263. + const key = `buzz.codex.sessions.v4:https://agents.production.us-west-2.buzz-workspace.platform.engineering.internal.tools.example.com:${"a".repeat(64)}:${"b".repeat(64)}`; + expect(key.length).toBe(251); + expect(encodeKey(key).length).toBeGreaterThan(255); + // The name agent_state.rs `file_name` gives it. + expect(fileName(key)).toBe( + "~2418a80781b72ec15bca6c4423fa41f0937d29e13b19fe1247bf0daaa571773f", + ); + storage.setItem(key, "{}"); + expect(readdirSync(dir)).toEqual([fileName(key)]); + expect(storage.getItem(key)).toBe("{}"); + expect(storage.key(0)).toBe(key); + storage.setItem(key, "2"); + expect(storage.getItem(key)).toBe("2"); + // A hashed file whose key does not give its name is not a key. + writeFileSync( + join(dir, `~${"0".repeat(64)}`), + JSON.stringify({ key: "other", value: "x" }), + ); + writeFileSync(join(dir, "~bad"), "not json"); + expect(storage.length).toBe(1); + storage.removeItem(key); + expect(storage.getItem(key)).toBeNull(); + expect(storage.length).toBe(0); +}); diff --git a/src/agent-host/storage.ts b/src/agent-host/storage.ts new file mode 100644 index 0000000000..e8b8d29ef3 --- /dev/null +++ b/src/agent-host/storage.ts @@ -0,0 +1,131 @@ +// The app's agent storage (`agents/state`, see src-tauri agent_state.rs) as a +// Storage: one file per key, each replaced whole through a rename. Reads go to +// the files every time, so this host sees what the app saves while it runs. +// A key whose encoded name is too long is saved, as the app saves it, in a file +// named `~` and the key's SHA-256 that holds `{"key": …, "value": …}`. +import { createHash } from "node:crypto"; +import { + closeSync, + fsyncSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; + +const SAFE = /^[A-Za-z0-9._-]$/; +/** The longest file name macOS, Linux and Windows all allow, in bytes. */ +const NAME_LIMIT = 255; +/** Starts the name of a file that holds its key. `encodeKey` never writes it. */ +const HASHED = "~"; + +/** The file name for `key`, as the app names it. */ +export function encodeKey(key: string) { + let name = ""; + for (const byte of new TextEncoder().encode(key)) { + const char = String.fromCharCode(byte); + name += SAFE.test(char) + ? char + : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`; + } + return name; +} + +/** The key a file name holds, or undefined for a file that is not one. */ +export function decodeKey(name: string) { + if (!name || name.startsWith(".") || name.endsWith(".tmp")) return undefined; + if (!/^(?:[A-Za-z0-9._-]|%[0-9A-Fa-f]{2})+$/.test(name)) return undefined; + try { + return decodeURIComponent(name); + } catch { + return undefined; + } +} + +/** The file `key` is saved in, as the app names it. */ +export function fileName(key: string) { + const name = encodeKey(key); + if (name.length <= NAME_LIMIT) return name; + return HASHED + createHash("sha256").update(key).digest("hex"); +} + +export function stateStorage(dir: string): Storage { + const read = (name: string) => { + try { + return readFileSync(join(dir, name), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + }; + /** The key and value a hashed file holds, if its key gives its name. */ + const hashed = (name: string) => { + try { + const saved = JSON.parse(read(name) ?? "null") as unknown; + if ( + typeof saved === "object" && + saved !== null && + "key" in saved && + "value" in saved && + typeof saved.key === "string" && + typeof saved.value === "string" && + fileName(saved.key) === name + ) + return { key: saved.key, value: saved.value }; + } catch {} + return undefined; + }; + const keys = () => { + let names: string[]; + try { + names = readdirSync(dir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + return names.flatMap( + (name) => + (name.startsWith(HASHED) ? hashed(name)?.key : decodeKey(name)) ?? [], + ); + }; + const remove = (key: string) => + rmSync(join(dir, fileName(key)), { force: true }); + return { + get length() { + return keys().length; + }, + key: (index: number) => keys()[index] ?? null, + getItem(key: string) { + const name = fileName(key); + if (!name.startsWith(HASHED)) return read(name) ?? null; + const saved = hashed(name); + return saved?.key === key ? saved.value : null; + }, + setItem(key: string, value: string) { + const name = fileName(key); + const text = name.startsWith(HASHED) + ? JSON.stringify({ key, value: String(value) }) + : String(value); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const temporary = join(dir, `.${process.pid}.${Date.now()}.tmp`); + // On disk before it replaces the file, as the app's `sync_all`, so a + // power loss cannot leave an empty file in its place. + const file = openSync(temporary, "w", 0o600); + try { + writeFileSync(file, text); + fsyncSync(file); + } finally { + closeSync(file); + } + renameSync(temporary, join(dir, name)); + }, + removeItem: remove, + clear() { + for (const key of keys()) remove(key); + }, + }; +} diff --git a/src/bundled/claude-code/defaults.ts b/src/bundled/claude-code/defaults.ts index b0bea2945a..92156bf2f0 100644 --- a/src/bundled/claude-code/defaults.ts +++ b/src/bundled/claude-code/defaults.ts @@ -6,7 +6,50 @@ export type ClaudeProviderDefaults = Readonly<{ effort?: string; }>; const NATIVE_DEFAULTS: ClaudeProviderDefaults = Object.freeze({ model: "" }); -const STORAGE_KEY = "buzz.claude-code.provider-defaults.v1"; +export const DEFAULTS_KEY = "buzz.claude-code.provider-defaults.v1"; + +/** The saved defaults; Claude Code's own, blank, when none are saved or + * they are malformed. */ +export function readDefaults( + storage: Pick, +): ClaudeProviderDefaults { + try { + const value: unknown = JSON.parse(storage.get(DEFAULTS_KEY) ?? "null"); + if ( + value && + typeof value === "object" && + (value as { version?: unknown }).version === 1 && + typeof (value as { model?: unknown }).model === "string" + ) + return Object.freeze({ + model: (value as { model: string }).model, + ...(typeof (value as { effort?: unknown }).effort === "string" + ? { effort: (value as { effort: string }).effort } + : {}), + }); + } catch { + // Malformed local preferences preserve Claude Code's native default. + } + return NATIVE_DEFAULTS; +} + +/** An agent's settings: its own model, else the defaults'; its own effort, + * else the defaults' when it uses the default model. */ +export function resolveConfig( + raw: unknown, + defaults: ClaudeProviderDefaults, +): Config { + const explicit = config(raw); + return { + ...explicit, + model: explicit.model.trim() ? explicit.model : defaults.model, + effort: + explicit.effort || + (!explicit.model.trim() || explicit.model === defaults.model + ? (defaults.effort ?? "") + : ""), + }; +} /** Device-wide defaults are separate from agent records; a blank agent value * inherits this store and a blank store value leaves Claude Code in charge. */ @@ -15,7 +58,7 @@ export class ClaudeDefaults { private readonly listeners = new Set<() => void>(); constructor(private readonly storage: AgentStore = localStore()) { - this.state = this.read(); + this.state = readDefaults(storage); } snapshot = () => this.state; @@ -24,48 +67,17 @@ export class ClaudeDefaults { return () => this.listeners.delete(listener); }; resolve(raw: unknown): Config { - const explicit = config(raw); - return { - ...explicit, - model: explicit.model.trim() ? explicit.model : this.state.model, - effort: - explicit.effort || - (!explicit.model.trim() || explicit.model === this.state.model - ? (this.state.effort ?? "") - : ""), - }; + return resolveConfig(raw, this.state); } /** Settles once the defaults are saved, and only then uses them; rejects, * keeping the saved ones, when they could not be saved. */ async save(next: ClaudeProviderDefaults) { const { model, effort = "" } = next; await this.storage.set( - STORAGE_KEY, + DEFAULTS_KEY, JSON.stringify({ version: 1, model, effort }), ); this.state = Object.freeze({ model, effort }); for (const listener of this.listeners) listener(); } - private read(): ClaudeProviderDefaults { - try { - const value: unknown = JSON.parse( - this.storage.get(STORAGE_KEY) ?? "null", - ); - if ( - value && - typeof value === "object" && - (value as { version?: unknown }).version === 1 && - typeof (value as { model?: unknown }).model === "string" - ) - return Object.freeze({ - model: (value as { model: string }).model, - ...(typeof (value as { effort?: unknown }).effort === "string" - ? { effort: (value as { effort: string }).effort } - : {}), - }); - } catch { - // Malformed local preferences preserve Claude Code's native default. - } - return NATIVE_DEFAULTS; - } } diff --git a/src/bundled/codex/defaults.ts b/src/bundled/codex/defaults.ts new file mode 100644 index 0000000000..4705ad1ba1 --- /dev/null +++ b/src/bundled/codex/defaults.ts @@ -0,0 +1,67 @@ +// The owner's device-wide Codex defaults, which an agent's blank model and +// thinking inherit. The app's provider dialog saves them in agent storage; the +// app and the standalone agent host resolve an agent's settings with them. +import type { AgentStore } from "../../features/agents/state"; +import { type Config, config, type Model, resolveModel } from "./config"; + +export type ProviderDefaults = Readonly<{ model: string; effort: string }>; +export const DEFAULTS_KEY = "buzz.codex.provider-defaults.v1"; +const NATIVE: ProviderDefaults = Object.freeze({ model: "", effort: "" }); + +/** The saved defaults; Codex's own, blank, when none are saved or they are + * malformed. */ +export function readDefaults( + storage: Pick, +): ProviderDefaults { + try { + const value: unknown = JSON.parse(storage.get(DEFAULTS_KEY) ?? "null"); + if ( + value && + typeof value === "object" && + (value as { version?: unknown }).version === 1 && + typeof (value as { model?: unknown }).model === "string" && + typeof (value as { effort?: unknown }).effort === "string" + ) + return Object.freeze({ + model: config(value).model, + effort: (value as { effort: string }).effort, + }); + } catch { + // Malformed local preferences follow Codex's recommendation. + } + return NATIVE; +} + +/** An agent's settings: its own model and thinking, else the defaults'. + * With Codex's `models`, a default model it no longer lists, and thinking the + * model does not support, fall back to Codex's choice. */ +export function resolveConfig( + raw: unknown, + defaults: ProviderDefaults, + models?: readonly Model[], +): Config { + const find = (model: string) => { + if (!models) return undefined; + const resolved = resolveModel(model, models); + return models.find((choice) => choice.model === resolved); + }; + const explicit = config(raw); + const inheritedModel = defaults.model; + const unavailable = Boolean( + inheritedModel && models && !find(inheritedModel), + ); + const model = explicit.model || (unavailable ? "" : inheritedModel); + if (explicit.effort) return { ...explicit, model }; + const selected = find(model); + const preferred = unavailable && !explicit.model ? "" : defaults.effort; + const effort = !preferred + ? "" + : !models + ? preferred + : selected?.supportedReasoningEfforts.some( + (choice) => choice.reasoningEffort === preferred, + ) + ? preferred + : (selected?.defaultReasoningEffort ?? ""); + return { ...explicit, model, effort }; +} diff --git a/src/bundled/codex/tabs.tsx b/src/bundled/codex/tabs.tsx index d5221861fc..d4cdbf443e 100644 --- a/src/bundled/codex/tabs.tsx +++ b/src/bundled/codex/tabs.tsx @@ -49,6 +49,12 @@ import { type Config, type Model, } from "./config"; +import { + DEFAULTS_KEY, + type ProviderDefaults, + readDefaults, + resolveConfig, +} from "./defaults"; import { AppServer, listModels, needsSignIn, type Spawn } from "./rpc"; import type { CodexRuntime, SavedConversation } from "./runtime"; import type { CodexSetup } from "./setup"; @@ -64,7 +70,6 @@ type Read = type Catalog = { models: Model[]; }; -type ProviderDefaults = Readonly<{ model: string; effort: string }>; type ProviderState = AgentProviderSnapshot & Readonly<{ checking: boolean; @@ -72,7 +77,6 @@ type ProviderState = AgentProviderSnapshot & error: string; defaults: ProviderDefaults; }>; -const STORAGE_KEY = "buzz.codex.provider-defaults.v1"; const SIGN_IN_ERROR = "Codex needs you to sign in."; const NOT_INSTALLED = "Codex is not installed."; const CONVERSATION_LIMIT = 30; @@ -113,7 +117,7 @@ export class CodexProvider { checking: false, data: undefined, error: "", - defaults: this.readDefaults(), + defaults: readDefaults(this.storage), }); // An install or sign-in holds the dialog open, then Codex is checked again. this.offSetup = setup?.subscribe(() => { @@ -203,32 +207,13 @@ export class CodexProvider { : (selected?.defaultReasoningEffort ?? ""); const defaults = Object.freeze({ model: next.model, effort }); await this.storage.set( - STORAGE_KEY, + DEFAULTS_KEY, JSON.stringify({ version: 1, ...defaults }), ); this.set({ defaults }); } resolve(raw: unknown): Config { - const explicit = readConfig(raw); - const inheritedModel = this.state.defaults.model; - const unavailable = Boolean( - inheritedModel && this.state.data && !this.model(inheritedModel), - ); - const model = explicit.model || (unavailable ? "" : inheritedModel); - if (explicit.effort) return { ...explicit, model }; - const selected = this.model(model); - const preferred = - unavailable && !explicit.model ? "" : this.state.defaults.effort; - const effort = !preferred - ? "" - : !this.state.data - ? preferred - : selected?.supportedReasoningEfforts.some( - (choice) => choice.reasoningEffort === preferred, - ) - ? preferred - : (selected?.defaultReasoningEffort ?? ""); - return { ...explicit, model, effort }; + return resolveConfig(raw, this.state.defaults, this.state.data?.models); } dispose() { this.offSetup?.(); @@ -238,27 +223,6 @@ export class CodexProvider { private model(model: string) { return catalogModel(model, this.state.data); } - private readDefaults(): ProviderDefaults { - try { - const value: unknown = JSON.parse( - this.storage.get(STORAGE_KEY) ?? "null", - ); - if ( - value && - typeof value === "object" && - (value as { version?: unknown }).version === 1 && - typeof (value as { model?: unknown }).model === "string" && - typeof (value as { effort?: unknown }).effort === "string" - ) - return Object.freeze({ - model: readConfig(value).model, - effort: (value as { effort: string }).effort, - }); - } catch { - // Malformed local preferences follow Codex's recommendation. - } - return Object.freeze({ model: "", effort: "" }); - } } const message = (error: unknown) => diff --git a/src/buzz-mcp/client.ts b/src/buzz-mcp/client.ts index 90e01c884d..dcbc30b9d5 100644 --- a/src/buzz-mcp/client.ts +++ b/src/buzz-mcp/client.ts @@ -111,7 +111,7 @@ export interface BuzzClient { /** The agent's memory entries, decrypted; only entry `slug` when given. */ memories(slug?: string): Promise; /** Writes entry `slug`, newer than the one it replaces (`after`, or 0). */ - remember(slug: string, body: string, after: number): Promise; + remember(slug: string, body: string, after: number): Promise; /** The owner's channel templates, where the host has them. */ templates?(): Promise; /** The agent's attention, where its host keeps one. Without it the diff --git a/src/buzz-mcp/node.ts b/src/buzz-mcp/node.ts index ceb4b10652..da5f391345 100644 --- a/src/buzz-mcp/node.ts +++ b/src/buzz-mcp/node.ts @@ -37,6 +37,11 @@ const MEDIA_FILE_LIMIT = 500 * 1024 * 1024; /** Images an agent can be shown. */ const SHOWN_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]; +/** A BuzzClient whose memory writes give back the event they posted. */ +export type NodeClient = Omit & { + remember(slug: string, body: string, after: number): Promise; +}; + export function nodeClient( options: Readonly<{ relay: string; @@ -49,7 +54,7 @@ export function nodeClient( /** Where fetched media is saved; a temporary directory by default. */ media?: string; }>, -): BuzzClient { +): NodeClient { const secret = options.key.startsWith("nsec1") ? (decode(options.key as `nsec1${string}`).data as Uint8Array) : hexToBytes(options.key); @@ -321,7 +326,7 @@ export function nodeClient( const plaintext = JSON.stringify( slug === "core" ? { slug, profile: body } : { slug, value: body }, ); - await post( + return post( sign( { kind: 30174, diff --git a/src/features/agents/runner.test.ts b/src/features/agents/runner.test.ts new file mode 100644 index 0000000000..16e4759fcb --- /dev/null +++ b/src/features/agents/runner.test.ts @@ -0,0 +1,159 @@ +// AgentRunners through a host other than AgentsService, as the standalone +// agent host (src/agent-host) gives it: the RunnerHost contract alone. +import { expect, it, vi } from "vitest"; +import type { RelayEvent } from "../relay/events"; +import { createLocalAgentActivity } from "./local-activity"; +import { AgentRunners, type RunnerHost } from "./runner"; +import type { Agent, Delivery, RegisteredAgentType } from "./service"; +import type { AgentRecord } from "./store"; + +const pubkey = "a".repeat(64); +const owner = "b".repeat(64); +const someone = "c".repeat(64); +const agent: Agent = Object.freeze({ + pubkey, + name: "Echo", + type: "test/echo", + owner, + relay: "wss://relay.example.test", + place: "here", + attention: {}, + skipped: {}, + timers: {}, + attentionEnabled: false, + config: { reply: "hi" }, +}); +const mention = (id: string) => + ({ + id: id.padEnd(64, "0"), + pubkey: someone, + kind: 9, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["h", "channel"], + ["p", pubkey], + ], + content: "hello", + sig: "f".repeat(128), + }) as unknown as RelayEvent; + +function fixture(holds = true) { + let live = true; + const record: AgentRecord = { pubkey, attention: {}, config: agent.config }; + const custody = { + claim: vi.fn(async () => holds), + release: vi.fn(async () => {}), + publish: vi.fn(async (_: string, event: { kind: number }) => ({ + ...mention("e"), + pubkey, + kind: event.kind, + })), + query: vi.fn(async () => []), + upload: vi.fn(), + media: vi.fn(), + remember: vi.fn(), + }; + const host: RunnerHost = { + ready: (key) => (live && key === pubkey ? agent : undefined), + agent: (key) => (key === pubkey ? agent : undefined), + member: () => true, + dm: () => false, + community: () => undefined, + records: () => [record], + checkpoints: { read: () => ({}), write: () => {} }, + scope: () => "scope", + saved: () => record, + record: () => record, + change: async (_, apply) => apply(record), + changing: () => false, + timers: () => ({}), + progress: () => {}, + start: () => ({ ok: true, config: agent.config, revisions: [] }), + team: () => "", + templates: () => [], + tabs: () => undefined, + classifier: () => "unavailable", + jev: () => undefined, + jevStatus: () => undefined, + classifierKey: () => "missing", + classifierKeyGeneration: () => 0, + setClassifierKey: () => {}, + context: async () => { + throw new Error("No classifier"); + }, + displayName: () => null, + custody: custody as unknown as RunnerHost["custody"], + activity: createLocalAgentActivity(), + soloChat: async () => false, + remembered: () => {}, + finished: () => {}, + named: () => {}, + }; + const ran: Delivery[] = []; + const type = { + id: "echo", + title: "Echo", + key: "test/echo", + pluginId: "test", + revision: "one", + defaults: () => ({ config: agent.config }), + run: vi.fn(async (delivery: Delivery) => { + ran.push(delivery); + await delivery.agent.publish({ kind: 9, content: "hi", tags: [] }); + }), + } as unknown as RegisteredAgentType; + const runners = new AgentRunners(host); + runners.reconcile([{ pubkey, type: type.key }], new Map([[type.key, type]])); + return { + runners, + custody, + ran, + type, + offline: () => { + live = false; + }, + }; +} + +it("runs a mention once it holds the agent, and publishes as the agent", async () => { + const f = fixture(); + const event = mention("1"); + f.runners.dispatch({ events: [event], channelId: "channel" }, () => true, 0); + // The same event again, as after a reconnect's catch-up, runs once. + f.runners.dispatch({ events: [event], channelId: "channel" }, () => true, 0); + await vi.waitFor(() => expect(f.ran).toHaveLength(1)); + expect(f.custody.claim).toHaveBeenCalledWith(pubkey); + expect(f.ran[0]).toMatchObject({ + trigger: { type: "mention", event }, + channelId: "channel", + config: agent.config, + agent: { pubkey, owner }, + }); + await vi.waitFor(() => + expect(f.custody.publish).toHaveBeenCalledWith( + pubkey, + expect.objectContaining({ kind: 9, content: "hi" }), + ), + ); + expect(f.runners.runs(pubkey)).toBe(true); +}); + +it("leaves a mention to the copy that holds the agent", async () => { + const f = fixture(false); + f.runners.dispatch({ events: [mention("2")] }, () => true, 0); + await vi.waitFor(() => expect(f.custody.claim).toHaveBeenCalled()); + await Promise.resolve(); + expect(f.type.run).not.toHaveBeenCalled(); + expect(f.runners.runs(pubkey)).toBe(false); +}); + +it("releases the agent once its community is no longer live", async () => { + const f = fixture(); + f.runners.resume(new Set([pubkey])); + await vi.waitFor(() => expect(f.custody.claim).toHaveBeenCalled()); + f.offline(); + f.runners.resume(new Set()); + await vi.waitFor(() => + expect(f.custody.release).toHaveBeenCalledWith(pubkey), + ); +}); diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index 00dc5a706f..b8a4ad17a3 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -106,6 +106,16 @@ export function readRecords(storage: AgentStore): Record { return records; } +/** One agent's saved record, from its own key or the legacy shared one. */ +export function readOne( + storage: AgentStore, + pubkey: string, +): AgentRecord | undefined { + const record = + parsed(storage.get(recordKey(pubkey))) ?? legacyRecords(storage)[pubkey]; + return record ? readRecord(pubkey, record as AgentRecord) : undefined; +} + function parsed(text: string | null): unknown { try { return JSON.parse(text ?? "null"); diff --git a/vite.agent-host.config.ts b/vite.agent-host.config.ts new file mode 100644 index 0000000000..d11801cf10 --- /dev/null +++ b/vite.agent-host.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vite"; + +// Builds the standalone agent host: one Node script with its dependencies +// bundled, run as `node dist-agent-host/buzz-agent-host.mjs run `. +export default defineConfig({ + publicDir: false, + ssr: { noExternal: true, target: "node" }, + build: { + outDir: "dist-agent-host", + emptyOutDir: true, + ssr: "src/agent-host/main.ts", + rolldownOptions: { + output: { entryFileNames: "buzz-agent-host.mjs", codeSplitting: false }, + }, + }, +}); From 85a4099c78bcb1bede7d823dbfc0a3cc0e704265 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 16 Oct 2026 15:18:08 -0400 Subject: [PATCH 02/17] fix(agent-host): keep the feed listening, and stop what a process left A DM's first messages wait for its metadata, so an untagged message from the owner wakes the agent as in the app. Subscriptions the relay closes after the login are asked for again, with a growing wait, unless it refused them. Channels are heard in batches of 128, the most the relay accepts in one request. When a process exits, stopped or not, its process group is killed too, as host_process.rs does, so a tool it left running cannot outlive the agent's claim. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- src/agent-host/feed.test.ts | 177 +++++++++++++++++++++++++++-- src/agent-host/feed.ts | 208 ++++++++++++++++++++++++++++------- src/agent-host/spawn.test.ts | 39 ++++++- src/agent-host/spawn.ts | 29 +++-- 4 files changed, 392 insertions(+), 61 deletions(-) diff --git a/src/agent-host/feed.test.ts b/src/agent-host/feed.test.ts index 943021638b..8ba9aeb29f 100644 --- a/src/agent-host/feed.test.ts +++ b/src/agent-host/feed.test.ts @@ -5,7 +5,7 @@ import { getPublicKey, } from "nostr-tools/pure"; import { afterEach, expect, it, vi } from "vitest"; -import { AgentFeed } from "./feed"; +import { AgentFeed, CHANNEL_BATCH } from "./feed"; /** A relay connection the test answers by hand. */ class FakeSocket { @@ -39,6 +39,20 @@ class FakeSocket { } } +/** The id of the latest request for subscription `key`. */ +const latest = (socket: FakeSocket, key: string) => { + const id = socket + .reqs() + .map((message) => String(message[1])) + .filter((id) => id === key || id.startsWith(`${key}:`)) + .at(-1); + if (!id) throw new Error(`No ${key} request`); + return id; +}; +/** The subscription keys of the requests sent, in order. */ +const asked = (socket: FakeSocket) => + socket.reqs().map((message) => String(message[1]).replace(/:\d+$/, "")); + /** The id of the login the feed sent. */ const loginId = (socket: FakeSocket) => { const login = socket.sent.find((message) => message[0] === "AUTH"); @@ -121,13 +135,13 @@ it("is not listening while the relay has closed its channel subscription", async generateSecretKey(), ); socket.receive(["EVENT", "rosters", roster]); - expect(socket.reqs().map((message) => message[1])).toEqual([ + expect(asked(socket)).toEqual([ "mentions", "rosters", - "channels", - "metadata", + "channels:0", + "metadata:0", ]); - socket.receive(["CLOSED", "channels", "error: too many"]); + socket.receive(["CLOSED", latest(socket, "channels:0"), "error: too many"]); expect(statuses.at(-1)).toBe(false); feed.stop(); }); @@ -180,9 +194,17 @@ it("knows a channel is a DM of the agent and one other from its roster and type" socket.receive(["EVENT", "rosters", roster("stream", 100, [pubkey, other])]); // Its type is unknown until the channel's metadata arrives. expect(feed.dm("dm")).toBe(false); - socket.receive(["EVENT", "metadata", metadata("dm", "dm")]); - socket.receive(["EVENT", "metadata", metadata("group", "dm")]); - socket.receive(["EVENT", "metadata", metadata("stream", "stream")]); + socket.receive(["EVENT", latest(socket, "metadata:0"), metadata("dm", "dm")]); + socket.receive([ + "EVENT", + latest(socket, "metadata:0"), + metadata("group", "dm"), + ]); + socket.receive([ + "EVENT", + latest(socket, "metadata:0"), + metadata("stream", "stream"), + ]); expect(feed.dm("dm")).toBe(true); expect(feed.dm("stream")).toBe(false); // A newer roster with a third member makes it a group DM; an older one is ignored. @@ -195,3 +217,142 @@ it("knows a channel is a DM of the agent and one other from its roster and type" expect(feed.dm("group")).toBe(false); feed.stop(); }); + +/** A feed logged in on a fake relay, and how to sign as the relay. */ +function loggedIn() { + vi.useFakeTimers(); + vi.stubGlobal("WebSocket", FakeSocket); + const secret = generateSecretKey(); + const pubkey = getPublicKey(secret); + const statuses: boolean[] = []; + const heard: string[] = []; + const feed = new AgentFeed({ + relay: "wss://relay.example.test", + secret, + pubkey, + onEvents: (batch) => { + for (const event of batch.events) + heard.push(`${event.content}:${feed.dm(batch.channelId ?? "")}`); + }, + onStatus: (live) => statuses.push(live), + }); + feed.start(); + const socket = FakeSocket.last as FakeSocket; + socket.opened(); + socket.receive(["AUTH", "challenge"]); + socket.receive(["OK", loginId(socket), true, ""]); + const relay = generateSecretKey(); + const sign = (kind: number, tags: string[][], content = "", at = 100) => + finalizeEvent({ kind, created_at: at, tags, content }, relay); + const roster = (channel: string, members: string[]) => + sign(39002, [["d", channel], ...members.map((member) => ["p", member])]); + return { feed, socket, pubkey, statuses, heard, sign, roster }; +} + +it("holds a new channel's messages until its metadata says whether it is a DM", () => { + const { feed, socket, pubkey, heard, sign, roster } = loggedIn(); + const owner = getPublicKey(generateSecretKey()); + socket.receive(["EVENT", "rosters", roster("dm", [pubkey, owner])]); + // The owner's untagged message arrives before the channel's metadata. + const message = sign(9, [["h", "dm"]], "hello", 150); + socket.receive(["EVENT", latest(socket, "channels:0"), message]); + // Heard again after a reconnect's overlap: held once. + socket.receive(["EVENT", latest(socket, "channels:0"), message]); + expect(heard).toEqual([]); + socket.receive([ + "EVENT", + latest(socket, "metadata:0"), + sign(39000, [ + ["d", "dm"], + ["t", "dm"], + ]), + ]); + expect(heard).toEqual([]); + socket.receive(["EOSE", latest(socket, "metadata:0")]); + // Given on once the DM is known as one. + expect(heard).toEqual(["hello:true"]); + // Later messages in it go straight on. + socket.receive([ + "EVENT", + latest(socket, "channels:0"), + sign(9, [["h", "dm"]], "again", 160), + ]); + expect(heard).toEqual(["hello:true", "again:true"]); + feed.stop(); +}); + +it("gives a channel's held messages on, not as a DM, when the relay refuses its metadata", () => { + const { feed, socket, pubkey, heard, sign, roster } = loggedIn(); + socket.receive(["EVENT", "rosters", roster("room", [pubkey])]); + socket.receive([ + "EVENT", + latest(socket, "channels:0"), + sign(9, [["h", "room"]], "hi", 150), + ]); + socket.receive([ + "CLOSED", + latest(socket, "metadata:0"), + "restricted: not allowed", + ]); + expect(heard).toEqual(["hi:false"]); + feed.stop(); +}); + +it("asks again for a subscription the relay closed after the login, and is listening once it is open", async () => { + const { feed, socket, statuses } = loggedIn(); + expect(statuses.at(-1)).toBe(true); + socket.receive(["CLOSED", "mentions", "error: database error"]); + socket.receive(["CLOSED", "rosters", "error: database error"]); + expect(statuses.at(-1)).toBe(false); + await vi.advanceTimersByTimeAsync(999); + expect(asked(socket)).toEqual(["mentions", "rosters"]); + await vi.advanceTimersByTimeAsync(1); + expect(asked(socket)).toEqual(["mentions", "rosters", "mentions", "rosters"]); + expect(statuses.at(-1)).toBe(true); + // Closed again before it sent anything: the wait grows. + socket.receive(["CLOSED", "mentions", "error: database error"]); + await vi.advanceTimersByTimeAsync(1_000); + expect(asked(socket)).toHaveLength(4); + await vi.advanceTimersByTimeAsync(1_000); + expect(asked(socket)).toHaveLength(5); + expect(statuses.at(-1)).toBe(true); + feed.stop(); +}); + +it("does not ask again for a subscription the relay refuses", async () => { + const { feed, socket, statuses } = loggedIn(); + socket.receive(["CLOSED", "mentions", "restricted: not a member"]); + await vi.advanceTimersByTimeAsync(60_000); + expect(asked(socket)).toEqual(["mentions", "rosters"]); + expect(statuses.at(-1)).toBe(false); + feed.stop(); +}); + +it("hears channels in batches the relay accepts, asking again only for the batch that changed", () => { + const { feed, socket, pubkey, statuses, roster } = loggedIn(); + for (let index = 0; index < CHANNEL_BATCH; index++) + socket.receive(["EVENT", "rosters", roster(`c${index}`, [pubkey])]); + const filters = (key: string) => + socket + .reqs() + .filter((message) => String(message[1]).startsWith(`${key}:`)) + .map((message) => message[2] as Record); + expect(filters("channels:0").at(-1)?.["#h"]).toHaveLength(CHANNEL_BATCH); + const before = socket.reqs().length; + socket.receive(["EVENT", "rosters", roster("last", [pubkey])]); + // One more channel starts a second batch; the first is not asked again. + expect(asked(socket).slice(before)).toEqual(["channels:1", "metadata:1"]); + expect(filters("channels:1").at(-1)?.["#h"]).toEqual(["last"]); + expect(filters("metadata:1").at(-1)?.["#d"]).toEqual(["last"]); + for (const message of socket.reqs()) { + const filter = message[2] as Record; + expect((filter["#h"] ?? filter["#d"] ?? []).length).toBeLessThanOrEqual( + CHANNEL_BATCH, + ); + } + expect(statuses.at(-1)).toBe(true); + // A batch the relay closes leaves the feed not listening until it is back. + socket.receive(["CLOSED", latest(socket, "channels:1"), "error: busy"]); + expect(statuses.at(-1)).toBe(false); + feed.stop(); +}); diff --git a/src/agent-host/feed.ts b/src/agent-host/feed.ts index a11ade4078..1aeceada14 100644 --- a/src/agent-host/feed.ts +++ b/src/agent-host/feed.ts @@ -11,6 +11,15 @@ const KINDS = [...new Set([...CHAT_KINDS, 4, 5, 7, 40002])]; const RETRY_MS = [1_000, 2_000, 5_000, 10_000, 30_000]; /** Catch-up after a drop reaches back this far before the last event heard. */ const OVERLAP_S = 30; +/** The most channels one request names: the relay refuses more + * (buzz-relay `MAX_EXPLICIT_CHANNEL_VALUES`), so channels are heard in + * batches of this many, each its own subscription. */ +export const CHANNEL_BATCH = 128; +/** The most events held for one channel while its metadata loads. */ +const HELD_LIMIT = 500; +/** Why a relay closes a subscription that asking again will not change: it + * stays closed until the next connection. Other closes are tried again. */ +const DENIED = /^(restricted|blocked):/; export type FeedOptions = Readonly<{ relay: string; @@ -28,6 +37,8 @@ export type FeedOptions = Readonly<{ export class AgentFeed { /** The channels the relay lists the agent in. */ readonly channels = new Set(); + /** The same channels in the order they were found, which batches them. */ + private readonly order: string[] = []; /** Each channel's members, from its newest roster. */ private readonly rosters = new Map< string, @@ -35,11 +46,16 @@ export class AgentFeed { >(); /** Each channel's type (the first `t` tag of its newest metadata). */ private readonly types = new Map(); + /** Channels whose metadata the relay has not finished sending, with the + * events heard in them meanwhile. Whether a channel is a DM decides how + * its messages wake the agent, so they wait until it is known. */ + private readonly loading = new Map(); private socket: WebSocket | undefined; private since = Math.floor(Date.now() / 1000); private attempt = 0; private stopped = false; private timer: ReturnType | undefined; + private cleanup: (() => void) | undefined; constructor(private readonly options: FeedOptions) {} /** Whether the channel is a DM of exactly the agent and one other, by its @@ -59,6 +75,7 @@ export class AgentFeed { stop() { this.stopped = true; clearTimeout(this.timer); + this.cleanup?.(); this.socket?.close(); this.socket = undefined; } @@ -68,32 +85,107 @@ export class AgentFeed { const socket = new WebSocket(relay); this.socket = socket; let authed = false; - /** The subscriptions the relay has not closed. */ + let subscribed = false; + let serial = 0; + /** Each subscription key's latest request id, and the key of each id. */ + const current = new Map(); + const keys = new Map(); + /** The ids the relay has not closed. */ const open = new Set(); + /** The channels each metadata request settles once the relay has sent + * what it has (EOSE). */ + const settles = new Map(); + /** Keys the relay refused, until the next connection. */ + const denied = new Set(); + /** Keys waiting to be asked again, with their failures so far. */ + const retries = new Map< + string, + { attempt: number; timer?: ReturnType } + >(); + this.cleanup = () => { + for (const retry of retries.values()) clearTimeout(retry.timer); + retries.clear(); + }; const send = (message: unknown[]) => socket.send(JSON.stringify(message)); - const req = (id: string, filter: Record) => { + const batches = () => Math.ceil(this.order.length / CHANNEL_BATCH); + const batch = (index: number) => + this.order.slice(index * CHANNEL_BATCH, (index + 1) * CHANNEL_BATCH); + const all = () => [ + "mentions", + "rosters", + ...Array.from({ length: batches() }, (_, index) => [ + `channels:${index}`, + `metadata:${index}`, + ]).flat(), + ]; + const isOpen = (key: string) => { + const id = current.get(key); + return !!id && open.has(id); + }; + const filter = (key: string): Record => { + const since = Math.max(0, this.since - OVERLAP_S); + const [kind, index] = key.split(":"); + if (kind === "mentions") + return { kinds: KINDS, "#p": [this.options.pubkey], since }; + if (kind === "rosters") + return { kinds: [39002], "#p": [this.options.pubkey] }; + const channels = batch(Number(index)); + return kind === "channels" + ? { kinds: KINDS, "#h": channels, since } + : { kinds: [39000], "#d": channels }; + }; + /** Asks for `key` as it is now, replacing an earlier request for it. */ + const ask = (key: string) => { + const old = current.get(key); + if (old && open.delete(old)) send(["CLOSE", old]); + if (old) { + keys.delete(old); + settles.delete(old); + } + const waiting = retries.get(key); + if (waiting?.timer) { + clearTimeout(waiting.timer); + retries.set(key, { attempt: waiting.attempt }); + } + const id = key.includes(":") ? `${key}:${++serial}` : key; + current.set(key, id); + keys.set(id, key); open.add(id); - send(["REQ", id, filter]); + const asked = filter(key); + if (key.startsWith("metadata:")) settles.set(id, asked["#d"] as string[]); + send(["REQ", id, asked]); }; - // Live while the relay keeps the mentions and channel subscriptions. + // Live while the relay keeps the mentions, rosters and every channel + // batch's subscription. const status = () => this.options.onStatus( - open.has("mentions") && - open.has("rosters") && - (!this.channels.size || open.has("channels")), + all() + .filter((key) => !key.startsWith("metadata:")) + .every(isOpen), ); - /** Opens each subscription that is not open: all at first, and after a - * login the ones the relay closed until the agent logged in. */ + /** Opens each subscription that is not open, refused or waiting to be + * asked again: all at first, and after a login the ones the relay closed + * until the agent logged in. */ const subscribe = () => { - const since = Math.max(0, this.since - OVERLAP_S); - if (!open.has("mentions")) - req("mentions", { kinds: KINDS, "#p": [this.options.pubkey], since }); - if (!open.has("rosters")) - req("rosters", { kinds: [39002], "#p": [this.options.pubkey] }); - if (!open.has("channels")) this.channelsChanged(req, since); - if (!open.has("metadata")) this.metadataChanged(req); + subscribed = true; + for (const key of all()) + if (!isOpen(key) && !denied.has(key) && !retries.get(key)?.timer) + ask(key); status(); }; + /** Asks for `key` again after a wait that grows with each failure. */ + const retry = (key: string) => { + const attempt = retries.get(key)?.attempt ?? 0; + const wait = RETRY_MS[Math.min(attempt, RETRY_MS.length - 1)] ?? 30_000; + const timer = setTimeout(() => { + retries.set(key, { attempt: attempt + 1 }); + if (this.socket === socket && socket.readyState === WebSocket.OPEN) + if (!isOpen(key)) ask(key); + status(); + }, wait); + retries.set(key, { attempt: attempt + 1, timer }); + log?.(`Asking for ${key} again in ${wait / 1000}s`); + }; socket.addEventListener("open", () => { log?.(`Connected to ${relay}`); // A relay that asks for no login hears the subscriptions as they are. @@ -134,10 +226,31 @@ export class AgentFeed { } else log?.(`Login refused: ${String(data[3])}`); return; } + if (type === "EOSE") { + const id = String(data[1]); + const key = keys.get(id); + if (key) retries.delete(key); + for (const channel of settles.get(id) ?? []) this.settle(channel); + settles.delete(id); + return; + } if (type === "CLOSED") { - log?.(`Subscription ${String(data[1])} closed: ${String(data[2])}`); - // One closed until the agent logs in opens again after the login. - open.delete(String(data[1])); + const id = String(data[1]); + const reason = String(data[2] ?? ""); + const key = keys.get(id); + // One replaced since, or not this feed's. + if (!key || current.get(key) !== id) return; + log?.(`Subscription ${key} closed: ${reason}`); + open.delete(id); + if (!authed && reason.startsWith("auth-required:")) { + // Opens again once the agent has logged in. + } else if (DENIED.test(reason)) { + denied.add(key); + // Its channels' facts will not come on this connection: their + // events go on as in channels that are not DMs. + for (const channel of settles.get(id) ?? []) this.settle(channel); + settles.delete(id); + } else retry(key); status(); return; } @@ -152,7 +265,8 @@ export class AgentFeed { } catch { return; } - if (data[1] === "metadata") { + const from = keys.get(String(data[1])) ?? ""; + if (from.startsWith("metadata:")) { const id = event.tags.find((tag) => tag[0] === "d")?.[1]; if (id && event.created_at >= (this.types.get(id)?.at ?? 0)) { const type = event.tags.find((tag) => tag[0] === "t")?.[1]; @@ -163,7 +277,7 @@ export class AgentFeed { } return; } - if (data[1] === "rosters") { + if (from === "rosters") { const id = event.tags.find((tag) => tag[0] === "d")?.[1]; if (!id) return; if (event.created_at >= (this.rosters.get(id)?.at ?? 0)) @@ -179,21 +293,42 @@ export class AgentFeed { }); if (!this.channels.has(id)) { this.channels.add(id); - this.channelsChanged(req, Math.max(0, this.since - OVERLAP_S)); - this.metadataChanged(req); - status(); + this.order.push(id); + this.loading.set(id, []); + if (subscribed) { + // Only its batch is asked for again. + const index = Math.floor((this.order.length - 1) / CHANNEL_BATCH); + for (const key of [`channels:${index}`, `metadata:${index}`]) + if (!denied.has(key)) ask(key); + if (denied.has(`metadata:${index}`)) this.settle(id); + status(); + } } return; } this.since = Math.max(this.since, event.created_at); const channelId = event.tags.find((tag) => tag[0] === "h")?.[1]; - this.options.onEvents({ + const live: LiveBatch = { events: [event], ...(channelId ? { channelId } : {}), - }); + }; + const held = channelId && this.loading.get(channelId); + if (!held) { + this.options.onEvents(live); + return; + } + // Heard again, as after a reconnect, it is held once. + if (held.some((batch) => batch.events[0]?.id === event.id)) return; + held.push(live); + if (held.length > HELD_LIMIT) { + held.shift(); + log?.(`Dropped an event from ${channelId} while its type loads`); + } }); socket.addEventListener("close", () => { if (this.socket === socket) this.socket = undefined; + for (const retry of retries.values()) clearTimeout(retry.timer); + retries.clear(); this.options.onStatus(false); if (this.stopped) return; const wait = @@ -203,19 +338,12 @@ export class AgentFeed { }); socket.addEventListener("error", () => {}); } - /** Hears every channel the agent is in, as of now. */ - private channelsChanged( - req: (id: string, filter: Record) => void, - since: number, - ) { - if (!this.channels.size) return; - req("channels", { kinds: KINDS, "#h": [...this.channels], since }); - } - /** Hears the metadata, and so the type, of every channel the agent is in. */ - private metadataChanged( - req: (id: string, filter: Record) => void, - ) { - if (!this.channels.size) return; - req("metadata", { kinds: [39000], "#d": [...this.channels] }); + /** The channel's facts are known, or will not come: the events held for + * it go on, in the order heard. */ + private settle(channel: string) { + const held = this.loading.get(channel); + if (!held) return; + this.loading.delete(channel); + for (const batch of held) this.options.onEvents(batch); } } diff --git a/src/agent-host/spawn.test.ts b/src/agent-host/spawn.test.ts index a90fab1cbb..7d46aeced1 100644 --- a/src/agent-host/spawn.test.ts +++ b/src/agent-host/spawn.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import { processHost } from "./spawn"; const alive = (pid: number) => { @@ -37,6 +37,43 @@ it("settles stopAll only once a process group that ignores SIGTERM is gone", asy expect(await child.exited).toBeNull(); }, 10_000); +it("kills what a process left running when it exits, stopped or not", async () => { + const processes = processHost([ + { id: "sh", program: "/bin/sh", args: ["-c"] }, + ]); + // A tool that ignores SIGTERM and lets go of the shell's pipes, so the + // shell's exit closes them while it still runs. + const tool = + "(trap '' TERM; exec sleep 60 /dev/null 2>&1) & echo $!"; + const start = async (script: string) => { + let out = ""; + let ready!: () => void; + const printed = new Promise((resolve) => (ready = resolve)); + const child = await processes.spawn("sh", { + args: [script], + onStdout: (data) => { + out += data; + if (out.includes("\n")) ready(); + }, + }); + await printed; + return { child, pid: Number(out.trim()) }; + }; + // Stopped: the shell exits on SIGTERM at once, and stopAll waits for the + // tool to be gone too, without the grace period. + const stopped = await start(`${tool}; wait`); + expect(alive(stopped.pid)).toBe(true); + const before = Date.now(); + await processes.stopAll(); + expect(Date.now() - before).toBeLessThan(2_900); + await vi.waitFor(() => expect(alive(stopped.pid)).toBe(false)); + // Not stopped: the shell ends by itself, and its tool goes with it. + const ended = await start(tool); + expect(await ended.child.exited).toBe(0); + await vi.waitFor(() => expect(alive(ended.pid)).toBe(false)); + await processes.stopAll(); +}, 10_000); + it("settles stopAll with nothing running", async () => { await expect(processHost([]).stopAll()).resolves.toBeUndefined(); }); diff --git a/src/agent-host/spawn.ts b/src/agent-host/spawn.ts index dc9b62cb7a..1f15615f01 100644 --- a/src/agent-host/spawn.ts +++ b/src/agent-host/spawn.ts @@ -83,30 +83,35 @@ export function processHost(declared: readonly Declared[]) { if (child.pid) process.kill(-child.pid, name); } catch {} }; - const exited = new Promise((resolve) => - child.once("close", (code) => resolve(code)), + const left = new Promise((resolve) => + child.once("exit", (code) => resolve(code)), ); + const closed = new Promise((resolve) => + child.once("close", () => resolve()), + ); + // Settles once the process has exited, however it ended, and its group + // with it: as host_process.rs does, what it started and left running is + // killed then. Only its pipes may still be closing, for a while. + const exited = left.then(async (code) => { + signal("SIGKILL"); + await Promise.race([closed, sleep(STOP_GRACE_MS)]); + return code; + }); let stopping: Promise | undefined; // SIGTERM to its process group, then SIGKILL to what is still there. const kill = () => { stopping ??= (async () => { signal("SIGTERM"); - let timer: ReturnType | undefined; - const killed = new Promise((resolve) => { - timer = setTimeout(() => { - signal("SIGKILL"); - resolve(); - }, STOP_GRACE_MS); - }); - await Promise.race([exited, killed]); + const timer = setTimeout(() => signal("SIGKILL"), STOP_GRACE_MS); + await exited; clearTimeout(timer); - // A killed group is gone; only its pipes may still be closing. - await Promise.race([exited, sleep(STOP_GRACE_MS)]); })(); return stopping; }; live.add(kill); void exited.then(() => live.delete(kill)); + // One that never started has nothing to stop. + started.catch(() => live.delete(kill)); await started; const write = (data: string) => new Promise((resolve, reject) => From 16d6ecd1599445eb94f93921e1a0d1a1e5b7fdef Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 16 Oct 2026 15:18:12 -0400 Subject: [PATCH 03/17] fix(agent-host): keep messages heard during a partial outage - While one subscription is asked for again, the open ones still hear messages. The feed now holds them until it is listening again, and then gives them to the host. Before, the host dropped them. - Each subscription that closes keeps where it stopped hearing. Its new request asks from there, until the relay has sent what it missed. Before, traffic on another subscription moved that point on, so a long outage skipped messages. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- src/agent-host/feed.test.ts | 54 ++++++++++++++++++++++++++++++ src/agent-host/feed.ts | 67 ++++++++++++++++++++++++++++++++----- 2 files changed, 112 insertions(+), 9 deletions(-) diff --git a/src/agent-host/feed.test.ts b/src/agent-host/feed.test.ts index 8ba9aeb29f..34db219089 100644 --- a/src/agent-host/feed.test.ts +++ b/src/agent-host/feed.test.ts @@ -356,3 +356,57 @@ it("hears channels in batches the relay accepts, asking again only for the batch expect(statuses.at(-1)).toBe(false); feed.stop(); }); + +it("gives on what open subscriptions heard while another was asked for again, once listening", async () => { + const { feed, socket, pubkey, statuses, heard, sign, roster } = loggedIn(); + socket.receive(["EVENT", "rosters", roster("room", [pubkey])]); + socket.receive(["EOSE", latest(socket, "metadata:0")]); + expect(statuses.at(-1)).toBe(true); + socket.receive(["CLOSED", "rosters", "error: busy"]); + expect(statuses.at(-1)).toBe(false); + // The channel's subscription is still open, but the host takes nothing + // while the feed is not listening. + socket.receive([ + "EVENT", + latest(socket, "channels:0"), + sign(9, [["h", "room"]], "meanwhile", 150), + ]); + expect(heard).toEqual([]); + const at = statuses.length; + await vi.advanceTimersByTimeAsync(1_000); + // Listening again, then given on. + expect(statuses.slice(at)).toEqual([true]); + expect(heard).toEqual(["meanwhile:false"]); + feed.stop(); +}); + +it("asks a stream it lost for what it missed, though other streams heard on", async () => { + const { feed, socket, pubkey, sign, roster } = loggedIn(); + const start = Math.floor(Date.now() / 1000); + for (let index = 0; index <= CHANNEL_BATCH; index++) + socket.receive(["EVENT", "rosters", roster(`c${index}`, [pubkey])]); + const since = (key: string) => + ( + socket + .reqs() + .find((message) => message[1] === latest(socket, key))?.[2] as + | { since?: number } + | undefined + )?.since; + socket.receive(["CLOSED", latest(socket, "channels:0"), "error: busy"]); + // The second batch hears on, well past the overlap. + socket.receive([ + "EVENT", + latest(socket, "channels:1"), + sign(9, [["h", `c${CHANNEL_BATCH}`]], "later", start + 300), + ]); + await vi.advanceTimersByTimeAsync(1_000); + // The first batch asks again from where it stopped. + expect(since("channels:0")).toBe(start - 30); + // Once it has sent what it missed, it hears on as the others do. + socket.receive(["EOSE", latest(socket, "channels:0")]); + socket.receive(["CLOSED", latest(socket, "channels:0"), "error: busy"]); + await vi.advanceTimersByTimeAsync(1_000); + expect(since("channels:0")).toBe(start + 270); + feed.stop(); +}); diff --git a/src/agent-host/feed.ts b/src/agent-host/feed.ts index 1aeceada14..bd39fc988d 100644 --- a/src/agent-host/feed.ts +++ b/src/agent-host/feed.ts @@ -1,7 +1,8 @@ // The agent's live feed on a standalone host: one WebSocket to its community, // logged in as the agent (NIP-42, carrying its owner's attestation), that // hears what names the agent and everything in the channels it is in. It -// reconnects after a drop and picks up from the last event it heard. +// reconnects after a drop, and each subscription picks up from where it +// stopped hearing. import { finalizeEvent } from "nostr-tools/pure"; import { CHAT_KINDS } from "../features/agents/attention"; import { eventsDto, type RelayEvent } from "../features/relay/events"; @@ -15,7 +16,8 @@ const OVERLAP_S = 30; * (buzz-relay `MAX_EXPLICIT_CHANNEL_VALUES`), so channels are heard in * batches of this many, each its own subscription. */ export const CHANNEL_BATCH = 128; -/** The most events held for one channel while its metadata loads. */ +/** The most events held for one channel while its metadata loads, and + * while the feed is not live. */ const HELD_LIMIT = 500; /** Why a relay closes a subscription that asking again will not change: it * stays closed until the next connection. Other closes are tried again. */ @@ -27,7 +29,8 @@ export type FeedOptions = Readonly<{ pubkey: string; /** The owner's NIP-OA `auth` tag. */ auth?: readonly string[]; - /** Live events, one batch per event, with the event's channel. */ + /** Live events, one batch per event, with the event's channel. Only while + * the feed is live: ones heard meanwhile come once it is live again. */ onEvents(batch: LiveBatch): void; /** Whether the feed is live now: the relay keeps its subscriptions open. */ onStatus(live: boolean): void; @@ -50,8 +53,18 @@ export class AgentFeed { * events heard in them meanwhile. Whether a channel is a DM decides how * its messages wake the agent, so they wait until it is known. */ private readonly loading = new Map(); + /** Events heard while the feed was not live, as while one subscription is + * asked for again and the others are still open. The host takes events + * only while it is listening, so they wait until then. */ + private paused: LiveBatch[] = []; + private live = false; private socket: WebSocket | undefined; + /** The newest event heard: an open subscription has heard to here. */ private since = Math.floor(Date.now() / 1000); + /** Where each closed subscription stopped hearing, until a new request + * for it has sent what it missed (EOSE). Others go on meanwhile, and move + * `since` past what it missed. */ + private readonly pinned = new Map(); private attempt = 0; private stopped = false; private timer: ReturnType | undefined; @@ -78,6 +91,32 @@ export class AgentFeed { this.cleanup?.(); this.socket?.close(); this.socket = undefined; + this.paused = []; + } + + private setLive(live: boolean) { + this.live = live; + this.options.onStatus(live); + if (!live) return; + const paused = this.paused; + this.paused = []; + for (const batch of paused) this.options.onEvents(batch); + } + /** Gives the event on now, or once the feed is live. */ + private deliver(batch: LiveBatch) { + if (this.live) return this.options.onEvents(batch); + // Heard again, as after a reconnect, it waits once. + const id = batch.events[0]?.id; + if (this.paused.some((paused) => paused.events[0]?.id === id)) return; + this.paused.push(batch); + if (this.paused.length > HELD_LIMIT) { + this.paused.shift(); + this.options.log?.("Dropped an event heard while not listening"); + } + } + /** Marks where `key`'s stream stopped, unless it already stopped. */ + private pin(key: string) { + if (!this.pinned.has(key)) this.pinned.set(key, this.since); } private connect() { @@ -123,7 +162,10 @@ export class AgentFeed { return !!id && open.has(id); }; const filter = (key: string): Record => { - const since = Math.max(0, this.since - OVERLAP_S); + const since = Math.max( + 0, + (this.pinned.get(key) ?? this.since) - OVERLAP_S, + ); const [kind, index] = key.split(":"); if (kind === "mentions") return { kinds: KINDS, "#p": [this.options.pubkey], since }; @@ -158,7 +200,7 @@ export class AgentFeed { // Live while the relay keeps the mentions, rosters and every channel // batch's subscription. const status = () => - this.options.onStatus( + this.setLive( all() .filter((key) => !key.startsWith("metadata:")) .every(isOpen), @@ -229,7 +271,11 @@ export class AgentFeed { if (type === "EOSE") { const id = String(data[1]); const key = keys.get(id); - if (key) retries.delete(key); + if (key) { + retries.delete(key); + // Its missed events are sent: it hears on from here. + this.pinned.delete(key); + } for (const channel of settles.get(id) ?? []) this.settle(channel); settles.delete(id); return; @@ -242,6 +288,7 @@ export class AgentFeed { if (!key || current.get(key) !== id) return; log?.(`Subscription ${key} closed: ${reason}`); open.delete(id); + this.pin(key); if (!authed && reason.startsWith("auth-required:")) { // Opens again once the agent has logged in. } else if (DENIED.test(reason)) { @@ -314,7 +361,7 @@ export class AgentFeed { }; const held = channelId && this.loading.get(channelId); if (!held) { - this.options.onEvents(live); + this.deliver(live); return; } // Heard again, as after a reconnect, it is held once. @@ -329,7 +376,9 @@ export class AgentFeed { if (this.socket === socket) this.socket = undefined; for (const retry of retries.values()) clearTimeout(retry.timer); retries.clear(); - this.options.onStatus(false); + // Each open one stopped here; a closed one stopped earlier. + for (const key of current.keys()) this.pin(key); + this.setLive(false); if (this.stopped) return; const wait = RETRY_MS[Math.min(this.attempt++, RETRY_MS.length - 1)] ?? 30_000; @@ -344,6 +393,6 @@ export class AgentFeed { const held = this.loading.get(channel); if (!held) return; this.loading.delete(channel); - for (const batch of held) this.options.onEvents(batch); + for (const batch of held) this.deliver(batch); } } From bcb7d6e696aefcbcddbf9098fb858e4db0f47f5d Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 16 Oct 2026 15:18:18 -0400 Subject: [PATCH 04/17] fix(agent-host): give a missed burst to the agent as it has room The feed gave what it heard while not listening as live events. The runner marks a live event seen before it queues it, so a burst past the agent's queue (32) or rate budget was dropped for good. Those events, and what a re-requested subscription sends before its EOSE, now come as a replay. The runner keeps a replay in its backlog and admits it as the agent has room. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- src/agent-host/feed.test.ts | 54 +---------------------- src/agent-host/feed.ts | 14 +++++- src/agent-host/host.ts | 4 +- src/agent-host/test-socket.ts | 53 +++++++++++++++++++++++ src/features/agents/runner.test.ts | 69 ++++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 56 deletions(-) create mode 100644 src/agent-host/test-socket.ts diff --git a/src/agent-host/feed.test.ts b/src/agent-host/feed.test.ts index 34db219089..44d853f31a 100644 --- a/src/agent-host/feed.test.ts +++ b/src/agent-host/feed.test.ts @@ -6,59 +6,7 @@ import { } from "nostr-tools/pure"; import { afterEach, expect, it, vi } from "vitest"; import { AgentFeed, CHANNEL_BATCH } from "./feed"; - -/** A relay connection the test answers by hand. */ -class FakeSocket { - static readonly OPEN = 1; - static last: FakeSocket | undefined; - readyState = 0; - readonly sent: unknown[][] = []; - private readonly listeners = new Map void)[]>(); - constructor(readonly url: string) { - FakeSocket.last = this; - } - addEventListener(type: string, listener: (event: unknown) => void) { - this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); - } - send(text: string) { - this.sent.push(JSON.parse(text) as unknown[]); - } - close() {} - emit(type: string, event: unknown = {}) { - for (const listener of this.listeners.get(type) ?? []) listener(event); - } - opened() { - this.readyState = FakeSocket.OPEN; - this.emit("open"); - } - receive(message: unknown[]) { - this.emit("message", { data: JSON.stringify(message) }); - } - reqs() { - return this.sent.filter((message) => message[0] === "REQ"); - } -} - -/** The id of the latest request for subscription `key`. */ -const latest = (socket: FakeSocket, key: string) => { - const id = socket - .reqs() - .map((message) => String(message[1])) - .filter((id) => id === key || id.startsWith(`${key}:`)) - .at(-1); - if (!id) throw new Error(`No ${key} request`); - return id; -}; -/** The subscription keys of the requests sent, in order. */ -const asked = (socket: FakeSocket) => - socket.reqs().map((message) => String(message[1]).replace(/:\d+$/, "")); - -/** The id of the login the feed sent. */ -const loginId = (socket: FakeSocket) => { - const login = socket.sent.find((message) => message[0] === "AUTH"); - if (!login) throw new Error("No login sent"); - return (login[1] as { id: string }).id; -}; +import { asked, FakeSocket, latest, loginId } from "./test-socket"; afterEach(() => { vi.useRealTimers(); diff --git a/src/agent-host/feed.ts b/src/agent-host/feed.ts index bd39fc988d..a84fc48a60 100644 --- a/src/agent-host/feed.ts +++ b/src/agent-host/feed.ts @@ -30,7 +30,9 @@ export type FeedOptions = Readonly<{ /** The owner's NIP-OA `auth` tag. */ auth?: readonly string[]; /** Live events, one batch per event, with the event's channel. Only while - * the feed is live: ones heard meanwhile come once it is live again. */ + * the feed is live: ones heard meanwhile come once it is live again. Those, + * and what a request sends before its EOSE, come as a replay, which the + * runner takes as it has room. */ onEvents(batch: LiveBatch): void; /** Whether the feed is live now: the relay keeps its subscriptions open. */ onStatus(live: boolean): void; @@ -108,7 +110,9 @@ export class AgentFeed { // Heard again, as after a reconnect, it waits once. const id = batch.events[0]?.id; if (this.paused.some((paused) => paused.events[0]?.id === id)) return; - this.paused.push(batch); + // Given on in a burst, which live delivery would drop past the agent's + // room. + this.paused.push({ ...batch, replay: true }); if (this.paused.length > HELD_LIMIT) { this.paused.shift(); this.options.log?.("Dropped an event heard while not listening"); @@ -131,6 +135,8 @@ export class AgentFeed { const keys = new Map(); /** The ids the relay has not closed. */ const open = new Set(); + /** The ids whose stored events the relay is still sending (no EOSE). */ + const stored = new Set(); /** The channels each metadata request settles once the relay has sent * what it has (EOSE). */ const settles = new Map(); @@ -193,6 +199,7 @@ export class AgentFeed { current.set(key, id); keys.set(id, key); open.add(id); + stored.add(id); const asked = filter(key); if (key.startsWith("metadata:")) settles.set(id, asked["#d"] as string[]); send(["REQ", id, asked]); @@ -270,6 +277,7 @@ export class AgentFeed { } if (type === "EOSE") { const id = String(data[1]); + stored.delete(id); const key = keys.get(id); if (key) { retries.delete(key); @@ -358,6 +366,8 @@ export class AgentFeed { const live: LiveBatch = { events: [event], ...(channelId ? { channelId } : {}), + // What it missed, as after a drop: given as it has room. + ...(stored.has(String(data[1])) ? { replay: true as const } : {}), }; const held = channelId && this.loading.get(channelId); if (!held) { diff --git a/src/agent-host/host.ts b/src/agent-host/host.ts index ecea5e00f4..124cffb685 100644 --- a/src/agent-host/host.ts +++ b/src/agent-host/host.ts @@ -195,7 +195,9 @@ export function host(options: HostOptions): () => Promise { secret, pubkey, ...(identity.auth ? { auth: JSON.parse(identity.auth) as string[] } : {}), - // The feed gives no replays (`batch.replay`), so `since` filters none. + // A replay (what the feed heard while not listening, or what a request + // sent before its EOSE) waits for the agent's room; of it, only what + // happened since this host started is heard. onEvents: (batch) => runners.dispatch(batch, () => true, started), onStatus: (now) => { if (now === live) return; diff --git a/src/agent-host/test-socket.ts b/src/agent-host/test-socket.ts new file mode 100644 index 0000000000..53e7abb907 --- /dev/null +++ b/src/agent-host/test-socket.ts @@ -0,0 +1,53 @@ +// A relay connection a test answers by hand, for the agent feed. +/** A relay connection the test answers by hand. */ +export class FakeSocket { + static readonly OPEN = 1; + static last: FakeSocket | undefined; + readyState = 0; + readonly sent: unknown[][] = []; + private readonly listeners = new Map void)[]>(); + constructor(readonly url: string) { + FakeSocket.last = this; + } + addEventListener(type: string, listener: (event: unknown) => void) { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + send(text: string) { + this.sent.push(JSON.parse(text) as unknown[]); + } + close() {} + emit(type: string, event: unknown = {}) { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + opened() { + this.readyState = FakeSocket.OPEN; + this.emit("open"); + } + receive(message: unknown[]) { + this.emit("message", { data: JSON.stringify(message) }); + } + reqs() { + return this.sent.filter((message) => message[0] === "REQ"); + } +} + +/** The id of the latest request for subscription `key`. */ +export const latest = (socket: FakeSocket, key: string) => { + const id = socket + .reqs() + .map((message) => String(message[1])) + .filter((id) => id === key || id.startsWith(`${key}:`)) + .at(-1); + if (!id) throw new Error(`No ${key} request`); + return id; +}; +/** The subscription keys of the requests sent, in order. */ +export const asked = (socket: FakeSocket) => + socket.reqs().map((message) => String(message[1]).replace(/:\d+$/, "")); + +/** The id of the login the feed sent. */ +export const loginId = (socket: FakeSocket) => { + const login = socket.sent.find((message) => message[0] === "AUTH"); + if (!login) throw new Error("No login sent"); + return (login[1] as { id: string }).id; +}; diff --git a/src/features/agents/runner.test.ts b/src/features/agents/runner.test.ts index 16e4759fcb..b7ca8dd0d1 100644 --- a/src/features/agents/runner.test.ts +++ b/src/features/agents/runner.test.ts @@ -1,6 +1,13 @@ // AgentRunners through a host other than AgentsService, as the standalone // agent host (src/agent-host) gives it: the RunnerHost contract alone. +import { + finalizeEvent, + generateSecretKey, + getPublicKey, +} from "nostr-tools/pure"; import { expect, it, vi } from "vitest"; +import { AgentFeed } from "../../agent-host/feed"; +import { FakeSocket } from "../../agent-host/test-socket"; import type { RelayEvent } from "../relay/events"; import { createLocalAgentActivity } from "./local-activity"; import { AgentRunners, type RunnerHost } from "./runner"; @@ -157,3 +164,65 @@ it("releases the agent once its community is no longer live", async () => { expect(f.custody.release).toHaveBeenCalledWith(pubkey), ); }); + +it("runs every mention the standalone feed heard while not listening, past the agent's queue", async () => { + vi.useFakeTimers(); + vi.stubGlobal("WebSocket", FakeSocket); + try { + const f = fixture(); + const secret = generateSecretKey(); + const feed = new AgentFeed({ + relay: "wss://relay.example.test", + secret, + pubkey: getPublicKey(secret), + onEvents: (batch) => f.runners.dispatch(batch, () => true, 0), + onStatus: () => {}, + }); + feed.start(); + const socket = FakeSocket.last as FakeSocket; + socket.opened(); + // No login asked for: it subscribes as it is, and has heard the past. + await vi.advanceTimersByTimeAsync(2_000); + socket.receive(["EOSE", "mentions"]); + socket.receive(["EOSE", "rosters"]); + // One stream is lost; the mentions stream hears a burst meanwhile. + socket.receive(["CLOSED", "rosters", "error: busy"]); + // Signed, as the feed checks. + const sender = generateSecretKey(); + const burst = Array.from( + { length: 40 }, + (_, index) => + finalizeEvent( + { + kind: 9, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["h", "channel"], + ["p", pubkey], + ], + content: `hello ${index}`, + }, + sender, + ) as unknown as RelayEvent, + ); + for (const event of burst) socket.receive(["EVENT", "mentions", event]); + expect(f.ran).toHaveLength(0); + // Listening again: the burst waits for room rather than overflow. + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => { + f.runners.tick(); + expect(f.ran).toHaveLength(burst.length); + }); + expect( + new Set( + f.ran.map( + (delivery) => (delivery.trigger as { event: RelayEvent }).event.id, + ), + ), + ).toEqual(new Set(burst.map((event) => event.id))); + feed.stop(); + } finally { + vi.useRealTimers(); + vi.unstubAllGlobals(); + } +}); From 1d3047d1a6f6c2663f7da886638b792c3aac77d4 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 16 Oct 2026 15:18:23 -0400 Subject: [PATCH 05/17] docs(agent-host): say why the host keeps catch-up progress in memory Catch-up progress now has one key per agent, so the old reason, a shared value the app rewrote whole, no longer applies. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- src/agent-host/host.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/agent-host/host.ts b/src/agent-host/host.ts index 124cffb685..727d503339 100644 --- a/src/agent-host/host.ts +++ b/src/agent-host/host.ts @@ -233,10 +233,10 @@ export function host(options: HostOptions): () => Promise { identity.owner, ); const started = Math.floor(Date.now() / 1000); - // Catch-up progress, kept only while this host runs. The app saves every - // agent's progress under one key, rewritten whole from its own copy, so - // this host neither writes it nor catches up: as before catch-up, it hears - // what happens while it is connected. + // Catch-up progress, kept only while this host runs. This host does not + // catch up yet, so it neither reads nor writes the agent's saved progress + // (its own key in agent storage): as before catch-up, it hears what happens + // while it is connected. let checkpoints: Readonly> = {}; const runners = new AgentRunners({ ready: (agent) => (live && agent === pubkey ? agentView() : undefined), From 29d340c1fc056d8d2c2c92fdfdbf07b5c786ac58 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sun, 18 Oct 2026 00:45:30 -0400 Subject: [PATCH 06/17] fix(agent-host): answer a Solo chat at top level, from its label The buzz.solo label on channel metadata is the one Solo fact, which every member and agent reads. The host now reads it fresh for each turn, so an agent taken over from the desktop answers an existing Solo chat as one top-level conversation, and threads again once the chat becomes a channel. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- src/agent-host/host.ts | 9 +++++---- src/agent-host/reads.test.ts | 27 +++++++++++++++++++++++++++ src/agent-host/reads.ts | 22 +++++++++++++++++----- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/agent-host/host.ts b/src/agent-host/host.ts index 727d503339..c47036cc87 100644 --- a/src/agent-host/host.ts +++ b/src/agent-host/host.ts @@ -368,12 +368,13 @@ export function host(options: HostOptions): () => Promise { }, }, activity, - // As in its reads: only the owner's connection sees Solo chats, so a - // mention's turn, and its typing, keep the thread. - soloChat: async () => false, + // Typing goes where the turn answers, read the same way. + soloChat: (agent, channel, signal) => + reads.soloChat(agent.owner, channel, signal), remembered: (agent, slug, body) => coreMemory.wrote(agent, slug, body), finished: (agent) => void coreMemory.refresh(agent), - // Only the owner's connection sees Solo chats, so there is none to name. + // Naming a Solo chat is the owner's signed write; this host holds only + // the agent's key, so it leaves the name as it is. named: () => {}, }); function agentView() { diff --git a/src/agent-host/reads.test.ts b/src/agent-host/reads.test.ts index 831daf3a49..488c6a7805 100644 --- a/src/agent-host/reads.test.ts +++ b/src/agent-host/reads.test.ts @@ -105,3 +105,30 @@ it("does not trust a workflow message signed by another key", async () => { ), ).toBe(false); }); + +it("answers a Solo chat at top level from its label, and threads it again once converted", async () => { + const f = setup(); + const metadata = (tags: string[][]) => + signed(f.relay, { + kind: 39000, + content: "", + tags: [["d", "chat"], ["t", "stream"], ["private"], ...tags], + }); + const signal = new AbortController().signal; + const reads = agentReads(f.client, RELAY, async () => f.relay.pubkey); + const solo = () => reads.soloChat(f.owner.pubkey, "chat", signal); + + f.query.mockResolvedValueOnce([metadata([["t", "buzz.solo"]])]); + expect(await solo()).toBe(true); + expect(f.query).toHaveBeenLastCalledWith([ + { kinds: [39000], "#d": ["chat"], limit: 1 }, + ]); + // Converted to a channel: the label is gone, so mentions keep their threads. + f.query.mockResolvedValueOnce([metadata([])]); + expect(await solo()).toBe(false); + // An unknown channel is not Solo; a failed read fails rather than guess. + f.query.mockResolvedValueOnce([]); + expect(await solo()).toBe(false); + f.query.mockRejectedValueOnce(new Error("offline")); + await expect(solo()).rejects.toThrow("offline"); +}); diff --git a/src/agent-host/reads.ts b/src/agent-host/reads.ts index 9a8be41122..d51929455b 100644 --- a/src/agent-host/reads.ts +++ b/src/agent-host/reads.ts @@ -3,6 +3,7 @@ // what the agent may see. import type { BuzzClient, Filter } from "../buzz-mcp/client"; import type { AgentReads, ChannelFacts } from "../features/agents/reads"; +import { soloChannelMetadata } from "../features/relay/discovery"; import { type EventData, eventsDto, @@ -12,7 +13,7 @@ import { const tagOf = (event: EventData, name: string) => event.tags.find((tag) => tag[0] === name)?.[1]; -const newest = (events: readonly EventData[], kind: number) => +const newest = (events: readonly T[], kind: number) => events .filter((event) => event.kind === kind) .sort((a, b) => b.created_at - a.created_at)[0]; @@ -151,9 +152,20 @@ export function agentReads( entries: (await abortable(client.memories(slug), signal)).entries, partial: true, }), - // Solo chats are the owner's own filing, which only the owner's connection - // reads; as the agent, a mention keeps its thread, as in the app when the - // owner is not the one signed in. - soloChat: async () => false, + // The channel's Solo label decides, as for every member and agent + // (docs/solo/README.md). Read fresh for each turn, so a chat converted + // to a channel goes back to threads. + async soloChat(_owner: string, id: string, signal: AbortSignal) { + const metadata = newest( + eventsDto([ + ...(await abortable( + client.query([{ kinds: [39000], "#d": [id], limit: 1 }]), + signal, + )), + ]), + 39000, + ); + return !!metadata && soloChannelMetadata(metadata); + }, }); } From 9af7dd351fa93c9739e95074e12904770296619b Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sun, 18 Oct 2026 00:45:35 -0400 Subject: [PATCH 07/17] fix(agent-host): keep listening when the relay refuses an unreadable batch The relay refuses a channel batch only once the agent can read none of its channels. The feed stopped being live until the next connection, so mentions and timers in every other channel stopped. A refused batch no longer counts toward being live, and is asked for again when a roster shows the agent back in one of its channels. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- src/agent-host/feed.test.ts | 43 +++++++++++++++++++++++++++++++++++++ src/agent-host/feed.ts | 28 +++++++++++++++++++++--- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/agent-host/feed.test.ts b/src/agent-host/feed.test.ts index 44d853f31a..e05fc087d1 100644 --- a/src/agent-host/feed.test.ts +++ b/src/agent-host/feed.test.ts @@ -358,3 +358,46 @@ it("asks a stream it lost for what it missed, though other streams heard on", as expect(since("channels:0")).toBe(start + 270); feed.stop(); }); + +it("goes on hearing its other channels when the relay refuses a batch it can no longer read, and asks again once back in", () => { + const { feed, socket, pubkey, statuses, heard, sign, roster } = loggedIn(); + for (let index = 0; index <= CHANNEL_BATCH; index++) + socket.receive(["EVENT", "rosters", roster(`c${index}`, [pubkey])]); + socket.receive(["EOSE", latest(socket, "metadata:0")]); + socket.receive(["EOSE", latest(socket, "metadata:1")]); + expect(statuses.at(-1)).toBe(true); + // Removed from the second batch's only channel: the relay closes it. + socket.receive([ + "CLOSED", + latest(socket, "channels:1"), + "restricted: channel access revoked", + ]); + socket.receive([ + "CLOSED", + latest(socket, "metadata:1"), + "restricted: channel access revoked", + ]); + expect(statuses.at(-1)).toBe(true); + // A mention in the first batch is given on as it comes. + socket.receive([ + "EVENT", + latest(socket, "channels:0"), + sign( + 9, + [ + ["h", "c0"], + ["p", pubkey], + ], + "still here", + 150, + ), + ]); + expect(heard).toEqual(["still here:false"]); + // Added back: its batch is asked for again, and the feed waits for it. + const before = asked(socket).length; + socket.receive(["EVENT", "rosters", roster(`c${CHANNEL_BATCH}`, [pubkey])]); + expect(asked(socket).slice(before)).toEqual(["channels:1", "metadata:1"]); + socket.receive(["CLOSED", latest(socket, "channels:1"), "error: busy"]); + expect(statuses.at(-1)).toBe(false); + feed.stop(); +}); diff --git a/src/agent-host/feed.ts b/src/agent-host/feed.ts index a84fc48a60..ea5f3442c2 100644 --- a/src/agent-host/feed.ts +++ b/src/agent-host/feed.ts @@ -205,11 +205,18 @@ export class AgentFeed { send(["REQ", id, asked]); }; // Live while the relay keeps the mentions, rosters and every channel - // batch's subscription. + // batch's subscription it allows. The relay drops a channel the agent + // left from a batch's subscription, and refuses the batch only once the + // agent can read none of its channels: that batch has nothing to hear, + // so the others go on without it. const status = () => this.setLive( all() - .filter((key) => !key.startsWith("metadata:")) + .filter( + (key) => + !key.startsWith("metadata:") && + !(key.startsWith("channels:") && denied.has(key)), + ) .every(isOpen), ); /** Opens each subscription that is not open, refused or waiting to be @@ -346,7 +353,22 @@ export class AgentFeed { ), ], }); - if (!this.channels.has(id)) { + if (this.channels.has(id)) { + // Back in a channel of a batch the relay refused: ask for it again. + const index = Math.floor(this.order.indexOf(id) / CHANNEL_BATCH); + const key = `channels:${index}`; + if ( + subscribed && + denied.has(key) && + this.rosters.get(id)?.members.includes(this.options.pubkey) + ) { + for (const each of [key, `metadata:${index}`]) { + denied.delete(each); + ask(each); + } + status(); + } + } else { this.channels.add(id); this.order.push(id); this.loading.set(id, []); From 7bd33eedd1616e0a22784c459401da6e8ec7373f Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Sun, 18 Oct 2026 01:01:07 -0400 Subject: [PATCH 08/17] test(agent-host): count a killed, unreaped process as gone After stopAll kills a process group, an orphan that has exited can stay a zombie until its new parent reaps it, and signal 0 still finds it. The test now reads the process state, so it no longer fails on CI runners whose reaper is slow. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- src/agent-host/spawn.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/agent-host/spawn.test.ts b/src/agent-host/spawn.test.ts index 7d46aeced1..89640dc64c 100644 --- a/src/agent-host/spawn.test.ts +++ b/src/agent-host/spawn.test.ts @@ -1,12 +1,18 @@ // @vitest-environment node +import { execFileSync } from "node:child_process"; import { expect, it, vi } from "vitest"; import { processHost } from "./spawn"; +/** Whether the process still runs. A killed one whose new parent has not yet + * reaped it (a zombie, state `Z`) has exited, though signal 0 still finds it. */ const alive = (pid: number) => { try { - process.kill(pid, 0); - return true; + const state = execFileSync("ps", ["-o", "stat=", "-p", String(pid)], { + encoding: "utf8", + }).trim(); + return !!state && !state.startsWith("Z"); } catch { + // ps exits non-zero when no such process exists. return false; } }; From cf1251546e2d56c5b9cdd957d994ba8aa4bf22f5 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 19 Oct 2026 15:27:09 -0400 Subject: [PATCH 09/17] fix(agent-host): give a Solo chat's turns its owner's Canvas The app now hands each Solo chat turn the chat's Canvas: its standing instructions and the folders the agent works in. The standalone host gave its runtimes no Canvas reader, so after the desktop app closed, a Solo chat lost those instructions, Claude started without the chat's folders, and an edited or cleared Canvas never replaced the one a resumed conversation knew. The host now reads the chat's newest Canvas as the agent, for each turn, and hands it over only when the owner or an agent they attest wrote it, with the same check the app uses. No Canvas tells the agent that there is none; a failed read keeps what the turn knew. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- src/agent-host/reads.test.ts | 33 ++++++++++++++++++++++++++++++++- src/agent-host/reads.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/agent-host/reads.test.ts b/src/agent-host/reads.test.ts index 488c6a7805..8113b735cd 100644 --- a/src/agent-host/reads.test.ts +++ b/src/agent-host/reads.test.ts @@ -49,7 +49,7 @@ function setup() { ], }), ); - return { relay, owner, sibling, agent, client, query, mention }; + return { relay, owner, sibling, agent, attestation, client, query, mention }; } it("accepts a workflow mention from the owner's attested agent, as the app does", async () => { @@ -132,3 +132,34 @@ it("answers a Solo chat at top level from its label, and threads it again once c f.query.mockRejectedValueOnce(new Error("offline")); await expect(solo()).rejects.toThrow("offline"); }); + +it("hands over a Solo chat's Canvas only from its owner's side, and says when there is none", async () => { + const f = setup(); + const reads = agentReads(f.client, RELAY, async () => f.relay.pubkey); + const canvas = (author: ReturnType, tags: string[][] = []) => + signed(author, { + kind: 40100, + content: `# By ${author.pubkey.slice(0, 4)}`, + tags: [["h", "chat"], ...tags], + }); + const read = () => reads.canvas?.("chat", f.owner.pubkey); + + f.query.mockResolvedValueOnce([canvas(f.owner)]); + expect(await read()).toBe(`# By ${f.owner.pubkey.slice(0, 4)}`); + expect(f.query).toHaveBeenLastCalledWith([ + { kinds: [40100], "#h": ["chat"], limit: 1 }, + ]); + // The owner's attested agent writes for the owner. + f.query.mockResolvedValueOnce([canvas(f.sibling, [f.attestation])]); + expect(await read()).toBe(`# By ${f.sibling.pubkey.slice(0, 4)}`); + // A member's head is not the owner's instructions. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + f.query.mockResolvedValueOnce([canvas(keypair())]); + expect(await read()).toBeUndefined(); + warn.mockRestore(); + // No Canvas is a confirmed empty; a failed read rejects instead. + f.query.mockResolvedValueOnce([]); + expect(await read()).toBeUndefined(); + f.query.mockRejectedValueOnce(new Error("offline")); + await expect(read()).rejects.toThrow("offline"); +}); diff --git a/src/agent-host/reads.ts b/src/agent-host/reads.ts index d51929455b..4f6ad0adcc 100644 --- a/src/agent-host/reads.ts +++ b/src/agent-host/reads.ts @@ -2,6 +2,7 @@ // is made as the agent, through its own signed requests, so a turn sees only // what the agent may see. import type { BuzzClient, Filter } from "../buzz-mcp/client"; +import { ownerCanvas } from "../features/agent-harness/project"; import type { AgentReads, ChannelFacts } from "../features/agents/reads"; import { soloChannelMetadata } from "../features/relay/discovery"; import { @@ -167,5 +168,30 @@ export function agentReads( ); return !!metadata && soloChannelMetadata(metadata); }, + // A Solo chat's Canvas, read fresh for each turn as the app does, and + // handed over only when the owner or an agent they attest wrote it. A + // failed read rejects, so the turn keeps the Canvas it knew. + canvas: (id: string, owner: string) => + ownerCanvas( + { + available: true, + read: async (channel) => + eventsDto([ + ...(await client.query([ + { kinds: [40100], "#h": [channel], limit: 1 }, + ])), + ]) + .filter( + (event) => + event.kind === 40100 && tagOf(event, "h") === channel, + ) + .sort( + (a, b) => + b.created_at - a.created_at || a.id.localeCompare(b.id), + )[0], + }, + owner, + id, + ), }); } From f9abc74ef2b639cd2a630667b10964cd0b2df13c Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 19 Oct 2026 21:43:43 -0400 Subject: [PATCH 10/17] fix(agent-host): read a Solo chat's Canvas from the relay's writer A turn read its chat's Canvas with an ordinary query, which a relay with replica reads may answer from a replica that lacks the owner's newest edit or clear. Ask for it with consistency: strong, as the app's Canvas reads do; the relay's HTTP bridge then answers from its writer. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- src/agent-host/reads.test.ts | 2 +- src/agent-host/reads.ts | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/agent-host/reads.test.ts b/src/agent-host/reads.test.ts index 8113b735cd..d8a2c18e71 100644 --- a/src/agent-host/reads.test.ts +++ b/src/agent-host/reads.test.ts @@ -147,7 +147,7 @@ it("hands over a Solo chat's Canvas only from its owner's side, and says when th f.query.mockResolvedValueOnce([canvas(f.owner)]); expect(await read()).toBe(`# By ${f.owner.pubkey.slice(0, 4)}`); expect(f.query).toHaveBeenLastCalledWith([ - { kinds: [40100], "#h": ["chat"], limit: 1 }, + { kinds: [40100], "#h": ["chat"], limit: 1, consistency: "strong" }, ]); // The owner's attested agent writes for the owner. f.query.mockResolvedValueOnce([canvas(f.sibling, [f.attestation])]); diff --git a/src/agent-host/reads.ts b/src/agent-host/reads.ts index 4f6ad0adcc..90884929b8 100644 --- a/src/agent-host/reads.ts +++ b/src/agent-host/reads.ts @@ -170,7 +170,10 @@ export function agentReads( }, // A Solo chat's Canvas, read fresh for each turn as the app does, and // handed over only when the owner or an agent they attest wrote it. A - // failed read rejects, so the turn keeps the Canvas it knew. + // failed read rejects, so the turn keeps the Canvas it knew. The read is + // strong, as the app's Canvas reads are: the relay answers it from its + // writer, never from a replica that may not have the newest edit or clear + // yet. canvas: (id: string, owner: string) => ownerCanvas( { @@ -178,7 +181,12 @@ export function agentReads( read: async (channel) => eventsDto([ ...(await client.query([ - { kinds: [40100], "#h": [channel], limit: 1 }, + { + kinds: [40100], + "#h": [channel], + limit: 1, + consistency: "strong", + }, ])), ]) .filter( From 12a80ab426f3013cd7213abf07031c07302637dc Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 19 Oct 2026 21:45:17 -0400 Subject: [PATCH 11/17] fix(agent-host): hear a channel the agent joins or rejoins in any batch Two membership changes left a channel unheard. A new channel added to a batch the relay had refused was never asked for. And a rejoined channel whose batch was still open for its other channels was not asked for again: the relay drops a channel the agent leaves from the subscription without closing it, and the feed hears no roster that leaves the agent out, so it could not tell. Now a new channel, or a new roster of a channel the agent is in, asks for its batch again as it is now, even if the relay refused it. A roster heard again, as after a reconnect, asks for nothing. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- src/agent-host/feed.test.ts | 72 +++++++++++++++++++++++++++++++++++-- src/agent-host/feed.ts | 57 ++++++++++++++--------------- 2 files changed, 98 insertions(+), 31 deletions(-) diff --git a/src/agent-host/feed.test.ts b/src/agent-host/feed.test.ts index e05fc087d1..2ceb1194ff 100644 --- a/src/agent-host/feed.test.ts +++ b/src/agent-host/feed.test.ts @@ -192,8 +192,13 @@ function loggedIn() { const relay = generateSecretKey(); const sign = (kind: number, tags: string[][], content = "", at = 100) => finalizeEvent({ kind, created_at: at, tags, content }, relay); - const roster = (channel: string, members: string[]) => - sign(39002, [["d", channel], ...members.map((member) => ["p", member])]); + const roster = (channel: string, members: string[], at = 100) => + sign( + 39002, + [["d", channel], ...members.map((member) => ["p", member])], + "", + at, + ); return { feed, socket, pubkey, statuses, heard, sign, roster }; } @@ -395,9 +400,70 @@ it("goes on hearing its other channels when the relay refuses a batch it can no expect(heard).toEqual(["still here:false"]); // Added back: its batch is asked for again, and the feed waits for it. const before = asked(socket).length; - socket.receive(["EVENT", "rosters", roster(`c${CHANNEL_BATCH}`, [pubkey])]); + socket.receive([ + "EVENT", + "rosters", + roster(`c${CHANNEL_BATCH}`, [pubkey], 200), + ]); expect(asked(socket).slice(before)).toEqual(["channels:1", "metadata:1"]); socket.receive(["CLOSED", latest(socket, "channels:1"), "error: busy"]); expect(statuses.at(-1)).toBe(false); feed.stop(); }); + +it("hears a channel the agent joins after the relay refused its batch", () => { + const { feed, socket, pubkey, statuses, heard, sign, roster } = loggedIn(); + for (let index = 0; index <= CHANNEL_BATCH; index++) + socket.receive(["EVENT", "rosters", roster(`c${index}`, [pubkey])]); + socket.receive(["EOSE", latest(socket, "metadata:1")]); + // Removed from the last batch's only channel: the relay refuses it. + for (const key of ["channels:1", "metadata:1"]) + socket.receive([ + "CLOSED", + latest(socket, key), + "restricted: channel access revoked", + ]); + // A new channel joins that batch: it is asked for, and the feed waits. + const before = asked(socket).length; + socket.receive(["EVENT", "rosters", roster("new", [pubkey], 200)]); + expect(asked(socket).slice(before)).toEqual(["channels:1", "metadata:1"]); + const filter = socket.reqs().at(-2)?.[2] as Record; + expect(filter["#h"]).toEqual([`c${CHANNEL_BATCH}`, "new"]); + socket.receive(["EOSE", latest(socket, "metadata:1")]); + socket.receive([ + "EVENT", + latest(socket, "channels:1"), + sign(9, [["h", "new"]], "hello", 210), + ]); + expect(heard).toEqual(["hello:false"]); + expect(statuses.at(-1)).toBe(true); + feed.stop(); +}); + +it("asks again for an open batch when the agent is back in one of its channels", () => { + const { feed, socket, pubkey, heard, sign, roster } = loggedIn(); + socket.receive(["EVENT", "rosters", roster("a", [pubkey])]); + socket.receive(["EVENT", "rosters", roster("b", [pubkey])]); + socket.receive(["EOSE", latest(socket, "metadata:0")]); + // Removed from "a": the relay drops it from the batch and closes nothing, + // and sends no roster that leaves the agent out. Heard again, a roster + // already known asks for nothing. + const before = asked(socket).length; + socket.receive(["EVENT", "rosters", roster("a", [pubkey])]); + expect(asked(socket).slice(before)).toEqual([]); + // Back in "a": its batch is asked for again, as it is now. + const old = latest(socket, "channels:0"); + socket.receive(["EVENT", "rosters", roster("a", [pubkey], 300)]); + expect(asked(socket).slice(before)).toEqual(["channels:0", "metadata:0"]); + expect(socket.sent).toContainEqual(["CLOSE", old]); + const filter = socket.reqs().at(-2)?.[2] as Record; + expect(filter["#h"]).toEqual(["a", "b"]); + socket.receive(["EOSE", latest(socket, "metadata:0")]); + socket.receive([ + "EVENT", + latest(socket, "channels:0"), + sign(9, [["h", "a"]], "welcome back", 310), + ]); + expect(heard).toEqual(["welcome back:false"]); + feed.stop(); +}); diff --git a/src/agent-host/feed.ts b/src/agent-host/feed.ts index ea5f3442c2..364b0dbd2e 100644 --- a/src/agent-host/feed.ts +++ b/src/agent-host/feed.ts @@ -44,10 +44,10 @@ export class AgentFeed { readonly channels = new Set(); /** The same channels in the order they were found, which batches them. */ private readonly order: string[] = []; - /** Each channel's members, from its newest roster. */ + /** Each channel's members, from its newest roster (`id`). */ private readonly rosters = new Map< string, - { at: number; members: readonly string[] } + { at: number; id: string; members: readonly string[] } >(); /** Each channel's type (the first `t` tag of its newest metadata). */ private readonly types = new Map(); @@ -219,6 +219,16 @@ export class AgentFeed { ) .every(isOpen), ); + /** Asks again for the batch that hears `channel`, though the relay + * refused it or holds it open. */ + const reopen = (channel: string) => { + const index = Math.floor(this.order.indexOf(channel) / CHANNEL_BATCH); + for (const key of [`channels:${index}`, `metadata:${index}`]) { + denied.delete(key); + ask(key); + } + status(); + }; /** Opens each subscription that is not open, refused or waiting to be * asked again: all at first, and after a login the ones the relay closed * until the agent logged in. */ @@ -342,9 +352,13 @@ export class AgentFeed { if (from === "rosters") { const id = event.tags.find((tag) => tag[0] === "d")?.[1]; if (!id) return; - if (event.created_at >= (this.rosters.get(id)?.at ?? 0)) + const previous = this.rosters.get(id); + const changed = + event.created_at >= (previous?.at ?? 0) && event.id !== previous?.id; + if (changed) this.rosters.set(id, { at: event.created_at, + id: event.id, members: [ ...new Set( event.tags.flatMap((tag) => @@ -353,34 +367,21 @@ export class AgentFeed { ), ], }); - if (this.channels.has(id)) { - // Back in a channel of a batch the relay refused: ask for it again. - const index = Math.floor(this.order.indexOf(id) / CHANNEL_BATCH); - const key = `channels:${index}`; - if ( - subscribed && - denied.has(key) && - this.rosters.get(id)?.members.includes(this.options.pubkey) - ) { - for (const each of [key, `metadata:${index}`]) { - denied.delete(each); - ask(each); - } - status(); - } - } else { + if (!this.channels.has(id)) { this.channels.add(id); this.order.push(id); this.loading.set(id, []); - if (subscribed) { - // Only its batch is asked for again. - const index = Math.floor((this.order.length - 1) / CHANNEL_BATCH); - for (const key of [`channels:${index}`, `metadata:${index}`]) - if (!denied.has(key)) ask(key); - if (denied.has(`metadata:${index}`)) this.settle(id); - status(); - } - } + } else if ( + !changed || + !this.rosters.get(id)?.members.includes(this.options.pubkey) + ) + return; + // A new channel, or a new roster of one the agent is in, which may + // be its way back in: this subscription hears no roster that leaves + // the agent out, and the relay drops a channel the agent left from + // its batch's subscription without closing it, unless it was the + // batch's last. So its batch is asked for again, as it is now. + if (subscribed) reopen(id); return; } this.since = Math.max(this.since, event.created_at); From 277cec562cf795717212cdad7af0e1ca95467555 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 20 Oct 2026 14:48:41 -0400 Subject: [PATCH 12/17] fix(agent-host): sign shared notes, as the app now does Main's #1285 lets the app's agent custody sign kind 30023 so agents can publish shared notes. The host's list must stay equal to it, and the test that pins the two lists caught the difference after the rebase. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- src/agent-host/host.test.ts | 2 +- src/agent-host/host.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/agent-host/host.test.ts b/src/agent-host/host.test.ts index c69d87b3bf..0ff458a438 100644 --- a/src/agent-host/host.test.ts +++ b/src/agent-host/host.test.ts @@ -27,7 +27,7 @@ it("publishes the kinds the app's custody signs, and no others", () => { for (const kind of [9007, 9002, 20002]) expect(() => checkKind(kind)).not.toThrow(); expect(() => checkKind(0)).toThrow("Agents cannot publish kind 0"); - expect(() => checkKind(30023)).toThrow("Agents cannot publish kind 30023"); + expect(() => checkKind(1)).toThrow("Agents cannot publish kind 1"); }); it("resolves blank settings from the owner's saved defaults, as they are now", () => { diff --git a/src/agent-host/host.ts b/src/agent-host/host.ts index c47036cc87..c71428cac3 100644 --- a/src/agent-host/host.ts +++ b/src/agent-host/host.ts @@ -59,7 +59,8 @@ import { stateStorage } from "./storage"; * attestation: agent-controller app_agent.rs `KINDS`, which a test keeps this * list equal to. The relay decides which channel changes it may make. */ export const KINDS: readonly number[] = Object.freeze([ - 5, 7, 9, 9000, 9001, 9002, 9007, 9008, 9021, 9022, 20002, 40003, 40100, 41010, + 5, 7, 9, 9000, 9001, 9002, 9007, 9008, 9021, 9022, 20002, 30023, 40003, 40100, + 41010, ]); /** Throws for a kind the app's custody would not sign. */ export function checkKind(kind: number) { From 6587124e542469011d8978c249b43156c2a87117 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 23 Oct 2026 10:06:11 -0400 Subject: [PATCH 13/17] fix(agent-host): match main's runner and custody contracts Main replaced the runner's Jev hooks with decisions and keyStatus, added interrupted turns and gitAuth to the runner host, and lets custody sign repository announcements (30617). The host keeps interrupted turns in memory, as it keeps catch-up progress, and reports that Git access needs the desktop app. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- src/agent-host/host.ts | 24 ++++++++++++++++++++---- src/features/agents/runner.test.ts | 5 +++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/agent-host/host.ts b/src/agent-host/host.ts index c71428cac3..9304edd2e0 100644 --- a/src/agent-host/host.ts +++ b/src/agent-host/host.ts @@ -29,6 +29,7 @@ import { createLocalAgentActivity } from "../features/agents/local-activity"; import type { AgentReads } from "../features/agents/reads"; import { communityDestination } from "../features/communities/destination"; import type { Checkpoint } from "../features/agents/catch-up"; +import type { Interrupted } from "../features/agents/interrupted"; import { AgentRunners } from "../features/agents/runner"; import { type Agent, @@ -59,8 +60,8 @@ import { stateStorage } from "./storage"; * attestation: agent-controller app_agent.rs `KINDS`, which a test keeps this * list equal to. The relay decides which channel changes it may make. */ export const KINDS: readonly number[] = Object.freeze([ - 5, 7, 9, 9000, 9001, 9002, 9007, 9008, 9021, 9022, 20002, 30023, 40003, 40100, - 41010, + 5, 7, 9, 9000, 9001, 9002, 9007, 9008, 9021, 9022, 20002, 30023, 30617, 40003, + 40100, 41010, ]); /** Throws for a kind the app's custody would not sign. */ export function checkKind(kind: number) { @@ -239,6 +240,7 @@ export function host(options: HostOptions): () => Promise { // (its own key in agent storage): as before catch-up, it hears what happens // while it is connected. let checkpoints: Readonly> = {}; + let interrupted: Interrupted = {}; const runners = new AgentRunners({ ready: (agent) => (live && agent === pubkey ? agentView() : undefined), agent: (agent) => (agent === pubkey ? agentView() : undefined), @@ -259,6 +261,15 @@ export function host(options: HostOptions): () => Promise { checkpoints = next; }, }, + // Turns cut off by a stop, kept as checkpoints are: this host does not + // resume them when it starts again. + interrupted: { + read: () => interrupted, + write: (agent, turns) => { + interrupted = { ...interrupted, [agent]: turns }; + }, + changed: () => {}, + }, scope: () => scope, saved: (agent) => (agent === pubkey ? stored : undefined), record: (agent) => { @@ -307,8 +318,8 @@ export function host(options: HostOptions): () => Promise { // The classifier key is in the app's Keychain entry: no event watch here // is checked by a classifier, as in an app without one. classifier: () => "unavailable", - jev: () => undefined, - jevStatus: () => undefined, + decisions: () => undefined, + keyStatus: () => undefined, classifierKey: () => "missing", classifierKeyGeneration: () => 0, setClassifierKey: () => {}, @@ -361,6 +372,11 @@ export function host(options: HostOptions): () => Promise { mine(agent); return client.media(url); }, + // The app signs Git access with the agent's custody; this host does + // not yet. + gitAuth: async () => { + throw new Error("Git access needs Buzz Desktop."); + }, remember: async (agent, slug, body, after) => { mine(agent); return verified([ diff --git a/src/features/agents/runner.test.ts b/src/features/agents/runner.test.ts index b7ca8dd0d1..fc84b7e38c 100644 --- a/src/features/agents/runner.test.ts +++ b/src/features/agents/runner.test.ts @@ -68,6 +68,7 @@ function fixture(holds = true) { community: () => undefined, records: () => [record], checkpoints: { read: () => ({}), write: () => {} }, + interrupted: { read: () => ({}), write: () => {}, changed: () => {} }, scope: () => "scope", saved: () => record, record: () => record, @@ -80,8 +81,8 @@ function fixture(holds = true) { templates: () => [], tabs: () => undefined, classifier: () => "unavailable", - jev: () => undefined, - jevStatus: () => undefined, + decisions: () => undefined, + keyStatus: () => undefined, classifierKey: () => "missing", classifierKeyGeneration: () => 0, setClassifierKey: () => {}, From 5f5a427ae431463e9f1bff96e89e317fc642d0ba Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 23 Oct 2026 10:13:14 -0400 Subject: [PATCH 14/17] refactor(agent-host): hear the agent through the app's live client The host had its own relay subscription client (feed.ts). The app already has one: subscribeRelayTraffic in features/relay/live.ts, which the desktop's agents hear through. The host now runs that client, logged in as the agent with its owner's attestation, and the app's recovery owner retries what fails. What the host adds is small (listen.ts): it reads the agent's channels from its rosters, with their types for DMs, and gives them to the client's update(). It reads them again when the relay says the agent's membership changed, when a channel is denied, and at the client's periodic repair. Until the first read, membership is unknown, so the runner holds watched events, and releases them once the read is done. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- src/agent-host/feed.test.ts | 469 ----------------------------- src/agent-host/feed.ts | 431 -------------------------- src/agent-host/host.ts | 21 +- src/agent-host/listen.test.ts | 240 +++++++++++++++ src/agent-host/listen.ts | 284 +++++++++++++++++ src/agent-host/test-socket.ts | 111 ++++--- src/features/agents/runner.test.ts | 76 +++-- 7 files changed, 647 insertions(+), 985 deletions(-) delete mode 100644 src/agent-host/feed.test.ts delete mode 100644 src/agent-host/feed.ts create mode 100644 src/agent-host/listen.test.ts create mode 100644 src/agent-host/listen.ts diff --git a/src/agent-host/feed.test.ts b/src/agent-host/feed.test.ts deleted file mode 100644 index 2ceb1194ff..0000000000 --- a/src/agent-host/feed.test.ts +++ /dev/null @@ -1,469 +0,0 @@ -// @vitest-environment node -import { - finalizeEvent, - generateSecretKey, - getPublicKey, -} from "nostr-tools/pure"; -import { afterEach, expect, it, vi } from "vitest"; -import { AgentFeed, CHANNEL_BATCH } from "./feed"; -import { asked, FakeSocket, latest, loginId } from "./test-socket"; - -afterEach(() => { - vi.useRealTimers(); - vi.unstubAllGlobals(); -}); - -it("opens again the subscriptions a relay closed until a late login, and is not listening meanwhile", async () => { - vi.useFakeTimers(); - vi.stubGlobal("WebSocket", FakeSocket); - const secret = generateSecretKey(); - const statuses: boolean[] = []; - const feed = new AgentFeed({ - relay: "wss://relay.example.test", - secret, - pubkey: getPublicKey(secret), - onEvents: () => {}, - onStatus: (live) => statuses.push(live), - }); - feed.start(); - const socket = FakeSocket.last as FakeSocket; - socket.opened(); - // No login asked for in two seconds: it subscribes as it is. - await vi.advanceTimersByTimeAsync(2_000); - expect(socket.reqs().map((message) => message[1])).toEqual([ - "mentions", - "rosters", - ]); - expect(statuses.at(-1)).toBe(true); - // Then the relay asks for a login, and closes both until it has one. - socket.receive(["AUTH", "challenge"]); - socket.receive(["CLOSED", "mentions", "auth-required: log in first"]); - socket.receive(["CLOSED", "rosters", "auth-required: log in first"]); - expect(statuses.at(-1)).toBe(false); - socket.receive(["OK", loginId(socket), true, ""]); - expect(socket.reqs().map((message) => message[1])).toEqual([ - "mentions", - "rosters", - "mentions", - "rosters", - ]); - expect(statuses.at(-1)).toBe(true); - feed.stop(); -}); - -it("is not listening while the relay has closed its channel subscription", async () => { - vi.stubGlobal("WebSocket", FakeSocket); - const secret = generateSecretKey(); - const pubkey = getPublicKey(secret); - const statuses: boolean[] = []; - const feed = new AgentFeed({ - relay: "wss://relay.example.test", - secret, - pubkey, - onEvents: () => {}, - onStatus: (live) => statuses.push(live), - }); - feed.start(); - const socket = FakeSocket.last as FakeSocket; - socket.opened(); - socket.receive(["AUTH", "challenge"]); - socket.receive(["OK", loginId(socket), true, ""]); - expect(statuses.at(-1)).toBe(true); - // The relay's roster of a channel the agent is in. - const roster = finalizeEvent( - { - kind: 39002, - created_at: 100, - tags: [ - ["d", "channel-1"], - ["p", pubkey], - ], - content: "", - }, - generateSecretKey(), - ); - socket.receive(["EVENT", "rosters", roster]); - expect(asked(socket)).toEqual([ - "mentions", - "rosters", - "channels:0", - "metadata:0", - ]); - socket.receive(["CLOSED", latest(socket, "channels:0"), "error: too many"]); - expect(statuses.at(-1)).toBe(false); - feed.stop(); -}); - -it("knows a channel is a DM of the agent and one other from its roster and type", () => { - vi.stubGlobal("WebSocket", FakeSocket); - const secret = generateSecretKey(); - const pubkey = getPublicKey(secret); - const other = getPublicKey(generateSecretKey()); - const third = getPublicKey(generateSecretKey()); - const feed = new AgentFeed({ - relay: "wss://relay.example.test", - secret, - pubkey, - onEvents: () => {}, - onStatus: () => {}, - }); - feed.start(); - const socket = FakeSocket.last as FakeSocket; - socket.opened(); - socket.receive(["AUTH", "challenge"]); - socket.receive(["OK", loginId(socket), true, ""]); - const relay = generateSecretKey(); - const roster = (channel: string, at: number, members: string[]) => - finalizeEvent( - { - kind: 39002, - created_at: at, - tags: [["d", channel], ...members.map((member) => ["p", member])], - content: "", - }, - relay, - ); - const metadata = (channel: string, type: string) => - finalizeEvent( - { - kind: 39000, - created_at: 100, - tags: [ - ["d", channel], - ["t", type], - ["t", "label"], - ], - content: "", - }, - relay, - ); - socket.receive(["EVENT", "rosters", roster("dm", 100, [pubkey, other])]); - socket.receive(["EVENT", "rosters", roster("group", 100, [pubkey, other])]); - socket.receive(["EVENT", "rosters", roster("stream", 100, [pubkey, other])]); - // Its type is unknown until the channel's metadata arrives. - expect(feed.dm("dm")).toBe(false); - socket.receive(["EVENT", latest(socket, "metadata:0"), metadata("dm", "dm")]); - socket.receive([ - "EVENT", - latest(socket, "metadata:0"), - metadata("group", "dm"), - ]); - socket.receive([ - "EVENT", - latest(socket, "metadata:0"), - metadata("stream", "stream"), - ]); - expect(feed.dm("dm")).toBe(true); - expect(feed.dm("stream")).toBe(false); - // A newer roster with a third member makes it a group DM; an older one is ignored. - socket.receive([ - "EVENT", - "rosters", - roster("group", 200, [pubkey, other, third]), - ]); - socket.receive(["EVENT", "rosters", roster("group", 150, [pubkey, other])]); - expect(feed.dm("group")).toBe(false); - feed.stop(); -}); - -/** A feed logged in on a fake relay, and how to sign as the relay. */ -function loggedIn() { - vi.useFakeTimers(); - vi.stubGlobal("WebSocket", FakeSocket); - const secret = generateSecretKey(); - const pubkey = getPublicKey(secret); - const statuses: boolean[] = []; - const heard: string[] = []; - const feed = new AgentFeed({ - relay: "wss://relay.example.test", - secret, - pubkey, - onEvents: (batch) => { - for (const event of batch.events) - heard.push(`${event.content}:${feed.dm(batch.channelId ?? "")}`); - }, - onStatus: (live) => statuses.push(live), - }); - feed.start(); - const socket = FakeSocket.last as FakeSocket; - socket.opened(); - socket.receive(["AUTH", "challenge"]); - socket.receive(["OK", loginId(socket), true, ""]); - const relay = generateSecretKey(); - const sign = (kind: number, tags: string[][], content = "", at = 100) => - finalizeEvent({ kind, created_at: at, tags, content }, relay); - const roster = (channel: string, members: string[], at = 100) => - sign( - 39002, - [["d", channel], ...members.map((member) => ["p", member])], - "", - at, - ); - return { feed, socket, pubkey, statuses, heard, sign, roster }; -} - -it("holds a new channel's messages until its metadata says whether it is a DM", () => { - const { feed, socket, pubkey, heard, sign, roster } = loggedIn(); - const owner = getPublicKey(generateSecretKey()); - socket.receive(["EVENT", "rosters", roster("dm", [pubkey, owner])]); - // The owner's untagged message arrives before the channel's metadata. - const message = sign(9, [["h", "dm"]], "hello", 150); - socket.receive(["EVENT", latest(socket, "channels:0"), message]); - // Heard again after a reconnect's overlap: held once. - socket.receive(["EVENT", latest(socket, "channels:0"), message]); - expect(heard).toEqual([]); - socket.receive([ - "EVENT", - latest(socket, "metadata:0"), - sign(39000, [ - ["d", "dm"], - ["t", "dm"], - ]), - ]); - expect(heard).toEqual([]); - socket.receive(["EOSE", latest(socket, "metadata:0")]); - // Given on once the DM is known as one. - expect(heard).toEqual(["hello:true"]); - // Later messages in it go straight on. - socket.receive([ - "EVENT", - latest(socket, "channels:0"), - sign(9, [["h", "dm"]], "again", 160), - ]); - expect(heard).toEqual(["hello:true", "again:true"]); - feed.stop(); -}); - -it("gives a channel's held messages on, not as a DM, when the relay refuses its metadata", () => { - const { feed, socket, pubkey, heard, sign, roster } = loggedIn(); - socket.receive(["EVENT", "rosters", roster("room", [pubkey])]); - socket.receive([ - "EVENT", - latest(socket, "channels:0"), - sign(9, [["h", "room"]], "hi", 150), - ]); - socket.receive([ - "CLOSED", - latest(socket, "metadata:0"), - "restricted: not allowed", - ]); - expect(heard).toEqual(["hi:false"]); - feed.stop(); -}); - -it("asks again for a subscription the relay closed after the login, and is listening once it is open", async () => { - const { feed, socket, statuses } = loggedIn(); - expect(statuses.at(-1)).toBe(true); - socket.receive(["CLOSED", "mentions", "error: database error"]); - socket.receive(["CLOSED", "rosters", "error: database error"]); - expect(statuses.at(-1)).toBe(false); - await vi.advanceTimersByTimeAsync(999); - expect(asked(socket)).toEqual(["mentions", "rosters"]); - await vi.advanceTimersByTimeAsync(1); - expect(asked(socket)).toEqual(["mentions", "rosters", "mentions", "rosters"]); - expect(statuses.at(-1)).toBe(true); - // Closed again before it sent anything: the wait grows. - socket.receive(["CLOSED", "mentions", "error: database error"]); - await vi.advanceTimersByTimeAsync(1_000); - expect(asked(socket)).toHaveLength(4); - await vi.advanceTimersByTimeAsync(1_000); - expect(asked(socket)).toHaveLength(5); - expect(statuses.at(-1)).toBe(true); - feed.stop(); -}); - -it("does not ask again for a subscription the relay refuses", async () => { - const { feed, socket, statuses } = loggedIn(); - socket.receive(["CLOSED", "mentions", "restricted: not a member"]); - await vi.advanceTimersByTimeAsync(60_000); - expect(asked(socket)).toEqual(["mentions", "rosters"]); - expect(statuses.at(-1)).toBe(false); - feed.stop(); -}); - -it("hears channels in batches the relay accepts, asking again only for the batch that changed", () => { - const { feed, socket, pubkey, statuses, roster } = loggedIn(); - for (let index = 0; index < CHANNEL_BATCH; index++) - socket.receive(["EVENT", "rosters", roster(`c${index}`, [pubkey])]); - const filters = (key: string) => - socket - .reqs() - .filter((message) => String(message[1]).startsWith(`${key}:`)) - .map((message) => message[2] as Record); - expect(filters("channels:0").at(-1)?.["#h"]).toHaveLength(CHANNEL_BATCH); - const before = socket.reqs().length; - socket.receive(["EVENT", "rosters", roster("last", [pubkey])]); - // One more channel starts a second batch; the first is not asked again. - expect(asked(socket).slice(before)).toEqual(["channels:1", "metadata:1"]); - expect(filters("channels:1").at(-1)?.["#h"]).toEqual(["last"]); - expect(filters("metadata:1").at(-1)?.["#d"]).toEqual(["last"]); - for (const message of socket.reqs()) { - const filter = message[2] as Record; - expect((filter["#h"] ?? filter["#d"] ?? []).length).toBeLessThanOrEqual( - CHANNEL_BATCH, - ); - } - expect(statuses.at(-1)).toBe(true); - // A batch the relay closes leaves the feed not listening until it is back. - socket.receive(["CLOSED", latest(socket, "channels:1"), "error: busy"]); - expect(statuses.at(-1)).toBe(false); - feed.stop(); -}); - -it("gives on what open subscriptions heard while another was asked for again, once listening", async () => { - const { feed, socket, pubkey, statuses, heard, sign, roster } = loggedIn(); - socket.receive(["EVENT", "rosters", roster("room", [pubkey])]); - socket.receive(["EOSE", latest(socket, "metadata:0")]); - expect(statuses.at(-1)).toBe(true); - socket.receive(["CLOSED", "rosters", "error: busy"]); - expect(statuses.at(-1)).toBe(false); - // The channel's subscription is still open, but the host takes nothing - // while the feed is not listening. - socket.receive([ - "EVENT", - latest(socket, "channels:0"), - sign(9, [["h", "room"]], "meanwhile", 150), - ]); - expect(heard).toEqual([]); - const at = statuses.length; - await vi.advanceTimersByTimeAsync(1_000); - // Listening again, then given on. - expect(statuses.slice(at)).toEqual([true]); - expect(heard).toEqual(["meanwhile:false"]); - feed.stop(); -}); - -it("asks a stream it lost for what it missed, though other streams heard on", async () => { - const { feed, socket, pubkey, sign, roster } = loggedIn(); - const start = Math.floor(Date.now() / 1000); - for (let index = 0; index <= CHANNEL_BATCH; index++) - socket.receive(["EVENT", "rosters", roster(`c${index}`, [pubkey])]); - const since = (key: string) => - ( - socket - .reqs() - .find((message) => message[1] === latest(socket, key))?.[2] as - | { since?: number } - | undefined - )?.since; - socket.receive(["CLOSED", latest(socket, "channels:0"), "error: busy"]); - // The second batch hears on, well past the overlap. - socket.receive([ - "EVENT", - latest(socket, "channels:1"), - sign(9, [["h", `c${CHANNEL_BATCH}`]], "later", start + 300), - ]); - await vi.advanceTimersByTimeAsync(1_000); - // The first batch asks again from where it stopped. - expect(since("channels:0")).toBe(start - 30); - // Once it has sent what it missed, it hears on as the others do. - socket.receive(["EOSE", latest(socket, "channels:0")]); - socket.receive(["CLOSED", latest(socket, "channels:0"), "error: busy"]); - await vi.advanceTimersByTimeAsync(1_000); - expect(since("channels:0")).toBe(start + 270); - feed.stop(); -}); - -it("goes on hearing its other channels when the relay refuses a batch it can no longer read, and asks again once back in", () => { - const { feed, socket, pubkey, statuses, heard, sign, roster } = loggedIn(); - for (let index = 0; index <= CHANNEL_BATCH; index++) - socket.receive(["EVENT", "rosters", roster(`c${index}`, [pubkey])]); - socket.receive(["EOSE", latest(socket, "metadata:0")]); - socket.receive(["EOSE", latest(socket, "metadata:1")]); - expect(statuses.at(-1)).toBe(true); - // Removed from the second batch's only channel: the relay closes it. - socket.receive([ - "CLOSED", - latest(socket, "channels:1"), - "restricted: channel access revoked", - ]); - socket.receive([ - "CLOSED", - latest(socket, "metadata:1"), - "restricted: channel access revoked", - ]); - expect(statuses.at(-1)).toBe(true); - // A mention in the first batch is given on as it comes. - socket.receive([ - "EVENT", - latest(socket, "channels:0"), - sign( - 9, - [ - ["h", "c0"], - ["p", pubkey], - ], - "still here", - 150, - ), - ]); - expect(heard).toEqual(["still here:false"]); - // Added back: its batch is asked for again, and the feed waits for it. - const before = asked(socket).length; - socket.receive([ - "EVENT", - "rosters", - roster(`c${CHANNEL_BATCH}`, [pubkey], 200), - ]); - expect(asked(socket).slice(before)).toEqual(["channels:1", "metadata:1"]); - socket.receive(["CLOSED", latest(socket, "channels:1"), "error: busy"]); - expect(statuses.at(-1)).toBe(false); - feed.stop(); -}); - -it("hears a channel the agent joins after the relay refused its batch", () => { - const { feed, socket, pubkey, statuses, heard, sign, roster } = loggedIn(); - for (let index = 0; index <= CHANNEL_BATCH; index++) - socket.receive(["EVENT", "rosters", roster(`c${index}`, [pubkey])]); - socket.receive(["EOSE", latest(socket, "metadata:1")]); - // Removed from the last batch's only channel: the relay refuses it. - for (const key of ["channels:1", "metadata:1"]) - socket.receive([ - "CLOSED", - latest(socket, key), - "restricted: channel access revoked", - ]); - // A new channel joins that batch: it is asked for, and the feed waits. - const before = asked(socket).length; - socket.receive(["EVENT", "rosters", roster("new", [pubkey], 200)]); - expect(asked(socket).slice(before)).toEqual(["channels:1", "metadata:1"]); - const filter = socket.reqs().at(-2)?.[2] as Record; - expect(filter["#h"]).toEqual([`c${CHANNEL_BATCH}`, "new"]); - socket.receive(["EOSE", latest(socket, "metadata:1")]); - socket.receive([ - "EVENT", - latest(socket, "channels:1"), - sign(9, [["h", "new"]], "hello", 210), - ]); - expect(heard).toEqual(["hello:false"]); - expect(statuses.at(-1)).toBe(true); - feed.stop(); -}); - -it("asks again for an open batch when the agent is back in one of its channels", () => { - const { feed, socket, pubkey, heard, sign, roster } = loggedIn(); - socket.receive(["EVENT", "rosters", roster("a", [pubkey])]); - socket.receive(["EVENT", "rosters", roster("b", [pubkey])]); - socket.receive(["EOSE", latest(socket, "metadata:0")]); - // Removed from "a": the relay drops it from the batch and closes nothing, - // and sends no roster that leaves the agent out. Heard again, a roster - // already known asks for nothing. - const before = asked(socket).length; - socket.receive(["EVENT", "rosters", roster("a", [pubkey])]); - expect(asked(socket).slice(before)).toEqual([]); - // Back in "a": its batch is asked for again, as it is now. - const old = latest(socket, "channels:0"); - socket.receive(["EVENT", "rosters", roster("a", [pubkey], 300)]); - expect(asked(socket).slice(before)).toEqual(["channels:0", "metadata:0"]); - expect(socket.sent).toContainEqual(["CLOSE", old]); - const filter = socket.reqs().at(-2)?.[2] as Record; - expect(filter["#h"]).toEqual(["a", "b"]); - socket.receive(["EOSE", latest(socket, "metadata:0")]); - socket.receive([ - "EVENT", - latest(socket, "channels:0"), - sign(9, [["h", "a"]], "welcome back", 310), - ]); - expect(heard).toEqual(["welcome back:false"]); - feed.stop(); -}); diff --git a/src/agent-host/feed.ts b/src/agent-host/feed.ts deleted file mode 100644 index 364b0dbd2e..0000000000 --- a/src/agent-host/feed.ts +++ /dev/null @@ -1,431 +0,0 @@ -// The agent's live feed on a standalone host: one WebSocket to its community, -// logged in as the agent (NIP-42, carrying its owner's attestation), that -// hears what names the agent and everything in the channels it is in. It -// reconnects after a drop, and each subscription picks up from where it -// stopped hearing. -import { finalizeEvent } from "nostr-tools/pure"; -import { CHAT_KINDS } from "../features/agents/attention"; -import { eventsDto, type RelayEvent } from "../features/relay/events"; -import type { LiveBatch } from "../features/relay/incoming"; - -const KINDS = [...new Set([...CHAT_KINDS, 4, 5, 7, 40002])]; -const RETRY_MS = [1_000, 2_000, 5_000, 10_000, 30_000]; -/** Catch-up after a drop reaches back this far before the last event heard. */ -const OVERLAP_S = 30; -/** The most channels one request names: the relay refuses more - * (buzz-relay `MAX_EXPLICIT_CHANNEL_VALUES`), so channels are heard in - * batches of this many, each its own subscription. */ -export const CHANNEL_BATCH = 128; -/** The most events held for one channel while its metadata loads, and - * while the feed is not live. */ -const HELD_LIMIT = 500; -/** Why a relay closes a subscription that asking again will not change: it - * stays closed until the next connection. Other closes are tried again. */ -const DENIED = /^(restricted|blocked):/; - -export type FeedOptions = Readonly<{ - relay: string; - secret: Uint8Array; - pubkey: string; - /** The owner's NIP-OA `auth` tag. */ - auth?: readonly string[]; - /** Live events, one batch per event, with the event's channel. Only while - * the feed is live: ones heard meanwhile come once it is live again. Those, - * and what a request sends before its EOSE, come as a replay, which the - * runner takes as it has room. */ - onEvents(batch: LiveBatch): void; - /** Whether the feed is live now: the relay keeps its subscriptions open. */ - onStatus(live: boolean): void; - log?(message: string): void; -}>; - -export class AgentFeed { - /** The channels the relay lists the agent in. */ - readonly channels = new Set(); - /** The same channels in the order they were found, which batches them. */ - private readonly order: string[] = []; - /** Each channel's members, from its newest roster (`id`). */ - private readonly rosters = new Map< - string, - { at: number; id: string; members: readonly string[] } - >(); - /** Each channel's type (the first `t` tag of its newest metadata). */ - private readonly types = new Map(); - /** Channels whose metadata the relay has not finished sending, with the - * events heard in them meanwhile. Whether a channel is a DM decides how - * its messages wake the agent, so they wait until it is known. */ - private readonly loading = new Map(); - /** Events heard while the feed was not live, as while one subscription is - * asked for again and the others are still open. The host takes events - * only while it is listening, so they wait until then. */ - private paused: LiveBatch[] = []; - private live = false; - private socket: WebSocket | undefined; - /** The newest event heard: an open subscription has heard to here. */ - private since = Math.floor(Date.now() / 1000); - /** Where each closed subscription stopped hearing, until a new request - * for it has sent what it missed (EOSE). Others go on meanwhile, and move - * `since` past what it missed. */ - private readonly pinned = new Map(); - private attempt = 0; - private stopped = false; - private timer: ReturnType | undefined; - private cleanup: (() => void) | undefined; - constructor(private readonly options: FeedOptions) {} - - /** Whether the channel is a DM of exactly the agent and one other, by its - * roster and metadata. A group DM, or one either is unknown for, is not. */ - dm(channel: string) { - const members = this.rosters.get(channel)?.members; - return ( - this.types.get(channel)?.type === "dm" && - members?.length === 2 && - members.includes(this.options.pubkey) - ); - } - start() { - this.stopped = false; - this.connect(); - } - stop() { - this.stopped = true; - clearTimeout(this.timer); - this.cleanup?.(); - this.socket?.close(); - this.socket = undefined; - this.paused = []; - } - - private setLive(live: boolean) { - this.live = live; - this.options.onStatus(live); - if (!live) return; - const paused = this.paused; - this.paused = []; - for (const batch of paused) this.options.onEvents(batch); - } - /** Gives the event on now, or once the feed is live. */ - private deliver(batch: LiveBatch) { - if (this.live) return this.options.onEvents(batch); - // Heard again, as after a reconnect, it waits once. - const id = batch.events[0]?.id; - if (this.paused.some((paused) => paused.events[0]?.id === id)) return; - // Given on in a burst, which live delivery would drop past the agent's - // room. - this.paused.push({ ...batch, replay: true }); - if (this.paused.length > HELD_LIMIT) { - this.paused.shift(); - this.options.log?.("Dropped an event heard while not listening"); - } - } - /** Marks where `key`'s stream stopped, unless it already stopped. */ - private pin(key: string) { - if (!this.pinned.has(key)) this.pinned.set(key, this.since); - } - - private connect() { - const { relay, log } = this.options; - const socket = new WebSocket(relay); - this.socket = socket; - let authed = false; - let subscribed = false; - let serial = 0; - /** Each subscription key's latest request id, and the key of each id. */ - const current = new Map(); - const keys = new Map(); - /** The ids the relay has not closed. */ - const open = new Set(); - /** The ids whose stored events the relay is still sending (no EOSE). */ - const stored = new Set(); - /** The channels each metadata request settles once the relay has sent - * what it has (EOSE). */ - const settles = new Map(); - /** Keys the relay refused, until the next connection. */ - const denied = new Set(); - /** Keys waiting to be asked again, with their failures so far. */ - const retries = new Map< - string, - { attempt: number; timer?: ReturnType } - >(); - this.cleanup = () => { - for (const retry of retries.values()) clearTimeout(retry.timer); - retries.clear(); - }; - const send = (message: unknown[]) => socket.send(JSON.stringify(message)); - const batches = () => Math.ceil(this.order.length / CHANNEL_BATCH); - const batch = (index: number) => - this.order.slice(index * CHANNEL_BATCH, (index + 1) * CHANNEL_BATCH); - const all = () => [ - "mentions", - "rosters", - ...Array.from({ length: batches() }, (_, index) => [ - `channels:${index}`, - `metadata:${index}`, - ]).flat(), - ]; - const isOpen = (key: string) => { - const id = current.get(key); - return !!id && open.has(id); - }; - const filter = (key: string): Record => { - const since = Math.max( - 0, - (this.pinned.get(key) ?? this.since) - OVERLAP_S, - ); - const [kind, index] = key.split(":"); - if (kind === "mentions") - return { kinds: KINDS, "#p": [this.options.pubkey], since }; - if (kind === "rosters") - return { kinds: [39002], "#p": [this.options.pubkey] }; - const channels = batch(Number(index)); - return kind === "channels" - ? { kinds: KINDS, "#h": channels, since } - : { kinds: [39000], "#d": channels }; - }; - /** Asks for `key` as it is now, replacing an earlier request for it. */ - const ask = (key: string) => { - const old = current.get(key); - if (old && open.delete(old)) send(["CLOSE", old]); - if (old) { - keys.delete(old); - settles.delete(old); - } - const waiting = retries.get(key); - if (waiting?.timer) { - clearTimeout(waiting.timer); - retries.set(key, { attempt: waiting.attempt }); - } - const id = key.includes(":") ? `${key}:${++serial}` : key; - current.set(key, id); - keys.set(id, key); - open.add(id); - stored.add(id); - const asked = filter(key); - if (key.startsWith("metadata:")) settles.set(id, asked["#d"] as string[]); - send(["REQ", id, asked]); - }; - // Live while the relay keeps the mentions, rosters and every channel - // batch's subscription it allows. The relay drops a channel the agent - // left from a batch's subscription, and refuses the batch only once the - // agent can read none of its channels: that batch has nothing to hear, - // so the others go on without it. - const status = () => - this.setLive( - all() - .filter( - (key) => - !key.startsWith("metadata:") && - !(key.startsWith("channels:") && denied.has(key)), - ) - .every(isOpen), - ); - /** Asks again for the batch that hears `channel`, though the relay - * refused it or holds it open. */ - const reopen = (channel: string) => { - const index = Math.floor(this.order.indexOf(channel) / CHANNEL_BATCH); - for (const key of [`channels:${index}`, `metadata:${index}`]) { - denied.delete(key); - ask(key); - } - status(); - }; - /** Opens each subscription that is not open, refused or waiting to be - * asked again: all at first, and after a login the ones the relay closed - * until the agent logged in. */ - const subscribe = () => { - subscribed = true; - for (const key of all()) - if (!isOpen(key) && !denied.has(key) && !retries.get(key)?.timer) - ask(key); - status(); - }; - /** Asks for `key` again after a wait that grows with each failure. */ - const retry = (key: string) => { - const attempt = retries.get(key)?.attempt ?? 0; - const wait = RETRY_MS[Math.min(attempt, RETRY_MS.length - 1)] ?? 30_000; - const timer = setTimeout(() => { - retries.set(key, { attempt: attempt + 1 }); - if (this.socket === socket && socket.readyState === WebSocket.OPEN) - if (!isOpen(key)) ask(key); - status(); - }, wait); - retries.set(key, { attempt: attempt + 1, timer }); - log?.(`Asking for ${key} again in ${wait / 1000}s`); - }; - socket.addEventListener("open", () => { - log?.(`Connected to ${relay}`); - // A relay that asks for no login hears the subscriptions as they are. - setTimeout(() => { - if (!authed && socket.readyState === WebSocket.OPEN) subscribe(); - }, 2_000); - }); - socket.addEventListener("message", (message) => { - let data: unknown[]; - try { - data = JSON.parse(String(message.data)) as unknown[]; - } catch { - return; - } - const [type] = data; - if (type === "AUTH" && typeof data[1] === "string") { - const event = finalizeEvent( - { - kind: 22242, - created_at: Math.floor(Date.now() / 1000), - content: "", - tags: [ - ["relay", relay], - ["challenge", data[1]], - ...(this.options.auth ? [[...this.options.auth]] : []), - ], - }, - this.options.secret, - ); - send(["AUTH", event]); - return; - } - if (type === "OK" && !authed) { - if (data[2] === true) { - authed = true; - this.attempt = 0; - subscribe(); - } else log?.(`Login refused: ${String(data[3])}`); - return; - } - if (type === "EOSE") { - const id = String(data[1]); - stored.delete(id); - const key = keys.get(id); - if (key) { - retries.delete(key); - // Its missed events are sent: it hears on from here. - this.pinned.delete(key); - } - for (const channel of settles.get(id) ?? []) this.settle(channel); - settles.delete(id); - return; - } - if (type === "CLOSED") { - const id = String(data[1]); - const reason = String(data[2] ?? ""); - const key = keys.get(id); - // One replaced since, or not this feed's. - if (!key || current.get(key) !== id) return; - log?.(`Subscription ${key} closed: ${reason}`); - open.delete(id); - this.pin(key); - if (!authed && reason.startsWith("auth-required:")) { - // Opens again once the agent has logged in. - } else if (DENIED.test(reason)) { - denied.add(key); - // Its channels' facts will not come on this connection: their - // events go on as in channels that are not DMs. - for (const channel of settles.get(id) ?? []) this.settle(channel); - settles.delete(id); - } else retry(key); - status(); - return; - } - if (type === "NOTICE") { - log?.(`Notice: ${String(data[1])}`); - return; - } - if (type !== "EVENT") return; - let event: RelayEvent; - try { - [event] = eventsDto([data[2]]) as [RelayEvent]; - } catch { - return; - } - const from = keys.get(String(data[1])) ?? ""; - if (from.startsWith("metadata:")) { - const id = event.tags.find((tag) => tag[0] === "d")?.[1]; - if (id && event.created_at >= (this.types.get(id)?.at ?? 0)) { - const type = event.tags.find((tag) => tag[0] === "t")?.[1]; - this.types.set(id, { - at: event.created_at, - ...(type ? { type } : {}), - }); - } - return; - } - if (from === "rosters") { - const id = event.tags.find((tag) => tag[0] === "d")?.[1]; - if (!id) return; - const previous = this.rosters.get(id); - const changed = - event.created_at >= (previous?.at ?? 0) && event.id !== previous?.id; - if (changed) - this.rosters.set(id, { - at: event.created_at, - id: event.id, - members: [ - ...new Set( - event.tags.flatMap((tag) => - tag[0] === "p" && tag[1] ? [tag[1]] : [], - ), - ), - ], - }); - if (!this.channels.has(id)) { - this.channels.add(id); - this.order.push(id); - this.loading.set(id, []); - } else if ( - !changed || - !this.rosters.get(id)?.members.includes(this.options.pubkey) - ) - return; - // A new channel, or a new roster of one the agent is in, which may - // be its way back in: this subscription hears no roster that leaves - // the agent out, and the relay drops a channel the agent left from - // its batch's subscription without closing it, unless it was the - // batch's last. So its batch is asked for again, as it is now. - if (subscribed) reopen(id); - return; - } - this.since = Math.max(this.since, event.created_at); - const channelId = event.tags.find((tag) => tag[0] === "h")?.[1]; - const live: LiveBatch = { - events: [event], - ...(channelId ? { channelId } : {}), - // What it missed, as after a drop: given as it has room. - ...(stored.has(String(data[1])) ? { replay: true as const } : {}), - }; - const held = channelId && this.loading.get(channelId); - if (!held) { - this.deliver(live); - return; - } - // Heard again, as after a reconnect, it is held once. - if (held.some((batch) => batch.events[0]?.id === event.id)) return; - held.push(live); - if (held.length > HELD_LIMIT) { - held.shift(); - log?.(`Dropped an event from ${channelId} while its type loads`); - } - }); - socket.addEventListener("close", () => { - if (this.socket === socket) this.socket = undefined; - for (const retry of retries.values()) clearTimeout(retry.timer); - retries.clear(); - // Each open one stopped here; a closed one stopped earlier. - for (const key of current.keys()) this.pin(key); - this.setLive(false); - if (this.stopped) return; - const wait = - RETRY_MS[Math.min(this.attempt++, RETRY_MS.length - 1)] ?? 30_000; - log?.(`Disconnected; trying again in ${wait / 1000}s`); - this.timer = setTimeout(() => this.connect(), wait); - }); - socket.addEventListener("error", () => {}); - } - /** The channel's facts are known, or will not come: the events held for - * it go on, in the order heard. */ - private settle(channel: string) { - const held = this.loading.get(channel); - if (!held) return; - this.loading.delete(channel); - for (const batch of held) this.deliver(batch); - } -} diff --git a/src/agent-host/host.ts b/src/agent-host/host.ts index 9304edd2e0..d3187c9fa4 100644 --- a/src/agent-host/host.ts +++ b/src/agent-host/host.ts @@ -50,8 +50,8 @@ import { import { eventsDto, type RelayEvent } from "../features/relay/events"; import { relayPartition } from "../features/relay/partition"; import { Claims } from "./claim"; -import { AgentFeed } from "./feed"; import { checkSaved, type Identity, readNames } from "./identity"; +import { listen } from "./listen"; import { agentReads } from "./reads"; import { type Declared, processHost } from "./spawn"; import { stateStorage } from "./storage"; @@ -192,21 +192,23 @@ export function host(options: HostOptions): () => Promise { // from the record the one before it kept, and its timers wait for them. let changes: Promise = Promise.resolve(); let changing = 0; - const feed = new AgentFeed({ + const listener = listen({ relay: identity.relay, secret, pubkey, ...(identity.auth ? { auth: JSON.parse(identity.auth) as string[] } : {}), - // A replay (what the feed heard while not listening, or what a request - // sent before its EOSE) waits for the agent's room; of it, only what - // happened since this host started is heard. + query: async (filters) => verified(await client.query(filters)), + // As the app's agents hear its session: a replay (the recent history a + // subscription sends again before it goes live) is heard only for what + // happened since this host started. onEvents: (batch) => runners.dispatch(batch, () => true, started), onStatus: (now) => { - if (now === live) return; live = now; log(now ? "Listening" : "Not listening"); runners.resume(new Set(now ? [pubkey] : [])); }, + // A roster the relay confirms decides the events held for it. + onChannels: () => runners.releaseHeld(() => true), log, }); // Whether the app deleted, erased or changed the agent: then this host @@ -246,11 +248,11 @@ export function host(options: HostOptions): () => Promise { agent: (agent) => (agent === pubkey ? agentView() : undefined), member: (_, event) => { const channel = event.tags.find((tag) => tag[0] === "h")?.[1]; - return !!channel && feed.channels.has(channel); + return channel ? listener.member(channel) : false; }, dm: (_, event) => { const channel = event.tags.find((tag) => tag[0] === "h")?.[1]; - return !!channel && feed.dm(channel); + return !!channel && listener.dm(channel); }, // No catch-up: see `checkpoints`. community: () => undefined, @@ -403,7 +405,6 @@ export function host(options: HostOptions): () => Promise { new Map([[identity.type, run.type]]), ); run.sync([agentView()]); - feed.start(); const tick = setInterval(() => { if (gone || isGone()) return; try { @@ -421,7 +422,7 @@ export function host(options: HostOptions): () => Promise { async function stopHosting() { clearInterval(tick); - feed.stop(); + listener.stop(); runners.clear(); // Only once its processes have exited may another copy run the agent. await run.dispose(); diff --git a/src/agent-host/listen.test.ts b/src/agent-host/listen.test.ts new file mode 100644 index 0000000000..b3928a46ca --- /dev/null +++ b/src/agent-host/listen.test.ts @@ -0,0 +1,240 @@ +// @vitest-environment node +import { + finalizeEvent, + generateSecretKey, + getPublicKey, + verifyEvent, +} from "nostr-tools/pure"; +import { afterEach, expect, it, vi } from "vitest"; +import type { Filter } from "../buzz-mcp/client"; +import type { RelayEvent } from "../features/relay/events"; +import type { LiveBatch } from "../features/relay/incoming"; +import { listen, readChannels } from "./listen"; +import { LiveSocket } from "./test-socket"; + +afterEach(() => vi.useRealTimers()); + +const secret = generateSecretKey(); +const pubkey = getPublicKey(secret); +const owner = getPublicKey(generateSecretKey()); +const someone = generateSecretKey(); +const auth = ["auth", owner, "", "f".repeat(128)]; + +const sign = ( + kind: number, + tags: string[][], + key = someone, + at = Math.floor(Date.now() / 1000), +) => + finalizeEvent( + { kind, created_at: at, tags, content: "" }, + key, + ) as unknown as RelayEvent; +const roster = (channel: string, members: readonly string[]) => + sign(39002, [["d", channel], ...members.map((member) => ["p", member])]); +const metadata = (channel: string, type: string) => + sign(39000, [ + ["d", channel], + ["t", type], + ]); + +/** A relay whose reads answer from `events` as the relay filters them. */ +function relay(events: () => readonly RelayEvent[]) { + const reads: Filter[][] = []; + const query = vi.fn(async (filters: readonly Filter[]) => { + reads.push([...filters]); + return events().filter((event) => + filters.some((filter) => { + const f = filter as Record; + const has = (name: string) => { + const want = f[`#${name}`] as string[] | undefined; + return ( + !want || + event.tags.some( + ([key, value]) => key === name && !!value && want.includes(value), + ) + ); + }; + return ( + (f.kinds as number[]).includes(event.kind) && + has("p") && + has("d") && + // A system channel's roster comes only to a read that asks for it. + (f["#t"] + ? has("t") + : event.kind !== 39002 || + !event.tags.some((tag) => tag[1] === "system")) + ); + }), + ); + }); + return { query, reads }; +} + +function host(events: () => readonly RelayEvent[]) { + const sockets: LiveSocket[] = []; + const batches: LiveBatch[] = []; + const statuses: boolean[] = []; + const channels = vi.fn(); + const { query, reads } = relay(events); + const listener = listen({ + relay: "wss://relay.example.test", + secret, + pubkey, + auth, + query, + onEvents: (batch) => batches.push(batch), + onStatus: (live) => statuses.push(live), + onChannels: channels, + log: () => {}, + socket: () => { + const socket = new LiveSocket(); + sockets.push(socket); + return socket as unknown as WebSocket; + }, + }); + const socket = () => sockets.at(-1) as LiveSocket; + return { listener, socket, batches, statuses, channels, query, reads }; +} + +it("logs in as the agent with its owner's attestation, and hears the channels its rosters list", async () => { + const events = [ + roster("general", [owner, pubkey]), + metadata("general", "stream"), + roster("chat", [owner, pubkey]), + metadata("chat", "dm"), + roster("elsewhere", [owner]), + ]; + const h = host(() => events); + // Until its channels are read, membership is unknown, so a watch waits. + expect(h.listener.member("general")).toBeUndefined(); + await vi.waitFor(() => expect(h.channels).toHaveBeenCalledOnce()); + expect(h.listener.member("general")).toBe(true); + expect(h.listener.member("elsewhere")).toBe(false); + expect(h.listener.dm("chat")).toBe(true); + expect(h.listener.dm("general")).toBe(false); + + const login = await h.socket().auth(); + expect(login.pubkey).toBe(pubkey); + expect(verifyEvent(login as never)).toBe(true); + expect(login.tags).toContainEqual(auth); + expect(h.statuses).toEqual([true]); + expect(h.socket().channels()).toEqual(["chat", "general"]); + h.listener.stop(); +}); + +it("gives on the client's live events, and its replays marked as such", async () => { + const events = [roster("general", [owner, pubkey])]; + const h = host(() => events); + await vi.waitFor(() => expect(h.channels).toHaveBeenCalled()); + await h.socket().auth(); + const [wire] = h.socket().channelWires(); + const older = sign(9, [ + ["h", "general"], + ["p", pubkey], + ]); + const newer = sign(9, [ + ["h", "general"], + ["p", pubkey], + ]); + await h.socket().receive(["EVENT", wire, older]); + await h.socket().receive(["EOSE", wire]); + await h.socket().receive(["EVENT", wire, newer]); + expect( + h.batches.map(({ events, ...batch }) => ({ + ...batch, + ids: events.map((event) => event.id), + })), + ).toEqual([ + { channelId: "general", replay: true, ids: [older.id] }, + { channelId: "general", ids: [newer.id] }, + ]); + h.listener.stop(); +}); + +it("reads its channels again when the relay says its membership changed, and hears the new one", async () => { + const events = [roster("general", [owner, pubkey])]; + const h = host(() => events); + await vi.waitFor(() => expect(h.channels).toHaveBeenCalledOnce()); + await h.socket().auth(); + expect(h.socket().channels()).toEqual(["general"]); + events.push(roster("new", [owner, pubkey]), metadata("new", "dm")); + const notice = sign(44100, [ + ["p", pubkey], + ["h", "new"], + ]); + const membership = h.socket().wire("membership"); + await h.socket().receive(["EOSE", membership]); + await h.socket().receive(["EVENT", membership, notice]); + await vi.waitFor(() => expect(h.channels).toHaveBeenCalledTimes(2)); + expect(h.listener.member("new")).toBe(true); + expect(h.listener.dm("new")).toBe(true); + expect(h.socket().channels()).toContain("new"); + h.listener.stop(); +}); + +it("stops hearing a channel the relay denies, until a read lists it again", async () => { + const events = [ + roster("general", [owner, pubkey]), + roster("gone", [owner, pubkey]), + ]; + const h = host(() => events); + await vi.waitFor(() => expect(h.channels).toHaveBeenCalledOnce()); + await h.socket().auth(); + events.splice(1, 1); + const [wire] = h.socket().channelWires(); + await h + .socket() + .receive(["CLOSED", wire, "restricted: not a channel member"]); + // The relay refused the batch both were heard in. + expect(h.listener.member("gone")).toBe(false); + expect(h.listener.member("general")).toBe(false); + await vi.waitFor(() => expect(h.listener.member("general")).toBe(true)); + expect(h.listener.member("gone")).toBe(false); + expect(h.listener.member("general")).toBe(true); + expect(h.socket().channels()).toEqual(["general"]); + h.listener.stop(); +}); + +it("keeps the last channels it read when a read fails, and the app's recovery reads again", async () => { + vi.useFakeTimers(); + let fail = false; + const events = [roster("general", [owner, pubkey])]; + const h = host(() => { + if (fail) throw new Error("busy"); + return events; + }); + await vi.advanceTimersByTimeAsync(0); + expect(h.channels).toHaveBeenCalledOnce(); + await h.socket().auth(); + await h.socket().established(); + fail = true; + // The client's periodic repair asks for a read, which fails. + await vi.advanceTimersByTimeAsync(60_000); + expect(h.listener.member("general")).toBe(true); + const reads = h.query.mock.calls.length; + fail = false; + await vi.advanceTimersByTimeAsync(1_000); + expect(h.query.mock.calls.length).toBeGreaterThan(reads); + expect(h.channels).toHaveBeenCalledTimes(2); + h.listener.stop(); +}); + +it("reads system channels through their opt-in filter, and only rosters that list the agent", async () => { + const events = [ + roster("general", [owner, pubkey]), + sign(39002, [ + ["d", "notices"], + ["t", "system"], + ["p", pubkey], + ]), + sign(39000, [ + ["d", "notices"], + ["t", "system"], + ]), + ]; + const { query } = relay(() => events); + const channels = await readChannels(query, pubkey); + expect([...channels.keys()].sort()).toEqual(["general", "notices"]); + expect(channels.get("notices")?.type).toBe("system"); +}); diff --git a/src/agent-host/listen.ts b/src/agent-host/listen.ts new file mode 100644 index 0000000000..c7e8ec2959 --- /dev/null +++ b/src/agent-host/listen.ts @@ -0,0 +1,284 @@ +// The agent's live events on a standalone host, heard as the app hears its +// own: through the app's live client (`subscribeRelayTraffic`), logged in as +// the agent with its owner's attestation. The app's session gives that client +// the channels its viewer is in; here the host reads the agent's from the +// relay, and reads them again when the relay says its membership changed, +// when a channel is denied, and each time the client asks for its periodic +// repair. The app's recovery owner retries what fails. +import type { EventTemplate, VerifiedEvent } from "nostr-tools"; +import { finalizeEvent } from "nostr-tools/pure"; +import type { Filter } from "../buzz-mcp/client"; +import type { RelayEvent } from "../features/relay/events"; +import type { LiveBatch } from "../features/relay/incoming"; +import { + type LiveSnapshot, + liveChannels, + subscribeRelayTraffic, +} from "../features/relay/live"; +import { liveRecoveryStatus } from "../features/relay/live-recovery"; +import { createRecovery } from "../features/relay/recovery"; + +/** One read's page size: the most the relay returns for one query. */ +const PAGE = 500; +/** The most channel ids one request names (buzz-relay + * `MAX_EXPLICIT_CHANNEL_VALUES`). */ +const ID_BATCH = 128; +/** The most channels the live client hears (`liveChannels`). */ +const MAX_CHANNELS = 1024; +const CHANNEL_ID = /^[a-zA-Z0-9_-]{1,128}$/; +/** Channel metadata and rosters, and the relay's membership notices. */ +const MEMBERSHIP_KINDS = [39000, 39002, 44100, 44101]; + +type Channel = Readonly<{ members: readonly string[]; type?: string }>; + +export type ListenOptions = Readonly<{ + relay: string; + secret: Uint8Array; + pubkey: string; + /** The owner's NIP-OA `auth` tag. */ + auth?: readonly string[]; + /** Reads the relay as the agent. */ + query(filters: readonly Filter[]): Promise; + /** Live events, and the recent history a subscription sends again before + * it goes live (`replay`), as the app's session gives its agents. */ + onEvents(batch: LiveBatch): void; + /** Whether the live connection is up. */ + onStatus(live: boolean): void; + /** The agent's channels were read again: events held for a roster may now + * match. */ + onChannels(): void; + log(message: string): void; + socket?: (url: string) => WebSocket; +}>; + +export type Listener = Readonly<{ + /** Whether the agent is in the channel, or undefined until its channels + * have been read once. */ + member(channel: string): boolean | undefined; + /** Whether the channel is a DM of exactly the agent and one other. */ + dm(channel: string): boolean; + stop(): void; +}>; + +const tagOf = (event: RelayEvent, name: string) => + event.tags.find((tag) => tag[0] === name)?.[1]; + +export function listen(options: ListenOptions): Listener { + const { pubkey, log } = options; + let channels: ReadonlyMap | undefined; + let roster: Readonly<{ state: string; transient?: true }> = { + state: "pending", + }; + let snapshot: LiveSnapshot = { status: "connecting", routes: [] }; + let live = false; + let connected = 0; + let stopped = false; + let reading: Promise | undefined; + let again = false; + const listeners = new Set<() => void>(); + const changed = () => { + for (const listener of [...listeners]) listener(); + }; + + const sign = async (template: EventTemplate) => + finalizeEvent( + { + ...template, + tags: + template.kind === 22242 && options.auth + ? [...template.tags, [...options.auth]] + : template.tags, + }, + options.secret, + ) as VerifiedEvent; + + const traffic = subscribeRelayTraffic( + options.relay, + sign, + pubkey, + { + receive(events, provenance) { + if (stopped) return; + // The relay's word that the agent's membership changed, or a new + // roster or type of one of its channels: read its channels again. + // A replay's are older than the read each connection starts with. + if ( + provenance?.phase === "live" && + events.some((event) => MEMBERSHIP_KINDS.includes(event.kind)) + ) + refresh(); + options.onEvents( + Object.freeze({ + events, + ...(provenance?.channelId + ? { channelId: provenance.channelId } + : {}), + ...(provenance?.phase === "replay" + ? { replay: true as const } + : {}), + }), + ); + }, + state(next) { + if (stopped) return; + if (next.status === "error" && snapshot.status !== "error") + log(`Not listening: ${next.error ?? "live connection failed"}`); + snapshot = next; + const now = next.status === "connected"; + if (now !== live) { + live = now; + options.onStatus(now); + // What changed while it was down; the first read is under way. + if (now && connected++) refresh(); + } + changed(); + }, + established() {}, + recover: () => refresh(), + denied(channel, reason) { + log(`Lost ${channel}: ${reason}`); + if (!channels?.has(channel)) return; + const next = new Map(channels); + next.delete(channel); + apply(next); + refresh(); + }, + }, + options.socket, + ); + + // The app's recovery owner, over the one source this host has. + const recovery = createRecovery([ + { + status: () => liveRecoveryStatus({ ...snapshot, roster, heads: [] }), + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + retry: () => { + if (roster.state === "error") refresh(); + traffic.retry(); + }, + }, + ]); + + function apply(next: ReadonlyMap) { + channels = next; + const ids = [...next.keys()].filter((id) => CHANNEL_ID.test(id)).sort(); + if (ids.length > MAX_CHANNELS) + log(`Hearing ${MAX_CHANNELS} of the agent's ${ids.length} channels`); + const heard = liveChannels(ids.slice(0, MAX_CHANNELS)); + traffic.update(heard, heard); + } + + /** Reads the agent's channels again, once at a time; one asked for + * meanwhile runs after it. */ + function refresh() { + if (stopped) return; + if (reading) { + again = true; + return; + } + reading = readChannels(options.query, pubkey).then( + (next) => { + if (stopped) return; + roster = { state: "ready" }; + apply(next); + options.onChannels(); + }, + (error) => { + if (stopped) return; + log(`Could not read the agent's channels: ${String(error)}`); + roster = { state: "error", transient: true }; + }, + ); + void reading.finally(() => { + reading = undefined; + if (again && !stopped) { + again = false; + refresh(); + } + changed(); + }); + } + refresh(); + + return Object.freeze({ + member: (channel: string) => channels?.has(channel), + dm(channel: string) { + const found = channels?.get(channel); + return ( + found?.type === "dm" && + found.members.length === 2 && + found.members.includes(pubkey) + ); + }, + stop() { + if (stopped) return; + stopped = true; + recovery.dispose(); + traffic.dispose(); + listeners.clear(); + }, + }); +} + +/** Every event matching `filter`, newest first, page by page. */ +async function scan( + query: ListenOptions["query"], + filter: Filter, +): Promise { + const found: RelayEvent[] = []; + for (let cursor = {}; ; ) { + const page = await query([{ ...filter, ...cursor, limit: PAGE }]); + found.push(...page); + const last = page.at(-1); + if (!last || page.length < PAGE) return found; + cursor = { until: last.created_at, before_id: last.id }; + } +} + +/** The channels whose newest roster lists the agent, with their members and + * type. NIP-CL: the relay leaves system channels out of a `#p` roster read + * unless it asks for them with `#t`. */ +export async function readChannels( + query: ListenOptions["query"], + pubkey: string, +): Promise> { + const memberships = { kinds: [39002], "#p": [pubkey] }; + const rosters = [ + ...(await scan(query, memberships)), + ...(await scan(query, { ...memberships, "#t": ["system"] })), + ]; + const newest = new Map(); + for (const event of rosters) { + const id = tagOf(event, "d"); + const seen = id && newest.get(id); + if (id && (!seen || event.created_at > seen.created_at)) + newest.set(id, event); + } + const ids = [...newest.keys()]; + const types = new Map(); + for (let index = 0; index < ids.length; index += ID_BATCH) + for (const event of await scan(query, { + kinds: [39000], + "#d": ids.slice(index, index + ID_BATCH), + })) { + const id = tagOf(event, "d"); + if (!id || event.created_at < (types.get(id)?.at ?? 0)) continue; + const type = tagOf(event, "t"); + types.set(id, { at: event.created_at, ...(type ? { type } : {}) }); + } + const channels = new Map(); + for (const [id, event] of newest) { + const members = [ + ...new Set( + event.tags.flatMap((tag) => (tag[0] === "p" && tag[1] ? [tag[1]] : [])), + ), + ]; + if (!members.includes(pubkey)) continue; + const type = types.get(id)?.type; + channels.set(id, { members, ...(type ? { type } : {}) }); + } + return channels; +} diff --git a/src/agent-host/test-socket.ts b/src/agent-host/test-socket.ts index 53e7abb907..168154b269 100644 --- a/src/agent-host/test-socket.ts +++ b/src/agent-host/test-socket.ts @@ -1,53 +1,76 @@ -// A relay connection a test answers by hand, for the agent feed. -/** A relay connection the test answers by hand. */ -export class FakeSocket { - static readonly OPEN = 1; - static last: FakeSocket | undefined; - readyState = 0; +// A relay connection a test answers by hand, for the app's live client +// (`subscribeRelayTraffic`) as the agent host runs it. +type Filter = Readonly<{ kinds?: number[]; "#h"?: string[] }>; + +export class LiveSocket { + readyState = 1; + onmessage?: (event: { data: string }) => Promise; + onclose?: () => void; + onerror?: () => void; readonly sent: unknown[][] = []; - private readonly listeners = new Map void)[]>(); - constructor(readonly url: string) { - FakeSocket.last = this; - } - addEventListener(type: string, listener: (event: unknown) => void) { - this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); - } send(text: string) { this.sent.push(JSON.parse(text) as unknown[]); } - close() {} - emit(type: string, event: unknown = {}) { - for (const listener of this.listeners.get(type) ?? []) listener(event); + close() { + this.readyState = 3; + this.onclose?.(); + } + async receive(message: unknown[]) { + await this.onmessage?.({ data: JSON.stringify(message) }); + } + /** Asks for the login, accepts it, and gives back the login event. */ + async auth() { + await this.receive(["AUTH", "challenge"]); + const login = this.sent.find((message) => message[0] === "AUTH")?.[1] as + | { id: string; pubkey: string; tags: string[][] } + | undefined; + if (!login) throw new Error("No login sent"); + await this.receive(["OK", login.id, true, ""]); + return login; } - opened() { - this.readyState = FakeSocket.OPEN; - this.emit("open"); + /** The requests still open, by wire id. */ + private open() { + const closed = new Set( + this.sent.filter((message) => message[0] === "CLOSE").map((m) => m[1]), + ); + return this.sent.filter( + (message) => message[0] === "REQ" && !closed.has(message[1]), + ) as [string, string, ...Filter[]][]; } - receive(message: unknown[]) { - this.emit("message", { data: JSON.stringify(message) }); + /** Ends the stored events of every open request: each is live. */ + async established() { + for (const [, wire] of this.open()) await this.receive(["EOSE", wire]); } - reqs() { - return this.sent.filter((message) => message[0] === "REQ"); + /** The wire ids of the open channel requests. */ + channelWires() { + return this.open() + .filter((message) => + (message.slice(2) as Filter[]).some((filter) => filter["#h"]), + ) + .map((message) => message[1]); + } + /** The channels the open requests hear. */ + channels() { + return [ + ...new Set( + this.open().flatMap((message) => + (message.slice(2) as Filter[]).flatMap( + (filter) => filter["#h"] ?? [], + ), + ), + ), + ].sort(); + } + /** The wire id of the open global request `name`: `membership` hears the + * relay's membership notices. */ + wire(name: "membership" | "profiles") { + const kind = name === "membership" ? 44100 : 0; + const found = this.open().find((message) => + (message.slice(2) as Filter[]).some((filter) => + filter.kinds?.includes(kind), + ), + ); + if (!found) throw new Error(`No ${name} request`); + return found[1]; } } - -/** The id of the latest request for subscription `key`. */ -export const latest = (socket: FakeSocket, key: string) => { - const id = socket - .reqs() - .map((message) => String(message[1])) - .filter((id) => id === key || id.startsWith(`${key}:`)) - .at(-1); - if (!id) throw new Error(`No ${key} request`); - return id; -}; -/** The subscription keys of the requests sent, in order. */ -export const asked = (socket: FakeSocket) => - socket.reqs().map((message) => String(message[1]).replace(/:\d+$/, "")); - -/** The id of the login the feed sent. */ -export const loginId = (socket: FakeSocket) => { - const login = socket.sent.find((message) => message[0] === "AUTH"); - if (!login) throw new Error("No login sent"); - return (login[1] as { id: string }).id; -}; diff --git a/src/features/agents/runner.test.ts b/src/features/agents/runner.test.ts index fc84b7e38c..6e35d7ccd0 100644 --- a/src/features/agents/runner.test.ts +++ b/src/features/agents/runner.test.ts @@ -6,8 +6,8 @@ import { getPublicKey, } from "nostr-tools/pure"; import { expect, it, vi } from "vitest"; -import { AgentFeed } from "../../agent-host/feed"; -import { FakeSocket } from "../../agent-host/test-socket"; +import { listen } from "../../agent-host/listen"; +import { LiveSocket } from "../../agent-host/test-socket"; import type { RelayEvent } from "../relay/events"; import { createLocalAgentActivity } from "./local-activity"; import { AgentRunners, type RunnerHost } from "./runner"; @@ -166,29 +166,46 @@ it("releases the agent once its community is no longer live", async () => { ); }); -it("runs every mention the standalone feed heard while not listening, past the agent's queue", async () => { - vi.useFakeTimers(); - vi.stubGlobal("WebSocket", FakeSocket); +it("runs every mention the standalone host's live client hears, through the runner's backlog", async () => { + const f = fixture(); + const secret = generateSecretKey(); + const viewer = getPublicKey(secret); + const sockets: LiveSocket[] = []; + const read = vi.fn(); + const listener = listen({ + relay: "wss://relay.example.test", + secret, + pubkey: viewer, + query: async () => [ + finalizeEvent( + { + kind: 39002, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["d", "channel"], + ["p", viewer], + ], + content: "", + }, + generateSecretKey(), + ) as unknown as RelayEvent, + ], + onEvents: (batch) => f.runners.dispatch(batch, () => true, 0), + onStatus: () => {}, + onChannels: read, + log: () => {}, + socket: () => { + const socket = new LiveSocket(); + sockets.push(socket); + return socket as unknown as WebSocket; + }, + }); try { - const f = fixture(); - const secret = generateSecretKey(); - const feed = new AgentFeed({ - relay: "wss://relay.example.test", - secret, - pubkey: getPublicKey(secret), - onEvents: (batch) => f.runners.dispatch(batch, () => true, 0), - onStatus: () => {}, - }); - feed.start(); - const socket = FakeSocket.last as FakeSocket; - socket.opened(); - // No login asked for: it subscribes as it is, and has heard the past. - await vi.advanceTimersByTimeAsync(2_000); - socket.receive(["EOSE", "mentions"]); - socket.receive(["EOSE", "rosters"]); - // One stream is lost; the mentions stream hears a burst meanwhile. - socket.receive(["CLOSED", "rosters", "error: busy"]); - // Signed, as the feed checks. + await vi.waitFor(() => expect(read).toHaveBeenCalled()); + const socket = sockets.at(-1) as LiveSocket; + await socket.auth(); + const [wire] = socket.channelWires(); + // Signed, as the client checks. const sender = generateSecretKey(); const burst = Array.from( { length: 40 }, @@ -206,10 +223,9 @@ it("runs every mention the standalone feed heard while not listening, past the a sender, ) as unknown as RelayEvent, ); - for (const event of burst) socket.receive(["EVENT", "mentions", event]); - expect(f.ran).toHaveLength(0); - // Listening again: the burst waits for room rather than overflow. - await vi.advanceTimersByTimeAsync(1_000); + // What the subscription sends before it is live: a replay. + for (const event of burst) await socket.receive(["EVENT", wire, event]); + await socket.receive(["EOSE", wire]); await vi.waitFor(() => { f.runners.tick(); expect(f.ran).toHaveLength(burst.length); @@ -221,9 +237,7 @@ it("runs every mention the standalone feed heard while not listening, past the a ), ), ).toEqual(new Set(burst.map((event) => event.id))); - feed.stop(); } finally { - vi.useRealTimers(); - vi.unstubAllGlobals(); + listener.stop(); } }); From a833d618ca13cf4cb46d452aee42c4bc0f87e5c2 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 23 Oct 2026 10:28:40 -0400 Subject: [PATCH 15/17] refactor(agent-host): use the app's relay session as the agent The host no longer has its own live client and reads. It signs in to the app's RelaySession as the agent (session.ts), with no writer and no publishing on its socket. The agent's reads are the app's ownerReads on that session, and its roster checks are the service's, moved to roster.ts so both use one copy. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src/agent-host/host.ts | 121 ++++++++---- src/agent-host/listen.test.ts | 240 ------------------------ src/agent-host/listen.ts | 284 ----------------------------- src/agent-host/main.ts | 29 ++- src/agent-host/reads.test.ts | 165 ----------------- src/agent-host/reads.ts | 205 --------------------- src/agent-host/session.test.ts | 87 +++++++++ src/agent-host/session.ts | 134 ++++++++++++++ src/agent-host/test-socket.ts | 76 -------- src/buzz-mcp/node.ts | 14 +- src/features/agents/roster.ts | 47 +++++ src/features/agents/runner.test.ts | 83 --------- src/features/agents/service.ts | 38 +--- src/features/agents/solo-chat.ts | 8 +- 14 files changed, 389 insertions(+), 1142 deletions(-) delete mode 100644 src/agent-host/listen.test.ts delete mode 100644 src/agent-host/listen.ts delete mode 100644 src/agent-host/reads.test.ts delete mode 100644 src/agent-host/reads.ts create mode 100644 src/agent-host/session.test.ts create mode 100644 src/agent-host/session.ts delete mode 100644 src/agent-host/test-socket.ts create mode 100644 src/features/agents/roster.ts diff --git a/src/agent-host/host.ts b/src/agent-host/host.ts index d3187c9fa4..814ff4d28e 100644 --- a/src/agent-host/host.ts +++ b/src/agent-host/host.ts @@ -26,7 +26,9 @@ import { CodexRuntime } from "../bundled/codex/runtime"; import { nodeClient } from "../buzz-mcp/node"; import { CoreMemoryCache } from "../features/agents/core-memory-cache"; import { createLocalAgentActivity } from "../features/agents/local-activity"; -import type { AgentReads } from "../features/agents/reads"; +import { type AgentReads, ownerReads } from "../features/agents/reads"; +import { rosterDm, rosterMember } from "../features/agents/roster"; +import { soloChannel } from "../features/agents/solo-chat"; import { communityDestination } from "../features/communities/destination"; import type { Checkpoint } from "../features/agents/catch-up"; import type { Interrupted } from "../features/agents/interrupted"; @@ -51,8 +53,7 @@ import { eventsDto, type RelayEvent } from "../features/relay/events"; import { relayPartition } from "../features/relay/partition"; import { Claims } from "./claim"; import { checkSaved, type Identity, readNames } from "./identity"; -import { listen } from "./listen"; -import { agentReads } from "./reads"; +import { type AgentSession, connectAgentSession } from "./session"; import { type Declared, processHost } from "./spawn"; import { stateStorage } from "./storage"; @@ -100,8 +101,8 @@ export type HostOptions = Readonly<{ onGone(reason: string): void; }>; -/** Runs the agent until the returned stop is called. */ -export function host(options: HostOptions): () => Promise { +/** Connects as the agent, then runs it until the returned stop is called. */ +export async function host(options: HostOptions): Promise<() => Promise> { const { identity, secret, dir, log, onGone } = options; const { pubkey } = identity; if (getPublicKey(secret) !== pubkey) @@ -117,7 +118,54 @@ export function host(options: HostOptions): () => Promise { ...(identity.auth ? { auth: identity.auth } : {}), root: home, }); - const reads: AgentReads = agentReads(client, identity.relay); + // As the app scopes the agent's community, so saved sessions match. + const scope = relayPartition( + communityDestination(identity.relay).url, + identity.owner, + ); + // The app's own relay session, as the agent: it hears the agent's channels + // and answers its reads, as the owner's session does in the app. + // Its live connection reconnects by itself; only the first connection, + // which reads the community's information, is tried again here. + let connected: AgentSession | undefined; + for (let attempt = 0; !connected; attempt++) + try { + connected = await connectAgentSession({ + relay: identity.relay, + secret, + pubkey, + ...(identity.auth + ? { auth: JSON.parse(identity.auth) as string[] } + : {}), + client, + scope, + }); + } catch (error) { + const wait = Math.min(30_000, 1_000 * 2 ** attempt); + log( + `Could not connect (${String(error)}); trying again in ${wait / 1000}s`, + ); + await new Promise((resolve) => setTimeout(resolve, wait)); + } + const connection = connected; + const { session } = connection; + const reads: AgentReads = { + ...(ownerReads(connection.relay, pubkey) as AgentReads), + // The agent's own client decrypts its memory. It skips entries it cannot + // decrypt, so it cannot show that a listing is complete; a turn then + // never takes a missing core as none. + memoryListing: async (signal, slug) => { + signal.throwIfAborted(); + const { entries } = await client.memories(slug); + signal.throwIfAborted(); + return { entries, partial: true }; + }, + // The channel's Solo label decides, as for every member and agent. + soloChat: async (_owner, channel, signal) => { + signal.throwIfAborted(); + return soloChannel(session, channel); + }, + }; let live = false; const readsOf = (agent: string) => live && agent === pubkey ? reads : undefined; @@ -192,25 +240,6 @@ export function host(options: HostOptions): () => Promise { // from the record the one before it kept, and its timers wait for them. let changes: Promise = Promise.resolve(); let changing = 0; - const listener = listen({ - relay: identity.relay, - secret, - pubkey, - ...(identity.auth ? { auth: JSON.parse(identity.auth) as string[] } : {}), - query: async (filters) => verified(await client.query(filters)), - // As the app's agents hear its session: a replay (the recent history a - // subscription sends again before it goes live) is heard only for what - // happened since this host started. - onEvents: (batch) => runners.dispatch(batch, () => true, started), - onStatus: (now) => { - live = now; - log(now ? "Listening" : "Not listening"); - runners.resume(new Set(now ? [pubkey] : [])); - }, - // A roster the relay confirms decides the events held for it. - onChannels: () => runners.releaseHeld(() => true), - log, - }); // Whether the app deleted, erased or changed the agent: then this host // stops, and tells its caller why. let gone = false; @@ -231,11 +260,6 @@ export function host(options: HostOptions): () => Promise { const mine = (agent: string) => { if (agent !== pubkey) throw new Error("Not this host's agent"); }; - // As the app scopes the agent's community, so saved sessions match. - const scope = relayPartition( - communityDestination(identity.relay).url, - identity.owner, - ); const started = Math.floor(Date.now() / 1000); // Catch-up progress, kept only while this host runs. This host does not // catch up yet, so it neither reads nor writes the agent's saved progress @@ -246,14 +270,8 @@ export function host(options: HostOptions): () => Promise { const runners = new AgentRunners({ ready: (agent) => (live && agent === pubkey ? agentView() : undefined), agent: (agent) => (agent === pubkey ? agentView() : undefined), - member: (_, event) => { - const channel = event.tags.find((tag) => tag[0] === "h")?.[1]; - return channel ? listener.member(channel) : false; - }, - dm: (_, event) => { - const channel = event.tags.find((tag) => tag[0] === "h")?.[1]; - return !!channel && listener.dm(channel); - }, + member: (_, event) => rosterMember(session, pubkey, event), + dm: (_, event) => rosterDm(session, pubkey, event), // No catch-up: see `checkpoints`. community: () => undefined, records: () => [recordOf()], @@ -405,6 +423,28 @@ export function host(options: HostOptions): () => Promise { new Map([[identity.type, run.type]]), ); run.sync([agentView()]); + // As the app's agents hear its session (`AgentsService.listen`): a replay + // (the recent history a subscription sends again before it goes live) is + // heard only for what happened since this host started. + const stopEvents = session.subscribeLive( + (batch) => runners.dispatch(batch, () => true, started), + { replay: true }, + ); + const onStatus = () => { + const now = session.live.snapshot().status === "connected"; + if (now === live) return; + live = now; + log(now ? "Listening" : "Not listening"); + // Its core memory may have changed elsewhere while it was not connected. + if (now) void coreMemory.refresh(pubkey); + runners.resume(new Set(now ? [pubkey] : [])); + }; + const stopStatus = session.live.subscribe(onStatus); + onStatus(); + // A roster the relay confirms decides the events held for it. + const stopRosters = session.channels.subscribeList?.(() => + runners.releaseHeld(() => true), + ); const tick = setInterval(() => { if (gone || isGone()) return; try { @@ -422,7 +462,10 @@ export function host(options: HostOptions): () => Promise { async function stopHosting() { clearInterval(tick); - listener.stop(); + stopEvents(); + stopStatus(); + stopRosters?.(); + connection.relay.disconnect(); runners.clear(); // Only once its processes have exited may another copy run the agent. await run.dispose(); diff --git a/src/agent-host/listen.test.ts b/src/agent-host/listen.test.ts deleted file mode 100644 index b3928a46ca..0000000000 --- a/src/agent-host/listen.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -// @vitest-environment node -import { - finalizeEvent, - generateSecretKey, - getPublicKey, - verifyEvent, -} from "nostr-tools/pure"; -import { afterEach, expect, it, vi } from "vitest"; -import type { Filter } from "../buzz-mcp/client"; -import type { RelayEvent } from "../features/relay/events"; -import type { LiveBatch } from "../features/relay/incoming"; -import { listen, readChannels } from "./listen"; -import { LiveSocket } from "./test-socket"; - -afterEach(() => vi.useRealTimers()); - -const secret = generateSecretKey(); -const pubkey = getPublicKey(secret); -const owner = getPublicKey(generateSecretKey()); -const someone = generateSecretKey(); -const auth = ["auth", owner, "", "f".repeat(128)]; - -const sign = ( - kind: number, - tags: string[][], - key = someone, - at = Math.floor(Date.now() / 1000), -) => - finalizeEvent( - { kind, created_at: at, tags, content: "" }, - key, - ) as unknown as RelayEvent; -const roster = (channel: string, members: readonly string[]) => - sign(39002, [["d", channel], ...members.map((member) => ["p", member])]); -const metadata = (channel: string, type: string) => - sign(39000, [ - ["d", channel], - ["t", type], - ]); - -/** A relay whose reads answer from `events` as the relay filters them. */ -function relay(events: () => readonly RelayEvent[]) { - const reads: Filter[][] = []; - const query = vi.fn(async (filters: readonly Filter[]) => { - reads.push([...filters]); - return events().filter((event) => - filters.some((filter) => { - const f = filter as Record; - const has = (name: string) => { - const want = f[`#${name}`] as string[] | undefined; - return ( - !want || - event.tags.some( - ([key, value]) => key === name && !!value && want.includes(value), - ) - ); - }; - return ( - (f.kinds as number[]).includes(event.kind) && - has("p") && - has("d") && - // A system channel's roster comes only to a read that asks for it. - (f["#t"] - ? has("t") - : event.kind !== 39002 || - !event.tags.some((tag) => tag[1] === "system")) - ); - }), - ); - }); - return { query, reads }; -} - -function host(events: () => readonly RelayEvent[]) { - const sockets: LiveSocket[] = []; - const batches: LiveBatch[] = []; - const statuses: boolean[] = []; - const channels = vi.fn(); - const { query, reads } = relay(events); - const listener = listen({ - relay: "wss://relay.example.test", - secret, - pubkey, - auth, - query, - onEvents: (batch) => batches.push(batch), - onStatus: (live) => statuses.push(live), - onChannels: channels, - log: () => {}, - socket: () => { - const socket = new LiveSocket(); - sockets.push(socket); - return socket as unknown as WebSocket; - }, - }); - const socket = () => sockets.at(-1) as LiveSocket; - return { listener, socket, batches, statuses, channels, query, reads }; -} - -it("logs in as the agent with its owner's attestation, and hears the channels its rosters list", async () => { - const events = [ - roster("general", [owner, pubkey]), - metadata("general", "stream"), - roster("chat", [owner, pubkey]), - metadata("chat", "dm"), - roster("elsewhere", [owner]), - ]; - const h = host(() => events); - // Until its channels are read, membership is unknown, so a watch waits. - expect(h.listener.member("general")).toBeUndefined(); - await vi.waitFor(() => expect(h.channels).toHaveBeenCalledOnce()); - expect(h.listener.member("general")).toBe(true); - expect(h.listener.member("elsewhere")).toBe(false); - expect(h.listener.dm("chat")).toBe(true); - expect(h.listener.dm("general")).toBe(false); - - const login = await h.socket().auth(); - expect(login.pubkey).toBe(pubkey); - expect(verifyEvent(login as never)).toBe(true); - expect(login.tags).toContainEqual(auth); - expect(h.statuses).toEqual([true]); - expect(h.socket().channels()).toEqual(["chat", "general"]); - h.listener.stop(); -}); - -it("gives on the client's live events, and its replays marked as such", async () => { - const events = [roster("general", [owner, pubkey])]; - const h = host(() => events); - await vi.waitFor(() => expect(h.channels).toHaveBeenCalled()); - await h.socket().auth(); - const [wire] = h.socket().channelWires(); - const older = sign(9, [ - ["h", "general"], - ["p", pubkey], - ]); - const newer = sign(9, [ - ["h", "general"], - ["p", pubkey], - ]); - await h.socket().receive(["EVENT", wire, older]); - await h.socket().receive(["EOSE", wire]); - await h.socket().receive(["EVENT", wire, newer]); - expect( - h.batches.map(({ events, ...batch }) => ({ - ...batch, - ids: events.map((event) => event.id), - })), - ).toEqual([ - { channelId: "general", replay: true, ids: [older.id] }, - { channelId: "general", ids: [newer.id] }, - ]); - h.listener.stop(); -}); - -it("reads its channels again when the relay says its membership changed, and hears the new one", async () => { - const events = [roster("general", [owner, pubkey])]; - const h = host(() => events); - await vi.waitFor(() => expect(h.channels).toHaveBeenCalledOnce()); - await h.socket().auth(); - expect(h.socket().channels()).toEqual(["general"]); - events.push(roster("new", [owner, pubkey]), metadata("new", "dm")); - const notice = sign(44100, [ - ["p", pubkey], - ["h", "new"], - ]); - const membership = h.socket().wire("membership"); - await h.socket().receive(["EOSE", membership]); - await h.socket().receive(["EVENT", membership, notice]); - await vi.waitFor(() => expect(h.channels).toHaveBeenCalledTimes(2)); - expect(h.listener.member("new")).toBe(true); - expect(h.listener.dm("new")).toBe(true); - expect(h.socket().channels()).toContain("new"); - h.listener.stop(); -}); - -it("stops hearing a channel the relay denies, until a read lists it again", async () => { - const events = [ - roster("general", [owner, pubkey]), - roster("gone", [owner, pubkey]), - ]; - const h = host(() => events); - await vi.waitFor(() => expect(h.channels).toHaveBeenCalledOnce()); - await h.socket().auth(); - events.splice(1, 1); - const [wire] = h.socket().channelWires(); - await h - .socket() - .receive(["CLOSED", wire, "restricted: not a channel member"]); - // The relay refused the batch both were heard in. - expect(h.listener.member("gone")).toBe(false); - expect(h.listener.member("general")).toBe(false); - await vi.waitFor(() => expect(h.listener.member("general")).toBe(true)); - expect(h.listener.member("gone")).toBe(false); - expect(h.listener.member("general")).toBe(true); - expect(h.socket().channels()).toEqual(["general"]); - h.listener.stop(); -}); - -it("keeps the last channels it read when a read fails, and the app's recovery reads again", async () => { - vi.useFakeTimers(); - let fail = false; - const events = [roster("general", [owner, pubkey])]; - const h = host(() => { - if (fail) throw new Error("busy"); - return events; - }); - await vi.advanceTimersByTimeAsync(0); - expect(h.channels).toHaveBeenCalledOnce(); - await h.socket().auth(); - await h.socket().established(); - fail = true; - // The client's periodic repair asks for a read, which fails. - await vi.advanceTimersByTimeAsync(60_000); - expect(h.listener.member("general")).toBe(true); - const reads = h.query.mock.calls.length; - fail = false; - await vi.advanceTimersByTimeAsync(1_000); - expect(h.query.mock.calls.length).toBeGreaterThan(reads); - expect(h.channels).toHaveBeenCalledTimes(2); - h.listener.stop(); -}); - -it("reads system channels through their opt-in filter, and only rosters that list the agent", async () => { - const events = [ - roster("general", [owner, pubkey]), - sign(39002, [ - ["d", "notices"], - ["t", "system"], - ["p", pubkey], - ]), - sign(39000, [ - ["d", "notices"], - ["t", "system"], - ]), - ]; - const { query } = relay(() => events); - const channels = await readChannels(query, pubkey); - expect([...channels.keys()].sort()).toEqual(["general", "notices"]); - expect(channels.get("notices")?.type).toBe("system"); -}); diff --git a/src/agent-host/listen.ts b/src/agent-host/listen.ts deleted file mode 100644 index c7e8ec2959..0000000000 --- a/src/agent-host/listen.ts +++ /dev/null @@ -1,284 +0,0 @@ -// The agent's live events on a standalone host, heard as the app hears its -// own: through the app's live client (`subscribeRelayTraffic`), logged in as -// the agent with its owner's attestation. The app's session gives that client -// the channels its viewer is in; here the host reads the agent's from the -// relay, and reads them again when the relay says its membership changed, -// when a channel is denied, and each time the client asks for its periodic -// repair. The app's recovery owner retries what fails. -import type { EventTemplate, VerifiedEvent } from "nostr-tools"; -import { finalizeEvent } from "nostr-tools/pure"; -import type { Filter } from "../buzz-mcp/client"; -import type { RelayEvent } from "../features/relay/events"; -import type { LiveBatch } from "../features/relay/incoming"; -import { - type LiveSnapshot, - liveChannels, - subscribeRelayTraffic, -} from "../features/relay/live"; -import { liveRecoveryStatus } from "../features/relay/live-recovery"; -import { createRecovery } from "../features/relay/recovery"; - -/** One read's page size: the most the relay returns for one query. */ -const PAGE = 500; -/** The most channel ids one request names (buzz-relay - * `MAX_EXPLICIT_CHANNEL_VALUES`). */ -const ID_BATCH = 128; -/** The most channels the live client hears (`liveChannels`). */ -const MAX_CHANNELS = 1024; -const CHANNEL_ID = /^[a-zA-Z0-9_-]{1,128}$/; -/** Channel metadata and rosters, and the relay's membership notices. */ -const MEMBERSHIP_KINDS = [39000, 39002, 44100, 44101]; - -type Channel = Readonly<{ members: readonly string[]; type?: string }>; - -export type ListenOptions = Readonly<{ - relay: string; - secret: Uint8Array; - pubkey: string; - /** The owner's NIP-OA `auth` tag. */ - auth?: readonly string[]; - /** Reads the relay as the agent. */ - query(filters: readonly Filter[]): Promise; - /** Live events, and the recent history a subscription sends again before - * it goes live (`replay`), as the app's session gives its agents. */ - onEvents(batch: LiveBatch): void; - /** Whether the live connection is up. */ - onStatus(live: boolean): void; - /** The agent's channels were read again: events held for a roster may now - * match. */ - onChannels(): void; - log(message: string): void; - socket?: (url: string) => WebSocket; -}>; - -export type Listener = Readonly<{ - /** Whether the agent is in the channel, or undefined until its channels - * have been read once. */ - member(channel: string): boolean | undefined; - /** Whether the channel is a DM of exactly the agent and one other. */ - dm(channel: string): boolean; - stop(): void; -}>; - -const tagOf = (event: RelayEvent, name: string) => - event.tags.find((tag) => tag[0] === name)?.[1]; - -export function listen(options: ListenOptions): Listener { - const { pubkey, log } = options; - let channels: ReadonlyMap | undefined; - let roster: Readonly<{ state: string; transient?: true }> = { - state: "pending", - }; - let snapshot: LiveSnapshot = { status: "connecting", routes: [] }; - let live = false; - let connected = 0; - let stopped = false; - let reading: Promise | undefined; - let again = false; - const listeners = new Set<() => void>(); - const changed = () => { - for (const listener of [...listeners]) listener(); - }; - - const sign = async (template: EventTemplate) => - finalizeEvent( - { - ...template, - tags: - template.kind === 22242 && options.auth - ? [...template.tags, [...options.auth]] - : template.tags, - }, - options.secret, - ) as VerifiedEvent; - - const traffic = subscribeRelayTraffic( - options.relay, - sign, - pubkey, - { - receive(events, provenance) { - if (stopped) return; - // The relay's word that the agent's membership changed, or a new - // roster or type of one of its channels: read its channels again. - // A replay's are older than the read each connection starts with. - if ( - provenance?.phase === "live" && - events.some((event) => MEMBERSHIP_KINDS.includes(event.kind)) - ) - refresh(); - options.onEvents( - Object.freeze({ - events, - ...(provenance?.channelId - ? { channelId: provenance.channelId } - : {}), - ...(provenance?.phase === "replay" - ? { replay: true as const } - : {}), - }), - ); - }, - state(next) { - if (stopped) return; - if (next.status === "error" && snapshot.status !== "error") - log(`Not listening: ${next.error ?? "live connection failed"}`); - snapshot = next; - const now = next.status === "connected"; - if (now !== live) { - live = now; - options.onStatus(now); - // What changed while it was down; the first read is under way. - if (now && connected++) refresh(); - } - changed(); - }, - established() {}, - recover: () => refresh(), - denied(channel, reason) { - log(`Lost ${channel}: ${reason}`); - if (!channels?.has(channel)) return; - const next = new Map(channels); - next.delete(channel); - apply(next); - refresh(); - }, - }, - options.socket, - ); - - // The app's recovery owner, over the one source this host has. - const recovery = createRecovery([ - { - status: () => liveRecoveryStatus({ ...snapshot, roster, heads: [] }), - subscribe: (listener) => { - listeners.add(listener); - return () => listeners.delete(listener); - }, - retry: () => { - if (roster.state === "error") refresh(); - traffic.retry(); - }, - }, - ]); - - function apply(next: ReadonlyMap) { - channels = next; - const ids = [...next.keys()].filter((id) => CHANNEL_ID.test(id)).sort(); - if (ids.length > MAX_CHANNELS) - log(`Hearing ${MAX_CHANNELS} of the agent's ${ids.length} channels`); - const heard = liveChannels(ids.slice(0, MAX_CHANNELS)); - traffic.update(heard, heard); - } - - /** Reads the agent's channels again, once at a time; one asked for - * meanwhile runs after it. */ - function refresh() { - if (stopped) return; - if (reading) { - again = true; - return; - } - reading = readChannels(options.query, pubkey).then( - (next) => { - if (stopped) return; - roster = { state: "ready" }; - apply(next); - options.onChannels(); - }, - (error) => { - if (stopped) return; - log(`Could not read the agent's channels: ${String(error)}`); - roster = { state: "error", transient: true }; - }, - ); - void reading.finally(() => { - reading = undefined; - if (again && !stopped) { - again = false; - refresh(); - } - changed(); - }); - } - refresh(); - - return Object.freeze({ - member: (channel: string) => channels?.has(channel), - dm(channel: string) { - const found = channels?.get(channel); - return ( - found?.type === "dm" && - found.members.length === 2 && - found.members.includes(pubkey) - ); - }, - stop() { - if (stopped) return; - stopped = true; - recovery.dispose(); - traffic.dispose(); - listeners.clear(); - }, - }); -} - -/** Every event matching `filter`, newest first, page by page. */ -async function scan( - query: ListenOptions["query"], - filter: Filter, -): Promise { - const found: RelayEvent[] = []; - for (let cursor = {}; ; ) { - const page = await query([{ ...filter, ...cursor, limit: PAGE }]); - found.push(...page); - const last = page.at(-1); - if (!last || page.length < PAGE) return found; - cursor = { until: last.created_at, before_id: last.id }; - } -} - -/** The channels whose newest roster lists the agent, with their members and - * type. NIP-CL: the relay leaves system channels out of a `#p` roster read - * unless it asks for them with `#t`. */ -export async function readChannels( - query: ListenOptions["query"], - pubkey: string, -): Promise> { - const memberships = { kinds: [39002], "#p": [pubkey] }; - const rosters = [ - ...(await scan(query, memberships)), - ...(await scan(query, { ...memberships, "#t": ["system"] })), - ]; - const newest = new Map(); - for (const event of rosters) { - const id = tagOf(event, "d"); - const seen = id && newest.get(id); - if (id && (!seen || event.created_at > seen.created_at)) - newest.set(id, event); - } - const ids = [...newest.keys()]; - const types = new Map(); - for (let index = 0; index < ids.length; index += ID_BATCH) - for (const event of await scan(query, { - kinds: [39000], - "#d": ids.slice(index, index + ID_BATCH), - })) { - const id = tagOf(event, "d"); - if (!id || event.created_at < (types.get(id)?.at ?? 0)) continue; - const type = tagOf(event, "t"); - types.set(id, { at: event.created_at, ...(type ? { type } : {}) }); - } - const channels = new Map(); - for (const [id, event] of newest) { - const members = [ - ...new Set( - event.tags.flatMap((tag) => (tag[0] === "p" && tag[1] ? [tag[1]] : [])), - ), - ]; - if (!members.includes(pubkey)) continue; - const type = types.get(id)?.type; - channels.set(id, { members, ...(type ? { type } : {}) }); - } - return channels; -} diff --git a/src/agent-host/main.ts b/src/agent-host/main.ts index 379ba78b49..0157e79075 100644 --- a/src/agent-host/main.ts +++ b/src/agent-host/main.ts @@ -14,7 +14,7 @@ const usage = "Usage: buzz-agent-host run "; const log = (message: string) => console.error(`${new Date().toISOString()} ${message}`); -function main(args: readonly string[]) { +async function main(args: readonly string[]) { const [command, pubkey, ...rest] = args; if ( command !== "run" || @@ -28,7 +28,17 @@ function main(args: readonly string[]) { const dir = agentsDir(); const identity = readIdentity(dir, pubkey); const secret = readKey(identity); - const stop = host({ + let stopping = false; + let stop: (() => Promise) | undefined; + const quit = () => { + if (stopping || !stop) process.exit(stop ? 1 : 0); + stopping = true; + log("Stopping"); + void stop().finally(() => process.exit(0)); + }; + process.on("SIGINT", quit); + process.on("SIGTERM", quit); + stop = await host({ identity, secret, dir, @@ -38,20 +48,9 @@ function main(args: readonly string[]) { process.exit(0); }, }); - let stopping = false; - const quit = () => { - if (stopping) process.exit(1); - stopping = true; - log("Stopping"); - void stop().finally(() => process.exit(0)); - }; - process.on("SIGINT", quit); - process.on("SIGTERM", quit); } -try { - main(process.argv.slice(2)); -} catch (error) { +main(process.argv.slice(2)).catch((error: unknown) => { console.error(error instanceof Error ? error.message : error); process.exit(1); -} +}); diff --git a/src/agent-host/reads.test.ts b/src/agent-host/reads.test.ts deleted file mode 100644 index d8a2c18e71..0000000000 --- a/src/agent-host/reads.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -// @vitest-environment node -import { createHash } from "node:crypto"; -import { schnorr } from "@noble/curves/secp256k1.js"; -import { bytesToHex } from "nostr-tools/utils"; -import { expect, it, vi } from "vitest"; -import type { BuzzClient } from "../buzz-mcp/client"; -import { fromOwner, workflowOf } from "../features/agents/owner-attestation"; -import { eventDto } from "../features/relay/events"; -import { keypair, signed } from "../features/relay/testing"; -import { agentReads } from "./reads"; - -const RELAY = "wss://relay.example"; - -function setup() { - const relay = keypair(), - owner = keypair(), - sibling = keypair(), - agent = keypair(); - const attestation = [ - "auth", - owner.pubkey, - "", - bytesToHex( - schnorr.sign( - createHash("sha256") - .update(`nostr:agent-auth:${sibling.pubkey}:`) - .digest(), - owner.secret, - ), - ), - ]; - const profile = signed(sibling, { - kind: 0, - content: JSON.stringify({ name: "Sibling" }), - tags: [attestation], - }); - const query = vi.fn(async () => [profile]); - const client = { pubkey: agent.pubkey, query } as unknown as BuzzClient; - const mention = eventDto( - signed(relay, { - kind: 9, - content: "@Agent workflow", - tags: [ - ["h", "channel"], - ["p", agent.pubkey], - ["buzz:workflow", "true"], - ["buzz:workflow-owner", sibling.pubkey], - ["buzz:workflow-mention", agent.pubkey], - ], - }), - ); - return { relay, owner, sibling, agent, attestation, client, query, mention }; -} - -it("accepts a workflow mention from the owner's attested agent, as the app does", async () => { - const f = setup(); - const reads = agentReads(f.client, RELAY, async () => f.relay.pubkey); - await vi.waitFor(() => - expect(reads.workflowAuthority()).toBe(f.relay.pubkey), - ); - expect( - await fromOwner( - f.mention, - f.owner.pubkey, - workflowOf(reads, f.agent.pubkey), - ), - ).toBe(true); - expect(f.query).toHaveBeenCalledWith([ - { kinds: [0], authors: [f.sibling.pubkey], limit: 1 }, - ]); - expect(reads.name(f.sibling.pubkey, [])).toBe("Sibling"); -}); - -it("ignores workflow mentions until the community gives its key, then asks again", async () => { - const f = setup(); - const self = vi - .fn<() => Promise>() - .mockRejectedValueOnce(new Error("offline")) - .mockResolvedValue(f.relay.pubkey); - const reads = agentReads(f.client, RELAY, self); - await vi.waitFor(() => expect(self).toHaveBeenCalledOnce()); - await Promise.resolve(); - expect( - await fromOwner( - f.mention, - f.owner.pubkey, - workflowOf(reads, f.agent.pubkey), - ), - ).toBe(false); - await vi.waitFor(() => - expect(reads.workflowAuthority()).toBe(f.relay.pubkey), - ); - expect(self).toHaveBeenCalledTimes(2); -}); - -it("does not trust a workflow message signed by another key", async () => { - const f = setup(); - const reads = agentReads(f.client, RELAY, async () => keypair().pubkey); - await vi.waitFor(() => expect(reads.workflowAuthority()).toBeDefined()); - expect( - await fromOwner( - f.mention, - f.owner.pubkey, - workflowOf(reads, f.agent.pubkey), - ), - ).toBe(false); -}); - -it("answers a Solo chat at top level from its label, and threads it again once converted", async () => { - const f = setup(); - const metadata = (tags: string[][]) => - signed(f.relay, { - kind: 39000, - content: "", - tags: [["d", "chat"], ["t", "stream"], ["private"], ...tags], - }); - const signal = new AbortController().signal; - const reads = agentReads(f.client, RELAY, async () => f.relay.pubkey); - const solo = () => reads.soloChat(f.owner.pubkey, "chat", signal); - - f.query.mockResolvedValueOnce([metadata([["t", "buzz.solo"]])]); - expect(await solo()).toBe(true); - expect(f.query).toHaveBeenLastCalledWith([ - { kinds: [39000], "#d": ["chat"], limit: 1 }, - ]); - // Converted to a channel: the label is gone, so mentions keep their threads. - f.query.mockResolvedValueOnce([metadata([])]); - expect(await solo()).toBe(false); - // An unknown channel is not Solo; a failed read fails rather than guess. - f.query.mockResolvedValueOnce([]); - expect(await solo()).toBe(false); - f.query.mockRejectedValueOnce(new Error("offline")); - await expect(solo()).rejects.toThrow("offline"); -}); - -it("hands over a Solo chat's Canvas only from its owner's side, and says when there is none", async () => { - const f = setup(); - const reads = agentReads(f.client, RELAY, async () => f.relay.pubkey); - const canvas = (author: ReturnType, tags: string[][] = []) => - signed(author, { - kind: 40100, - content: `# By ${author.pubkey.slice(0, 4)}`, - tags: [["h", "chat"], ...tags], - }); - const read = () => reads.canvas?.("chat", f.owner.pubkey); - - f.query.mockResolvedValueOnce([canvas(f.owner)]); - expect(await read()).toBe(`# By ${f.owner.pubkey.slice(0, 4)}`); - expect(f.query).toHaveBeenLastCalledWith([ - { kinds: [40100], "#h": ["chat"], limit: 1, consistency: "strong" }, - ]); - // The owner's attested agent writes for the owner. - f.query.mockResolvedValueOnce([canvas(f.sibling, [f.attestation])]); - expect(await read()).toBe(`# By ${f.sibling.pubkey.slice(0, 4)}`); - // A member's head is not the owner's instructions. - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - f.query.mockResolvedValueOnce([canvas(keypair())]); - expect(await read()).toBeUndefined(); - warn.mockRestore(); - // No Canvas is a confirmed empty; a failed read rejects instead. - f.query.mockResolvedValueOnce([]); - expect(await read()).toBeUndefined(); - f.query.mockRejectedValueOnce(new Error("offline")); - await expect(read()).rejects.toThrow("offline"); -}); diff --git a/src/agent-host/reads.ts b/src/agent-host/reads.ts deleted file mode 100644 index 90884929b8..0000000000 --- a/src/agent-host/reads.ts +++ /dev/null @@ -1,205 +0,0 @@ -// What an agent's turns read of its community on a standalone host: every read -// is made as the agent, through its own signed requests, so a turn sees only -// what the agent may see. -import type { BuzzClient, Filter } from "../buzz-mcp/client"; -import { ownerCanvas } from "../features/agent-harness/project"; -import type { AgentReads, ChannelFacts } from "../features/agents/reads"; -import { soloChannelMetadata } from "../features/relay/discovery"; -import { - type EventData, - eventsDto, - newer, - type RelayEvent, -} from "../features/relay/events"; - -const tagOf = (event: EventData, name: string) => - event.tags.find((tag) => tag[0] === name)?.[1]; -const newest = (events: readonly T[], kind: number) => - events - .filter((event) => event.kind === kind) - .sort((a, b) => b.created_at - a.created_at)[0]; - -/** Channel metadata's name: its content body overrides the tag. */ -function channelName(event: EventData) { - try { - const body = JSON.parse(event.content) as { name?: unknown }; - if (typeof body?.name === "string" && body.name) return body.name; - } catch {} - return tagOf(event, "name") || undefined; -} - -const abortable = (work: Promise, signal?: AbortSignal) => { - if (!signal) return work; - signal.throwIfAborted(); - return Promise.race([ - work, - new Promise((_, reject) => - signal.addEventListener("abort", () => reject(signal.reason), { - once: true, - }), - ), - ]); -}; - -/** The key the community signs workflow messages with: its NIP-11 `self`, as - * the app reads it (`connectNativeTransport`). */ -export async function relaySelf(relay: string) { - const response = await fetch(relay.replace(/^ws/, "http"), { - headers: { accept: "application/nostr+json" }, - }); - if (!response.ok) - throw new Error(`Community information: ${response.status}`); - const self = ((await response.json()) as { self?: unknown } | null)?.self; - if (typeof self !== "string" || !/^[a-f0-9]{64}$/.test(self)) - throw new Error("The community did not give its identity"); - return self; -} - -export function agentReads( - client: BuzzClient, - scope: string, - self: () => Promise = () => relaySelf(scope), -): AgentReads { - // Signed profiles, verified as read; names come from them. - const profiles = new Map(); - const names = new Map(); - const channels = new Map(); - // Until the community gives its key, only the owner's own mentions count, - // as in the app before it connects. A failed read is tried again later. - let authority: string | undefined; - let asking = false; - const ask = () => { - if (asking) return; - asking = true; - self().then( - (key) => { - authority = key; - }, - () => { - asking = false; - }, - ); - }; - ask(); - return Object.freeze({ - scope, - connection: client, - async channel(id: string): Promise { - const events = eventsDto([ - ...(await client.query([ - { kinds: [39000], "#d": [id], limit: 1 }, - { kinds: [39002], "#d": [id], limit: 1 }, - ])), - ]); - const metadata = newest(events, 39000); - const roster = newest(events, 39002); - if (!metadata && !roster) return undefined; - const name = metadata && channelName(metadata); - if (name) channels.set(id, name); - return { - ...(name ? { name } : {}), - dm: !!metadata?.tags.some((tag) => tag[0] === "t" && tag[1] === "dm"), - members: [ - ...new Set( - roster?.tags.flatMap(([name, value]) => - name === "p" && value && /^[0-9a-f]{64}$/.test(value) - ? [value] - : [], - ) ?? [], - ), - ].sort(), - }; - }, - read: (filters, signal) => - abortable( - client - .query(filters as readonly Filter[]) - .then((events) => eventsDto([...events])), - signal, - ), - async profiles(pubkeys: readonly string[]) { - const missing = pubkeys.filter((pubkey) => !profiles.has(pubkey)); - if (!missing.length) return; - const events = eventsDto([ - ...(await client.query([ - { kinds: [0], authors: missing, limit: missing.length }, - ])), - ]); - for (const event of events) { - if (event.kind !== 0 || !missing.includes(event.pubkey)) continue; - const previous = profiles.get(event.pubkey); - if (newer(previous, event) === previous) continue; - profiles.set(event.pubkey, event); - try { - const profile = JSON.parse(event.content) as Record; - const name = profile.display_name || profile.name; - if (typeof name === "string" && name) names.set(event.pubkey, name); - else names.delete(event.pubkey); - } catch { - names.delete(event.pubkey); - } - } - }, - name: (pubkey: string) => names.get(pubkey), - profile: (pubkey: string) => profiles.get(pubkey), - channelName: (id: string) => channels.get(id), - workflowAuthority: () => { - if (!authority) ask(); - return authority; - }, - // The client skips entries it cannot decrypt, so it cannot show that a - // listing is complete; a turn then never takes a missing core as none. - memoryListing: async (signal: AbortSignal, slug?: string) => ({ - entries: (await abortable(client.memories(slug), signal)).entries, - partial: true, - }), - // The channel's Solo label decides, as for every member and agent - // (docs/solo/README.md). Read fresh for each turn, so a chat converted - // to a channel goes back to threads. - async soloChat(_owner: string, id: string, signal: AbortSignal) { - const metadata = newest( - eventsDto([ - ...(await abortable( - client.query([{ kinds: [39000], "#d": [id], limit: 1 }]), - signal, - )), - ]), - 39000, - ); - return !!metadata && soloChannelMetadata(metadata); - }, - // A Solo chat's Canvas, read fresh for each turn as the app does, and - // handed over only when the owner or an agent they attest wrote it. A - // failed read rejects, so the turn keeps the Canvas it knew. The read is - // strong, as the app's Canvas reads are: the relay answers it from its - // writer, never from a replica that may not have the newest edit or clear - // yet. - canvas: (id: string, owner: string) => - ownerCanvas( - { - available: true, - read: async (channel) => - eventsDto([ - ...(await client.query([ - { - kinds: [40100], - "#h": [channel], - limit: 1, - consistency: "strong", - }, - ])), - ]) - .filter( - (event) => - event.kind === 40100 && tagOf(event, "h") === channel, - ) - .sort( - (a, b) => - b.created_at - a.created_at || a.id.localeCompare(b.id), - )[0], - }, - owner, - id, - ), - }); -} diff --git a/src/agent-host/session.test.ts b/src/agent-host/session.test.ts new file mode 100644 index 0000000000..9b89969b7a --- /dev/null +++ b/src/agent-host/session.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment node +import { generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools/pure"; +import { afterEach, expect, it, vi } from "vitest"; +import { nodeClient } from "../buzz-mcp/node"; +import { connectAgentSession } from "./session"; + +/** A relay connection the test answers by hand. */ +class Socket { + static last: Socket | undefined; + readyState = 1; + onmessage?: (event: { data: string }) => Promise; + onclose?: () => void; + onerror?: () => void; + readonly sent: unknown[][] = []; + constructor() { + Socket.last = this; + } + send(text: string) { + this.sent.push(JSON.parse(text) as unknown[]); + } + close() { + this.readyState = 3; + } + async receive(message: unknown[]) { + await this.onmessage?.({ data: JSON.stringify(message) }); + } +} + +afterEach(() => vi.unstubAllGlobals()); + +it("signs in as the agent with its owner's attestation, and publishes nothing", async () => { + const secret = generateSecretKey(); + const pubkey = getPublicKey(secret); + const auth = ["auth", "b".repeat(64), "", "f".repeat(128)]; + const posted: string[] = []; + vi.stubGlobal("WebSocket", Socket); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + if (init?.method !== "POST") + return Response.json({ self: "c".repeat(64) }); + posted.push(new URL(url).pathname); + return Response.json([]); + }), + ); + const relay = "wss://relay.example.test"; + const { session, relay: data } = await connectAgentSession({ + relay, + secret, + pubkey, + auth, + client: nodeClient({ + relay, + key: Buffer.from(secret).toString("hex"), + auth: JSON.stringify(auth), + root: "/tmp", + }), + scope: "scope", + }); + try { + const socket = Socket.last as Socket; + await socket.receive(["AUTH", "challenge"]); + const login = socket.sent.find((message) => message[0] === "AUTH")?.[1] as + | { id: string; pubkey: string; tags: string[][] } + | undefined; + expect(login?.pubkey).toBe(pubkey); + expect(verifyEvent(login as never)).toBe(true); + expect(login?.tags).toContainEqual(auth); + await socket.receive(["OK", login?.id, true, ""]); + await vi.waitFor(() => + expect(session.live.snapshot().status).toBe("connected"), + ); + expect(session.workflowAuthority).toBe("c".repeat(64)); + // The session has no writer, and its socket no publishing. + expect( + await session.publishAgentPresence( + "online", + [pubkey], + new AbortController().signal, + ), + ).toBe(false); + expect(socket.sent.filter((message) => message[0] === "EVENT")).toEqual([]); + expect(posted).not.toContain("/events"); + } finally { + data.disconnect(); + } +}); diff --git a/src/agent-host/session.ts b/src/agent-host/session.ts new file mode 100644 index 0000000000..30070b3391 --- /dev/null +++ b/src/agent-host/session.ts @@ -0,0 +1,134 @@ +// The agent's connection to its community on a standalone host: the app's own +// relay session (`createRelaySession`), signed in as the agent. It hears the +// agent's channels live, keeps their rosters and labels, and answers the +// agent's reads, as the owner's session does in the app. It is read-only: the +// agent publishes only through the host's custody, which checks each kind. +import type { EventTemplate, VerifiedEvent } from "nostr-tools"; +import { finalizeEvent } from "nostr-tools/pure"; +import type { NodeClient } from "../buzz-mcp/node"; +import { communityDestination } from "../features/communities/destination"; +import { audienceAuthorityCommunity } from "../features/sessions/audience-authority"; +import { + createRelaySession, + type RelaySession, +} from "../features/relay/session"; +import type { RelayData } from "../features/relay/service"; +import { + connectSignedTransport, + type ReadTransport, + type Signer, +} from "../features/relay/transport"; + +export type AgentSession = Readonly<{ + session: RelaySession; + /** The session as the app's relay service gives it, always ready. */ + relay: RelayData; +}>; + +/** The community's NIP-11 information. */ +export async function relayInfo(relay: string, signal?: AbortSignal) { + const response = await fetch(relay.replace(/^ws/, "http"), { + headers: { accept: "application/nostr+json" }, + signal: signal ?? null, + }); + if (!response.ok) + throw new Error(`Community information: ${response.status}`); + const info = (await response.json()) as Record | null; + if (!info || typeof info !== "object") + throw new Error("Invalid community information"); + return info; +} + +/** Connects as the agent. `scope` partitions what the app keeps for it. */ +export async function connectAgentSession( + options: Readonly<{ + relay: string; + secret: Uint8Array; + pubkey: string; + /** The owner's NIP-OA `auth` tag, which the agent's login carries. */ + auth?: readonly string[]; + client: NodeClient; + scope: string; + }>, +): Promise { + const { secret, pubkey, auth, client, scope } = options; + const origin = communityDestination(options.relay).url; + const info = await relayInfo(origin); + // As the app reads it (`connectNativeTransport`). + const author = info.self; + if (typeof author !== "string" || !/^[a-f0-9]{64}$/.test(author)) + throw new Error("The community did not give its identity"); + const extensions = Array.isArray(info.supported_extensions) + ? info.supported_extensions + : []; + const signer: Signer = { + getPublicKey: async () => pubkey, + signEvent: async (template: EventTemplate) => + finalizeEvent( + { + ...template, + tags: + template.kind === 22242 && auth + ? [...template.tags, [...auth]] + : template.tags, + }, + secret, + ) as VerifiedEvent, + request: (url, body, signal) => client.signedRequest(url, body, signal), + }; + // Nothing the session does publishes as the agent: it has no writer, and + // its live connection sends only the login and its subscriptions. + const { + writer: _writer, + subscribe, + ...signed + } = await connectSignedTransport(signer, origin, author); + const transport: ReadTransport = { + ...signed, + ...(subscribe + ? { + subscribe: (callbacks) => { + const { + publish: _publish, + publishPresence: _presence, + publishTyping: _typing, + ...traffic + } = subscribe(callbacks); + return traffic; + }, + } + : {}), + archiveAuthority: author, + ...(extensions.includes("nip-cl") + ? { channelIdentity: true as const } + : {}), + ...(audienceAuthorityCommunity(info.audience_authority) + ? { + audienceAuthorityCommunity: audienceAuthorityCommunity( + info.audience_authority, + ), + } + : {}), + }; + const store = createRelaySession(transport, { warm: false }); + await store.restore(); + const { session } = store; + const snapshot = Object.freeze({ + status: "ready" as const, + generation: 1, + scope, + origin, + session, + viewer: pubkey, + }); + return Object.freeze({ + session, + relay: { + snapshot: () => snapshot, + subscribe: () => () => {}, + retry: () => session.live.retry(), + disconnect: () => store.dispose(), + clearCache: async () => {}, + }, + }); +} diff --git a/src/agent-host/test-socket.ts b/src/agent-host/test-socket.ts deleted file mode 100644 index 168154b269..0000000000 --- a/src/agent-host/test-socket.ts +++ /dev/null @@ -1,76 +0,0 @@ -// A relay connection a test answers by hand, for the app's live client -// (`subscribeRelayTraffic`) as the agent host runs it. -type Filter = Readonly<{ kinds?: number[]; "#h"?: string[] }>; - -export class LiveSocket { - readyState = 1; - onmessage?: (event: { data: string }) => Promise; - onclose?: () => void; - onerror?: () => void; - readonly sent: unknown[][] = []; - send(text: string) { - this.sent.push(JSON.parse(text) as unknown[]); - } - close() { - this.readyState = 3; - this.onclose?.(); - } - async receive(message: unknown[]) { - await this.onmessage?.({ data: JSON.stringify(message) }); - } - /** Asks for the login, accepts it, and gives back the login event. */ - async auth() { - await this.receive(["AUTH", "challenge"]); - const login = this.sent.find((message) => message[0] === "AUTH")?.[1] as - | { id: string; pubkey: string; tags: string[][] } - | undefined; - if (!login) throw new Error("No login sent"); - await this.receive(["OK", login.id, true, ""]); - return login; - } - /** The requests still open, by wire id. */ - private open() { - const closed = new Set( - this.sent.filter((message) => message[0] === "CLOSE").map((m) => m[1]), - ); - return this.sent.filter( - (message) => message[0] === "REQ" && !closed.has(message[1]), - ) as [string, string, ...Filter[]][]; - } - /** Ends the stored events of every open request: each is live. */ - async established() { - for (const [, wire] of this.open()) await this.receive(["EOSE", wire]); - } - /** The wire ids of the open channel requests. */ - channelWires() { - return this.open() - .filter((message) => - (message.slice(2) as Filter[]).some((filter) => filter["#h"]), - ) - .map((message) => message[1]); - } - /** The channels the open requests hear. */ - channels() { - return [ - ...new Set( - this.open().flatMap((message) => - (message.slice(2) as Filter[]).flatMap( - (filter) => filter["#h"] ?? [], - ), - ), - ), - ].sort(); - } - /** The wire id of the open global request `name`: `membership` hears the - * relay's membership notices. */ - wire(name: "membership" | "profiles") { - const kind = name === "membership" ? 44100 : 0; - const found = this.open().find((message) => - (message.slice(2) as Filter[]).some((filter) => - filter.kinds?.includes(kind), - ), - ); - if (!found) throw new Error(`No ${name} request`); - return found[1]; - } -} diff --git a/src/buzz-mcp/node.ts b/src/buzz-mcp/node.ts index da5f391345..f39c1788e9 100644 --- a/src/buzz-mcp/node.ts +++ b/src/buzz-mcp/node.ts @@ -40,6 +40,12 @@ const SHOWN_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]; /** A BuzzClient whose memory writes give back the event they posted. */ export type NodeClient = Omit & { remember(slug: string, body: string, after: number): Promise; + /** POSTs `body` to one of the community's URLs, signed as the agent. */ + signedRequest( + url: string, + body: string, + signal?: AbortSignal, + ): Promise; }; export function nodeClient( @@ -89,7 +95,7 @@ export function nodeClient( return JSON.parse(text) as unknown; } - function send(path: string, body: string) { + function send(path: string, body: string, signal?: AbortSignal) { const url = `${base}${path}`; const nip98 = sign({ kind: 27235, @@ -109,6 +115,7 @@ export function nodeClient( ...(options.auth ? { "x-auth-tag": options.auth } : {}), }, body, + signal: signal ?? null, }); } @@ -129,6 +136,11 @@ export function nodeClient( return { pubkey, + signedRequest(url, body, signal) { + if (!url.startsWith(`${base}/`)) + throw new Error("Not a URL of the agent's community"); + return send(url.slice(base.length), body, signal); + }, query: async (filters) => eventsDto(await request("/query", JSON.stringify(filters))), async searchProfiles(query, limit) { diff --git a/src/features/agents/roster.ts b/src/features/agents/roster.ts new file mode 100644 index 0000000000..3e942cdcfc --- /dev/null +++ b/src/features/agents/roster.ts @@ -0,0 +1,47 @@ +// Whether an agent is in an event's channel, and whether that channel is its +// DM, by the relay-signed roster of a session that hears the channel: the +// owner's in the app, the agent's own on a standalone host. +import type { RelayEvent } from "../relay/events"; +import type { RelaySession } from "../relay/session"; + +/** The event's channel as the session lists it, if at all. */ +function channelOf(session: RelaySession | undefined, event: RelayEvent) { + const channel = event.tags.find((tag) => tag[0] === "h")?.[1]; + const channels = session?.channels; + if (!channel || !channels) return undefined; + return ( + channels.list().channels.find((item) => item.id === channel) ?? + channels.get?.(channel) + ); +} + +/** Whether the agent is in the event's channel, by its relay-signed roster. + * The stream may be another's, so a watch, even of "all", sees only what the + * agent itself could read; an event with no channel, or whose roster is + * unknown, wakes no watch. A roster only loaded from disk decides nothing + * (undefined): the agent may have been removed or added since it was saved, + * so the event waits until the relay confirms it. */ +export function rosterMember( + session: RelaySession | undefined, + agent: string, + event: RelayEvent, +): boolean | undefined { + const summary = channelOf(session, event); + if (summary?.cached) return undefined; + return !!summary?.members?.includes(agent); +} + +/** Whether the event's channel is a DM of exactly the agent and one other, + * by the same roster. A group DM, or one whose roster is unknown, is not. */ +export function rosterDm( + session: RelaySession | undefined, + agent: string, + event: RelayEvent, +) { + const summary = channelOf(session, event); + return ( + summary?.channelType === "dm" && + summary.members?.length === 2 && + summary.members.includes(agent) + ); +} diff --git a/src/features/agents/runner.test.ts b/src/features/agents/runner.test.ts index 6e35d7ccd0..2c0507066d 100644 --- a/src/features/agents/runner.test.ts +++ b/src/features/agents/runner.test.ts @@ -1,13 +1,6 @@ // AgentRunners through a host other than AgentsService, as the standalone // agent host (src/agent-host) gives it: the RunnerHost contract alone. -import { - finalizeEvent, - generateSecretKey, - getPublicKey, -} from "nostr-tools/pure"; import { expect, it, vi } from "vitest"; -import { listen } from "../../agent-host/listen"; -import { LiveSocket } from "../../agent-host/test-socket"; import type { RelayEvent } from "../relay/events"; import { createLocalAgentActivity } from "./local-activity"; import { AgentRunners, type RunnerHost } from "./runner"; @@ -165,79 +158,3 @@ it("releases the agent once its community is no longer live", async () => { expect(f.custody.release).toHaveBeenCalledWith(pubkey), ); }); - -it("runs every mention the standalone host's live client hears, through the runner's backlog", async () => { - const f = fixture(); - const secret = generateSecretKey(); - const viewer = getPublicKey(secret); - const sockets: LiveSocket[] = []; - const read = vi.fn(); - const listener = listen({ - relay: "wss://relay.example.test", - secret, - pubkey: viewer, - query: async () => [ - finalizeEvent( - { - kind: 39002, - created_at: Math.floor(Date.now() / 1000), - tags: [ - ["d", "channel"], - ["p", viewer], - ], - content: "", - }, - generateSecretKey(), - ) as unknown as RelayEvent, - ], - onEvents: (batch) => f.runners.dispatch(batch, () => true, 0), - onStatus: () => {}, - onChannels: read, - log: () => {}, - socket: () => { - const socket = new LiveSocket(); - sockets.push(socket); - return socket as unknown as WebSocket; - }, - }); - try { - await vi.waitFor(() => expect(read).toHaveBeenCalled()); - const socket = sockets.at(-1) as LiveSocket; - await socket.auth(); - const [wire] = socket.channelWires(); - // Signed, as the client checks. - const sender = generateSecretKey(); - const burst = Array.from( - { length: 40 }, - (_, index) => - finalizeEvent( - { - kind: 9, - created_at: Math.floor(Date.now() / 1000), - tags: [ - ["h", "channel"], - ["p", pubkey], - ], - content: `hello ${index}`, - }, - sender, - ) as unknown as RelayEvent, - ); - // What the subscription sends before it is live: a replay. - for (const event of burst) await socket.receive(["EVENT", wire, event]); - await socket.receive(["EOSE", wire]); - await vi.waitFor(() => { - f.runners.tick(); - expect(f.ran).toHaveLength(burst.length); - }); - expect( - new Set( - f.ran.map( - (delivery) => (delivery.trigger as { event: RelayEvent }).event.id, - ), - ), - ).toEqual(new Set(burst.map((event) => event.id))); - } finally { - listener.stop(); - } -}); diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index 611fe8e762..483ed88a60 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -8,6 +8,7 @@ import { isSynced, type Mode, resolveStart } from "./agent-start"; import { AgentRunners, type Connection, TIMEOUT_LIMIT_MS } from "./runner"; import { CoreMemoryCache } from "./core-memory-cache"; import { ownerReads } from "./reads"; +import { rosterDm, rosterMember } from "./roster"; // Agents: agents that are plugins from the start. A plugin registers an agent // type (its summary line, peek view, settings tabs and `run`); each agent made // from it has its own native-held key, an `agent-attention/v1` configuration the @@ -1235,8 +1236,10 @@ export class AgentsService extends Service implements Agents { ownershipChanged: () => this.notify(), ready: (pubkey) => this.ready(pubkey), agent: (pubkey) => this.agent(pubkey), - member: (agent, event) => this.member(agent, event), - dm: (agent, event) => this.dm(agent, event), + member: (agent, event) => + rosterMember(this.bindingOf(agent)?.session, agent.pubkey, event), + dm: (agent, event) => + rosterDm(this.bindingOf(agent)?.session, agent.pubkey, event), starting: (agent, channelId) => this.bindingOf(agent)?.session.channelWrites.starting(channelId), community: (agent) => this.bindingOf(agent), @@ -3301,37 +3304,6 @@ export class AgentsService extends Service implements Agents { private notify() { for (const listener of this.listeners) listener(); } - /** The event's channel as the agent's community knows it, if at all. */ - private channelOf(agent: Agent, event: RelayEvent) { - const channel = event.tags.find((tag) => tag[0] === "h")?.[1]; - const channels = this.bindingOf(agent)?.session.channels; - if (!channel || !channels) return undefined; - return ( - channels.list().channels.find((item) => item.id === channel) ?? - channels.get?.(channel) - ); - } - /** Whether the agent is in the event's channel, by its relay-signed roster. - * The stream is the owner's, so a watch, even of "all", sees only what the - * agent itself could read; an event with no channel, or whose roster is - * unknown, wakes no watch. A roster only loaded from disk decides nothing - * (undefined): the agent may have been removed or added since it was saved, - * so the event waits until the relay confirms it. */ - private member(agent: Agent, event: RelayEvent): boolean | undefined { - const summary = this.channelOf(agent, event); - if (summary?.cached) return undefined; - return !!summary?.members?.includes(agent.pubkey); - } - /** Whether the event's channel is a DM of exactly the agent and one other, - * by the same roster. A group DM, or one whose roster is unknown, is not. */ - private dm(agent: Agent, event: RelayEvent) { - const summary = this.channelOf(agent, event); - return ( - summary?.channelType === "dm" && - summary.members?.length === 2 && - summary.members.includes(agent.pubkey) - ); - } } /** An old Buzz team's copy for one owner in one community (`scope`), the same diff --git a/src/features/agents/solo-chat.ts b/src/features/agents/solo-chat.ts index 5494add818..1eed293c6a 100644 --- a/src/features/agents/solo-chat.ts +++ b/src/features/agents/solo-chat.ts @@ -1,3 +1,4 @@ +import type { RelaySession } from "../relay/session"; import type { RelayData } from "../relay/service"; /** Whether a native agent's mention continues a Solo chat: one @@ -14,7 +15,12 @@ export async function soloChat( const snapshot = relay()?.snapshot(); if (snapshot?.status !== "ready" || snapshot.viewer !== owner) return false; signal.throwIfAborted(); - const { channels } = snapshot.session; + return soloChannel(snapshot.session, channelId); +} + +/** Whether the session lists the channel as a Solo chat, by its label. */ +export function soloChannel(session: RelaySession, channelId: string) { + const { channels } = session; const list = channels.list(); const channel = channels.get?.(channelId) ?? From 03817729f7a9910fc047a856536babcaadce024d Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 23 Oct 2026 10:32:26 -0400 Subject: [PATCH 16/17] refactor(agents): one queue for record changes, in the app and the host The host had its own copy of the agents service's ordered change queue. Both now use RecordChanges: one agent's changes run in order, timer progress is queued behind them, and timers see progress not saved yet. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src/agent-host/host.ts | 66 ++++++------- src/features/agents/record-changes.ts | 132 ++++++++++++++++++++++++++ src/features/agents/service.ts | 130 +++++-------------------- 3 files changed, 182 insertions(+), 146 deletions(-) create mode 100644 src/features/agents/record-changes.ts diff --git a/src/agent-host/host.ts b/src/agent-host/host.ts index 814ff4d28e..8cb12220cb 100644 --- a/src/agent-host/host.ts +++ b/src/agent-host/host.ts @@ -27,6 +27,7 @@ import { nodeClient } from "../buzz-mcp/node"; import { CoreMemoryCache } from "../features/agents/core-memory-cache"; import { createLocalAgentActivity } from "../features/agents/local-activity"; import { type AgentReads, ownerReads } from "../features/agents/reads"; +import { RecordChanges } from "../features/agents/record-changes"; import { rosterDm, rosterMember } from "../features/agents/roster"; import { soloChannel } from "../features/agents/solo-chat"; import { communityDestination } from "../features/communities/destination"; @@ -46,7 +47,6 @@ import { readTeams, recordKey, setAttention, - withProgress, writeRecord, } from "../features/agents/store"; import { eventsDto, type RelayEvent } from "../features/relay/events"; @@ -225,21 +225,29 @@ export async function host(options: HostOptions): Promise<() => Promise> { }); return view; }; - /** Keeps `record` as the agent's: saved, when `write`. */ - const keep = async (record: AgentRecord, write = true) => { - mine(record.pubkey); - if (write) { - await writeRecord(storage, record); - saved = storage.get(recordKey(pubkey)); - } - stored = record; + /** Shows the agent's newest record to its runner. */ + const changed = () => { view = undefined; run.sync([agentView()]); }; // As the app's agents service: the record's changes run in order, each // from the record the one before it kept, and its timers wait for them. - let changes: Promise = Promise.resolve(); - let changing = 0; + const changes = new RecordChanges({ + saved: (agent) => (agent === pubkey ? recordOf() : undefined), + commit: async (agent, _, next) => { + mine(agent); + if (!next) throw new Error("This host cannot remove its agent"); + await writeRecord(storage, next); + saved = storage.get(recordKey(pubkey)); + stored = next; + }, + keep: (agent, next) => { + mine(agent); + stored = next; + }, + changed, + settled: () => runners.wake(pubkey), + }); // Whether the app deleted, erased or changed the agent: then this host // stops, and tells its caller why. let gone = false; @@ -296,35 +304,17 @@ export async function host(options: HostOptions): Promise<() => Promise> { mine(agent); return recordOf(); }, - change: (agent, apply) => { + change: async (agent, apply) => { mine(agent); - changing++; - const next = changes.then(async () => { - try { - const record = await apply(recordOf()); - await keep(record); - return record; - } finally { - if (!--changing) runners.wake(pubkey); - } - }); - changes = next.catch(() => {}); - return next; - }, - changing: () => changing > 0, - timers: () => recordOf().timers ?? {}, - // Timers wait while a change is pending, and a save here finishes as it - // is made, so progress applies to the newest record. Kept even when its - // save fails, so a timer does not run again at once. - progress: (agent, ran) => { - mine(agent); - const next = withProgress(recordOf(), ran); - if (!next) return; - keep(next).catch((error) => { - log(`Could not save the timers' progress: ${String(error)}`); - void keep(next, false); - }); + const record = await changes.change(agent, (saved) => + apply(saved ?? recordOf()), + ); + if (!record) throw new Error("No such agent on this host"); + return record; }, + changing: (agent) => changes.changing(agent), + timers: (agent) => changes.timers(agent), + progress: (agent, ran) => changes.progress(agent, ran), // Each run starts from its saved record, as the app's agents do while the // app binds no synced config to them: the synced copies are in the app's // web storage, which this host cannot read. diff --git a/src/features/agents/record-changes.ts b/src/features/agents/record-changes.ts new file mode 100644 index 0000000000..d0942ba27c --- /dev/null +++ b/src/features/agents/record-changes.ts @@ -0,0 +1,132 @@ +import { type AgentRecord, type TimerProgress, withProgress } from "./store"; + +const EMPTY = Object.freeze({}); + +/** Where the records are kept, for `RecordChanges`. */ +export type RecordStore = Readonly<{ + /** The agent's record as kept now. */ + saved: (pubkey: string) => AgentRecord | undefined; + /** Saves `next` over `base` (null removes it), then keeps it. */ + commit: ( + pubkey: string, + base: AgentRecord | undefined, + next: AgentRecord | null, + ) => Promise; + /** Keeps `next` without saving it. */ + keep: (pubkey: string, next: AgentRecord) => void; + /** A change was kept: tell the owner and the agent. */ + changed: () => void; + /** The agent has no changes left: its timers can run. */ + settled: (pubkey: string) => void; +}>; + +/** The changes to each agent's record. This is the only way a record + * changes: owner settings, the agent's attention tools, timer progress, a + * provider change and removal all send a change here. */ +export class RecordChanges { + /** Each agent's last change in its queue, until it settles. */ + private readonly changes = new Map>(); + /** How many changes other than timer progress each agent has queued. */ + private readonly counts = new Map(); + /** Each agent's timer progress that is not saved yet. */ + private readonly progressing = new Map(); + + constructor(private readonly store: RecordStore) {} + + /** Whether the agent has changes other than timer progress queued. */ + changing(pubkey: string) { + return this.counts.has(pubkey); + } + + /** Changes one agent's record. One agent's changes run in the order they + * are sent, each after the one before it is saved or has failed, so each + * one applies to the newest saved record, never to a copy another change + * replaces. `apply` gets that record (or undefined) and returns the next + * one, or null to remove it; it may throw to change nothing. The owner and + * the agent see a change once it is saved, and the promise settles then. + * When the save fails, the promise rejects and they keep the saved record, + * so a form keeps its unsaved changes. `saved` runs as the change is + * shown, before anyone is told. */ + change( + pubkey: string, + apply: ( + record: AgentRecord | undefined, + ) => AgentRecord | null | Promise, + saved?: (record: AgentRecord | null) => void, + ): Promise { + this.counts.set(pubkey, (this.counts.get(pubkey) ?? 0) + 1); + return this.queued(pubkey, async () => { + try { + const base = this.store.saved(pubkey); + const next = await apply(base); + await this.store.commit(pubkey, base, next); + saved?.(next); + this.store.changed(); + return next; + } finally { + const left = (this.counts.get(pubkey) ?? 1) - 1; + if (left) this.counts.set(pubkey, left); + else { + this.counts.delete(pubkey); + // Its timers waited for this change, saved or failed. + this.store.settled(pubkey); + } + } + }); + } + + /** Records that timers ran or started counting, as a change to their run + * state only. It applies to whatever the record is when its turn comes, so + * it never undoes a change sent before it: when a timer was rearmed or + * restarted meanwhile, its new deadline stays and only the occurrences it + * used are added. Timers see it at once (`timers`), and memory keeps it + * even when its save fails, so a timer does not run again at once. */ + progress(pubkey: string, ran: TimerProgress) { + const pending = this.progressing.get(pubkey) ?? []; + this.progressing.set(pubkey, [...pending, ran]); + void this.queued(pubkey, async () => { + try { + const base = this.store.saved(pubkey); + const next = base && withProgress(base, ran); + if (!base || !next) return; + await this.store.commit(pubkey, base, next).catch((error) => { + console.warn(`Agent ${pubkey} timer state was not saved`, error); + this.store.keep(pubkey, next); + }); + this.store.changed(); + } finally { + const left = (this.progressing.get(pubkey) ?? []).filter( + (entry) => entry !== ran, + ); + if (left.length) this.progressing.set(pubkey, left); + else this.progressing.delete(pubkey); + } + }); + } + + /** The agent's timers' run state as its timers go by: saved, with the + * progress still being saved. */ + timers(pubkey: string) { + const record = this.store.saved(pubkey); + const pending = this.progressing.get(pubkey); + if (!record || !pending) return record?.timers ?? EMPTY; + return ( + pending.reduce((next, ran) => withProgress(next, ran) ?? next, record) + .timers ?? EMPTY + ); + } + + /** Runs `step` after the agent's earlier changes have settled. */ + private queued(pubkey: string, step: () => Promise) { + const run = (this.changes.get(pubkey) ?? Promise.resolve()).then(step); + const tail = run.then( + () => {}, + () => {}, + ); + this.changes.set(pubkey, tail); + void tail.then(() => { + if (this.changes.get(pubkey) === tail) this.changes.delete(pubkey); + }); + return run; + } +} diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index 483ed88a60..14cfcd7c87 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -8,6 +8,7 @@ import { isSynced, type Mode, resolveStart } from "./agent-start"; import { AgentRunners, type Connection, TIMEOUT_LIMIT_MS } from "./runner"; import { CoreMemoryCache } from "./core-memory-cache"; import { ownerReads } from "./reads"; +import { RecordChanges } from "./record-changes"; import { rosterDm, rosterMember } from "./roster"; // Agents: agents that are plugins from the start. A plugin registers an agent // type (its summary line, peek view, settings tabs and `run`); each agent made @@ -109,9 +110,7 @@ import { writeRecord, writeTeams, writeUnfinished, - withProgress, type AgentRecord, - type TimerProgress, type SkippedObject, type Team, type Unfinished, @@ -1129,12 +1128,15 @@ export class AgentsService extends Service implements Agents { private identities: readonly AgentIdentity[] = []; /** Each agent's settings as saved, and as the owner and the agent see them. */ private records: Record; - /** Each agent's last change in its queue, until it settles. */ - private readonly changes = new Map>(); - /** How many changes other than timer progress each agent has queued. */ - private readonly changing = new Map(); - /** Each agent's timer progress that is not saved yet. */ - private readonly progressing = new Map(); + private readonly changes = new RecordChanges({ + saved: (pubkey) => this.records[pubkey], + commit: (pubkey, base, next) => this.commit(pubkey, base, next), + keep: (pubkey, next) => { + this.records = { ...this.records, [pubkey]: next }; + }, + changed: () => this.update(), + settled: (pubkey) => this.runners.wake(pubkey), + }); private teamList: readonly Team[]; private state: AgentsSnapshot; /** By origin, so each agent listens in its own community, selected or not. */ @@ -1275,7 +1277,7 @@ export class AgentsService extends Service implements Agents { return this.recordOf(identity); }, change: async (pubkey, apply) => { - const record = await this.change(pubkey, (saved) => { + const record = await this.changes.change(pubkey, (saved) => { const identity = this.live(pubkey); if (!identity) throw new Error("No such agent on this device"); return apply(saved ?? this.recordOf(identity)); @@ -1283,9 +1285,9 @@ export class AgentsService extends Service implements Agents { if (!record) throw new Error("No such agent on this device"); return record; }, - changing: (pubkey) => this.changing.has(pubkey), - timers: (pubkey) => this.timersOf(pubkey), - progress: (pubkey, ran) => this.progress(pubkey, ran), + changing: (pubkey) => this.changes.changing(pubkey), + timers: (pubkey) => this.changes.timers(pubkey), + progress: (pubkey, ran) => this.changes.progress(pubkey, ran), start: (agent) => { const { settings, instructions } = this.configBinding?.(agent) ?? {}; const merge = this.types().find( @@ -2020,7 +2022,7 @@ export class AgentsService extends Service implements Agents { record = { ...record, config: made.config }; if (carry && kind.adopt) record = { ...record, config: kind.adopt(carry, record.config) }; - await this.change(identity.pubkey, () => record); + await this.changes.change(identity.pubkey, () => record); } catch (error) { // The agent exists either way; unsaved settings start from the defaults. console.warn(`Agent ${identity.name} settings were not saved`, error); @@ -2069,7 +2071,7 @@ export class AgentsService extends Service implements Agents { change.attention || change.attentionEnabled !== undefined ) - await this.change(pubkey, (saved) => + await this.changes.change(pubkey, (saved) => changed(saved ?? this.recordOf(identity), change), ); const picture = @@ -2216,7 +2218,7 @@ export class AgentsService extends Service implements Agents { ); await native.setType(pubkey, type); try { - await this.change( + await this.changes.change( pubkey, (saved) => ({ ...(saved ?? this.recordOf(identity)), @@ -2660,85 +2662,6 @@ export class AgentsService extends Service implements Agents { return agent; } - /** Changes one agent's record. This is the only way a record changes: - * owner settings, the agent's attention tools, timer progress, a provider - * change and removal all send a change here. One agent's changes run in - * the order they are sent, each after the one before it is saved or has - * failed, so each one applies to the newest saved record, never to a copy - * another change replaces. `apply` gets that record (or undefined) and - * returns the next one, or null to remove it; it may throw to change - * nothing. The owner and the agent see a change once it is saved, and the - * promise settles then. When the save fails, the promise rejects and they - * keep the saved record, so a form keeps its unsaved changes. `saved` - * runs as the change is shown, before anyone is told. */ - private change( - pubkey: string, - apply: ( - record: AgentRecord | undefined, - ) => AgentRecord | null | Promise, - saved?: (record: AgentRecord | null) => void, - ): Promise { - this.changing.set(pubkey, (this.changing.get(pubkey) ?? 0) + 1); - return this.queued(pubkey, async () => { - try { - const base = this.records[pubkey]; - const next = await apply(base); - await this.commit(pubkey, base, next); - saved?.(next); - this.update(); - return next; - } finally { - const left = (this.changing.get(pubkey) ?? 1) - 1; - if (left) this.changing.set(pubkey, left); - else { - this.changing.delete(pubkey); - // Its timers waited for this change, saved or failed. - this.runners.wake(pubkey); - } - } - }); - } - /** Records that timers ran or started counting, as a change to their run - * state only. It applies to whatever the record is when its turn comes, so - * it never undoes a change sent before it: when a timer was rearmed or - * restarted meanwhile, its new deadline stays and only the occurrences it - * used are added. Timers see it at once (`timersOf`), and memory keeps it - * even when its save fails, so a timer does not run again at once. */ - private progress(pubkey: string, ran: TimerProgress) { - const pending = this.progressing.get(pubkey) ?? []; - this.progressing.set(pubkey, [...pending, ran]); - void this.queued(pubkey, async () => { - try { - const base = this.records[pubkey]; - const next = base && withProgress(base, ran); - if (!base || !next) return; - await this.commit(pubkey, base, next).catch((error) => { - console.warn(`Agent ${pubkey} timer state was not saved`, error); - this.records = { ...this.records, [pubkey]: next }; - }); - this.update(); - } finally { - const left = (this.progressing.get(pubkey) ?? []).filter( - (entry) => entry !== ran, - ); - if (left.length) this.progressing.set(pubkey, left); - else this.progressing.delete(pubkey); - } - }); - } - /** Runs `step` after the agent's earlier changes have settled. */ - private queued(pubkey: string, step: () => Promise) { - const run = (this.changes.get(pubkey) ?? Promise.resolve()).then(step); - const tail = run.then( - () => {}, - () => {}, - ); - this.changes.set(pubkey, tail); - void tail.then(() => { - if (this.changes.get(pubkey) === tail) this.changes.delete(pubkey); - }); - return run; - } /** Saves `next` over `base` in the agent's own key, then keeps it. Only * this agent's key: others may be another program's to change. */ private async commit( @@ -2753,17 +2676,6 @@ export class AgentsService extends Service implements Agents { const { [pubkey]: _, ...others } = this.records; this.records = next ? { ...others, [pubkey]: next } : others; } - /** The agent's timers' run state as its timers go by: saved, with the - * progress still being saved. */ - private timersOf(pubkey: string) { - const record = this.records[pubkey]; - const pending = this.progressing.get(pubkey); - if (!record || !pending) return record?.timers ?? EMPTY; - return ( - pending.reduce((next, ran) => withProgress(next, ran) ?? next, record) - .timers ?? EMPTY - ); - } private live(pubkey: string) { return this.identities.find( (identity) => identity.pubkey === pubkey && !identity.deleted, @@ -2903,9 +2815,11 @@ export class AgentsService extends Service implements Agents { (pubkey) => !live.has(pubkey), ); for (const pubkey of gone) - void this.change(pubkey, () => null).catch((error) => - console.warn(`Agent ${pubkey} settings were not removed`, error), - ); + void this.changes + .change(pubkey, () => null) + .catch((error) => + console.warn(`Agent ${pubkey} settings were not removed`, error), + ); // A deleted agent leaves its teams; the teams stay. if ( this.teamList.some((team) => From aee03fd6342dbf43bae57fb57b4fc9f3fe2fba71 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 23 Oct 2026 11:26:22 -0400 Subject: [PATCH 17/17] Run the standalone host's agent custody in the app's executable buzz-agent-host now starts ` agent-host `, a headless mode of the app's own executable that answers the app's agent commands as JSON lines over stdin and stdout, with the same Rust code the window uses. The agent's key no longer enters Node, and the Node copies of the claim, storage, identity and kind checks are gone. The helper adds login, read auth and memory reads as the agent, and Git access works. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- crates/agent-controller/src/app_agent.rs | 17 ++ .../agent-controller/src/app_agent/tests.rs | 28 +++ src-tauri/src/agent_host.rs | 192 ++++++++++++++++ src-tauri/src/agent_state.rs | 8 + src-tauri/src/app_agents.rs | 179 ++++++++++++++- src-tauri/src/lib.rs | 13 ++ src-tauri/src/main.rs | 5 + src-tauri/src/relay.rs | 4 +- src-tauri/src/relay/agent.rs | 182 +++++++++++++-- src/agent-host/claim.test.ts | 91 -------- src/agent-host/claim.ts | 72 ------ src/agent-host/custody.test.ts | 94 ++++++++ src/agent-host/custody.ts | 105 +++++++++ src/agent-host/host.test.ts | 68 +++--- src/agent-host/host.ts | 216 +++++++++--------- src/agent-host/identity.test.ts | 51 ----- src/agent-host/identity.ts | 142 ------------ src/agent-host/main.ts | 30 ++- src/agent-host/session.test.ts | 70 ++++-- src/agent-host/session.ts | 52 +++-- src/agent-host/storage.test.ts | 83 ------- src/agent-host/storage.ts | 131 ----------- src/buzz-mcp/client.ts | 2 +- src/buzz-mcp/node.ts | 23 +- src/features/agents/native.ts | 124 +++++----- src/features/agents/state.ts | 14 +- 26 files changed, 1131 insertions(+), 865 deletions(-) create mode 100644 src-tauri/src/agent_host.rs delete mode 100644 src/agent-host/claim.test.ts delete mode 100644 src/agent-host/claim.ts create mode 100644 src/agent-host/custody.test.ts create mode 100644 src/agent-host/custody.ts delete mode 100644 src/agent-host/identity.test.ts delete mode 100644 src/agent-host/identity.ts delete mode 100644 src/agent-host/storage.test.ts delete mode 100644 src/agent-host/storage.ts diff --git a/crates/agent-controller/src/app_agent.rs b/crates/agent-controller/src/app_agent.rs index ac6efd651b..a0fcdbadb4 100644 --- a/crates/agent-controller/src/app_agent.rs +++ b/crates/agent-controller/src/app_agent.rs @@ -205,6 +205,23 @@ impl AppAgent { } key.profile_auth(url, body) } + /// Its NIP-42 login to its own community, with its owner attestation. + pub fn login(&self, key: &Secret, relay: &str, challenge: &str) -> Result { + if relay != self.relay { + return Err("Agents sign in only to their community".into()); + } + if challenge.is_empty() || challenge.len() > 256 { + return Err("Invalid login challenge".into()); + } + let tags = self.attested( + key, + vec![ + vec!["relay".into(), relay.into()], + vec!["challenge".into(), challenge.into()], + ], + )?; + key.signed(22242, String::new(), tags) + } /// NIP-98 authorization for Git over HTTP to repository `repo` of `owner` /// in its community, and the URL it is bound to. Git reuses it for every /// request of one clone, fetch or push; the community honors it for a minute. diff --git a/crates/agent-controller/src/app_agent/tests.rs b/crates/agent-controller/src/app_agent/tests.rs index 6f5f188e7e..77d4887821 100644 --- a/crates/agent-controller/src/app_agent/tests.rs +++ b/crates/agent-controller/src/app_agent/tests.rs @@ -425,6 +425,34 @@ fn signs_git_auth_for_one_repository_in_its_own_community() { assert!(agent.git_auth(&other, &owner, "hive").is_err()); } +#[test] +fn signs_a_login_only_to_its_own_community() { + let dir = tempfile::tempdir().unwrap(); + let credentials = Memory::default(); + let (_, agent) = created(dir.path(), &credentials); + let key = agent.read_key(&credentials).unwrap(); + let saved: Vec = serde_json::from_str(&agent.auth).unwrap(); + let login = agent.login(&key, &agent.relay, "challenge").unwrap(); + assert_eq!(login["kind"], 22242); + assert_eq!(login["pubkey"], agent.pubkey); + let tags: Vec> = serde_json::from_value(login["tags"].clone()).unwrap(); + assert_eq!( + tags, + [ + vec!["relay".to_owned(), agent.relay.clone()], + vec!["challenge".to_owned(), "challenge".to_owned()], + saved + ] + ); + assert!(agent + .login(&key, "wss://elsewhere.test", "challenge") + .is_err()); + assert!(agent.login(&key, &agent.relay, "").is_err()); + assert!(agent.login(&key, &agent.relay, &"c".repeat(257)).is_err()); + let other = Secret::generate().unwrap(); + assert!(agent.login(&other, &agent.relay, "challenge").is_err()); +} + #[test] fn signs_uploads_and_memory_for_its_own_community_and_owner() { let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/agent_host.rs b/src-tauri/src/agent_host.rs new file mode 100644 index 0000000000..f1237d5e31 --- /dev/null +++ b/src-tauri/src/agent_host.rs @@ -0,0 +1,192 @@ +//! The app's custody of one agent without its window, for `buzz-agent-host`, +//! which runs the agent while the app is closed. It answers the requests the +//! WebView sends for its agents (`app_agents.rs`, `agent_state.rs`) with the +//! same code: one JSON line per request on stdin, one per answer on stdout. +//! Like a dev build, it runs the agent only while the installed app does not. +//! It exits when stdin closes, and the OS then lets go of its hosting locks. +use crate::agent_state::{self, AgentState}; +use crate::app_agents::{self, AppAgentHost}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::io::{BufRead, Write}; +use std::sync::{Arc, Mutex, PoisonError}; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Request { + id: u64, + command: String, + #[serde(default)] + args: Value, +} + +/// Serves custody of agent `pubkey` of the app `identifier` until stdin closes. +pub(crate) fn serve(identifier: &str, pubkey: &str) -> i32 { + let Some(data) = dirs::data_dir() else { + eprintln!("Could not resolve local agent storage"); + return 1; + }; + let agents = data.join(identifier).join(crate::sign_out::AGENTS); + let host = Arc::new(AppAgentHost::new( + Ok(agents.join("identities.json")), + Err("Not available without the app".into()), + false, + )); + let state = Arc::new(AgentState::new(Ok(agents.join("state")))); + let out = Arc::new(Mutex::new(std::io::stdout())); + let pubkey: Arc = pubkey.into(); + for line in std::io::stdin().lock().lines() { + let Ok(line) = line else { break }; + let (host, state, out, pubkey) = (host.clone(), state.clone(), out.clone(), pubkey.clone()); + tauri::async_runtime::spawn(async move { + let (id, answer) = match serde_json::from_str::(&line) { + Ok(request) => ( + json!(request.id), + answer(&host, &state, &pubkey, &request.command, &request.args).await, + ), + Err(_) => (Value::Null, Err("Invalid request".into())), + }; + let line = match answer { + Ok(value) => json!({ "id": id, "ok": value }), + Err(error) => json!({ "id": id, "error": error }), + }; + let mut out = out.lock().unwrap_or_else(PoisonError::into_inner); + let _ = writeln!(out, "{line}"); + let _ = out.flush(); + }); + } + 0 +} + +fn arg(args: &Value, name: &str) -> Result { + serde_json::from_value(args.get(name).cloned().unwrap_or(Value::Null)) + .map_err(|_| format!("Invalid {name}")) +} + +fn reply(value: impl Serialize) -> Result { + serde_json::to_value(value).map_err(|_| "Invalid answer".into()) +} + +async fn answer( + host: &AppAgentHost, + state: &AgentState, + agent: &str, + command: &str, + args: &Value, +) -> Result { + // Every agent's name, for team instructions; anything else only for its own. + if command != "app_agent_list" + && !command.starts_with("app_agent_state_") + && arg::(args, "pubkey")? != agent + { + return Err("Not this host's agent".into()); + } + let pubkey = agent.to_owned(); + match command { + "app_agent_list" => reply(app_agents::list(host).await?), + "app_agent_claim" => reply(app_agents::claim(host, pubkey).await?), + "app_agent_release" => { + app_agents::release(host, &pubkey); + Ok(Value::Null) + } + "app_agent_publish" => app_agents::publish(host, pubkey, arg(args, "event")?).await, + "app_agent_query" => app_agents::query(host, pubkey, arg(args, "filters")?).await, + "app_agent_search_profiles" => reply( + app_agents::find_profiles(host, pubkey, arg(args, "query")?, arg(args, "limit")?) + .await?, + ), + "app_agent_upload" => { + app_agents::upload_blob(host, pubkey, arg(args, "data")?, arg(args, "mime")?).await + } + "app_agent_media" => reply(app_agents::media(host, pubkey, arg(args, "url")?).await?), + "app_agent_git_auth" => { + app_agents::git_auth(host, pubkey, arg(args, "owner")?, arg(args, "repo")?).await + } + "app_agent_remember" => { + app_agents::remember( + host, + pubkey, + arg(args, "slug")?, + arg(args, "body")?, + arg(args, "after")?, + ) + .await + } + "app_agent_state_load" => reply(state.load()?), + "app_agent_state_set" => reply(agent_state::save( + state, + arg(args, "key")?, + arg(args, "value")?, + arg(args, "absent")?, + )?), + // What the app does as the owner, this host does as the agent: it + // hears the agent's community and reads its memory with the agent's key. + "app_agent_login" => { + app_agents::login(host, pubkey, arg(args, "relay")?, arg(args, "challenge")?).await + } + "app_agent_read_auth" => { + app_agents::read_auth(host, pubkey, arg(args, "url")?, arg(args, "body")?).await + } + "app_agent_memories" => app_agents::memories(host, pubkey, arg(args, "slug")?).await, + _ => Err("Not available without the app".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn serves_only_its_agent_and_the_shared_state() { + let dir = tempfile::tempdir().unwrap(); + let host = AppAgentHost::new( + Ok(dir.path().join("identities.json")), + Err("unused".into()), + false, + ); + let state = AgentState::new(Ok(dir.path().join("state"))); + let agent = "a".repeat(64); + let ask = |command: &'static str, args: Value| { + let (host, state, agent) = (&host, &state, agent.clone()); + async move { answer(host, state, &agent, command, &args).await } + }; + let other = json!({ "pubkey": "b".repeat(64) }); + for command in ["app_agent_claim", "app_agent_publish", "app_agent_login"] { + assert_eq!( + ask(command, other.clone()).await, + Err("Not this host's agent".into()) + ); + } + assert_eq!( + ask("app_agent_create", json!({ "pubkey": agent })).await, + Err("Not available without the app".into()) + ); + assert_eq!(ask("app_agent_list", Value::Null).await, Ok(json!([]))); + // A missing agent is refused by the app's own custody. + assert!(ask( + "app_agent_publish", + json!({ "pubkey": agent, "event": { "kind": 9, "content": "" } }) + ) + .await + .is_err()); + assert_eq!( + ask("app_agent_claim", json!({ "pubkey": agent })).await, + Ok(json!(true)) + ); + ask("app_agent_state_set", json!({ "key": "k", "value": "v" })) + .await + .unwrap(); + assert_eq!( + ask( + "app_agent_state_set", + json!({ "key": "k", "value": "w", "absent": true }) + ) + .await, + Ok(json!("v")) + ); + assert_eq!( + ask("app_agent_state_load", Value::Null).await, + Ok(json!({ "k": "v" })) + ); + } +} diff --git a/src-tauri/src/agent_state.rs b/src-tauri/src/agent_state.rs index 2d6fe40168..e89618aaeb 100644 --- a/src-tauri/src/agent_state.rs +++ b/src-tauri/src/agent_state.rs @@ -222,6 +222,14 @@ pub(crate) async fn app_agent_state_set( key: String, value: Option, absent: Option, +) -> Result, String> { + save(&state, key, value, absent) +} +pub(crate) fn save( + state: &AgentState, + key: String, + value: Option, + absent: Option, ) -> Result, String> { match (value, absent.unwrap_or(false)) { (Some(value), true) => state.add(&key, &value), diff --git a/src-tauri/src/app_agents.rs b/src-tauri/src/app_agents.rs index 4cf291a9f1..839cabd04e 100644 --- a/src-tauri/src/app_agents.rs +++ b/src-tauri/src/app_agents.rs @@ -273,6 +273,9 @@ pub(crate) struct Ported { pub(crate) async fn app_agent_list( state: tauri::State<'_, AppAgentHost>, ) -> Result, String> { + list(&state).await +} +pub(crate) async fn list(state: &AppAgentHost) -> Result, String> { let agents = state.agents.clone()?; blocking(move || Ok(agents.list()?.into_iter().map(Into::into).collect())).await } @@ -467,6 +470,9 @@ pub(crate) async fn app_agent_claim( state: tauri::State<'_, AppAgentHost>, pubkey: String, ) -> Result { + claim(&state, pubkey).await +} +pub(crate) async fn claim(state: &AppAgentHost, pubkey: String) -> Result { let hosting = state.hosting.clone(); blocking(move || hosting.claim(&pubkey)).await } @@ -474,7 +480,10 @@ pub(crate) async fn app_agent_claim( /// Lets another copy of the app run the agent, as when this one cannot. #[tauri::command] pub(crate) fn app_agent_release(state: tauri::State<'_, AppAgentHost>, pubkey: String) { - state.hosting.held().remove(&pubkey); + release(&state, &pubkey); +} +pub(crate) fn release(state: &AppAgentHost, pubkey: &str) { + state.hosting.held().remove(pubkey); } #[derive(Deserialize)] @@ -492,6 +501,13 @@ pub(crate) async fn app_agent_publish( state: tauri::State<'_, AppAgentHost>, pubkey: String, event: AppAgentEvent, +) -> Result { + publish(&state, pubkey, event).await +} +pub(crate) async fn publish( + state: &AppAgentHost, + pubkey: String, + event: AppAgentEvent, ) -> Result { let (agent, key) = state.key(state.agent(pubkey).await?).await?; let signed = agent.sign(&key, event.kind, event.content, event.tags)?; @@ -539,6 +555,13 @@ pub(crate) async fn app_agent_query( state: tauri::State<'_, AppAgentHost>, pubkey: String, filters: Vec, +) -> Result { + query(&state, pubkey, filters).await +} +pub(crate) async fn query( + state: &AppAgentHost, + pubkey: String, + filters: Vec, ) -> Result { if filters.is_empty() || filters.len() > 8 { return Err("Agents read with one to eight filters".into()); @@ -594,6 +617,14 @@ pub(crate) async fn app_agent_search_profiles( pubkey: String, query: String, limit: u32, +) -> Result, String> { + find_profiles(&state, pubkey, query, limit).await +} +pub(crate) async fn find_profiles( + state: &AppAgentHost, + pubkey: String, + query: String, + limit: u32, ) -> Result, String> { let (agent, key) = state.key(state.agent(pubkey).await?).await?; search_profiles(&agent, &key, &query, limit).await @@ -650,6 +681,14 @@ pub(crate) async fn app_agent_upload( pubkey: String, data: String, mime: String, +) -> Result { + upload_blob(&state, pubkey, data, mime).await +} +pub(crate) async fn upload_blob( + state: &AppAgentHost, + pubkey: String, + data: String, + mime: String, ) -> Result { use base64::Engine; // The type is a hint: the community sniffs the bytes and decides what it @@ -714,6 +753,14 @@ pub(crate) async fn app_agent_git_auth( pubkey: String, owner: String, repo: String, +) -> Result { + git_auth(&state, pubkey, owner, repo).await +} +pub(crate) async fn git_auth( + state: &AppAgentHost, + pubkey: String, + owner: String, + repo: String, ) -> Result { use base64::Engine; let (agent, key) = state.key(state.agent(pubkey).await?).await?; @@ -758,6 +805,13 @@ pub(crate) async fn app_agent_media( state: tauri::State<'_, AppAgentHost>, pubkey: String, url: String, +) -> Result { + media(&state, pubkey, url).await +} +pub(crate) async fn media( + state: &AppAgentHost, + pubkey: String, + url: String, ) -> Result { use base64::Engine; let dir = std::env::temp_dir().join("buzz-agent-media"); @@ -862,12 +916,85 @@ pub(crate) async fn app_agent_remember( slug: String, body: String, after: u64, +) -> Result { + remember(&state, pubkey, slug, body, after).await +} +pub(crate) async fn remember( + state: &AppAgentHost, + pubkey: String, + slug: String, + body: String, + after: u64, ) -> Result { let (agent, key) = state.key(state.agent(pubkey).await?).await?; let signed = agent.memory(&key, &slug, &body, after)?; post(&agent, &key, signed).await } +/// The agent's login to its own community's live connection, for a host +/// that hears its community as the agent. +pub(crate) async fn login( + state: &AppAgentHost, + pubkey: String, + relay: String, + challenge: String, +) -> Result { + let (agent, key) = state.key(state.agent(pubkey).await?).await?; + agent.login(&key, &relay, &challenge) +} + +/// The headers that post `body` to the agent's community as the agent: a +/// read (`/query`) or a profile name search (`/profiles/search`) only, so the +/// caller can send it and read the whole response itself. +pub(crate) async fn read_auth( + state: &AppAgentHost, + pubkey: String, + url: String, + body: String, +) -> Result { + let (agent, key) = state.key(state.agent(pubkey).await?).await?; + if url != agent.query_url() && url != agent.profile_search_url() { + return Err("Agents read only from their community".into()); + } + Ok(serde_json::json!({ + "authorization": authorization(agent.http_auth(&key, &url, body.as_bytes())?)?, + "authTag": agent.auth, + })) +} + +/// The agent's memory, read as the agent: every entry it wrote for its owner, +/// or only entry `slug`. +pub(crate) async fn memories( + state: &AppAgentHost, + pubkey: String, + slug: Option, +) -> Result { + let (agent, key) = state.key(state.agent(pubkey).await?).await?; + let secret = zeroize::Zeroizing::new( + nostr::key::SecretKey::from_hex(&key.hex()) + .map_err(|_| "Invalid identity")? + .to_secret_bytes(), + ); + crate::relay::own_memories_read( + secret, + agent.pubkey.clone(), + agent.owner.clone(), + slug, + |body| { + let (agent, key) = (&agent, &key); + async move { + let (status, body) = + signed_read(agent, key, agent.query_url(), body.into_bytes()).await?; + Ok(( + status.as_u16(), + String::from_utf8(body).map_err(|_| "Memory read failed")?, + )) + } + }, + ) + .await +} + /// One client for every agent request, so frequent small posts such as /// typing pulses reuse a connection instead of opening a new one each time. /// Each request sets its own timeout. @@ -1226,6 +1353,56 @@ mod tests { served.await.unwrap(); } + #[tokio::test] + async fn a_host_reads_and_signs_in_as_its_agent_but_posts_nothing() { + let dir = tempfile::tempdir().unwrap(); + let key = Secret::generate().unwrap(); + let mut agent = agent_at("wss://relay.example".into(), &key); + agent.auth = r#"["auth","owner","","sig"]"#.into(); + std::fs::write( + dir.path().join("identities.json"), + serde_json::to_vec(&[&agent]).unwrap(), + ) + .unwrap(); + let host = AppAgentHost { + agents: Ok(AppAgents::open(dir.path().join("identities.json"))), + old: Err("unused".into()), + credentials: Arc::new(Busy(key.hex().to_string(), AtomicU32::new(0))), + keys: Arc::default(), + profiles: Arc::default(), + hosting: Arc::new(hosting(Err("unused".into()), false)), + }; + let pubkey = agent.pubkey.clone(); + for url in [agent.query_url(), agent.profile_search_url()] { + let auth = read_auth(&host, pubkey.clone(), url, "[]".into()) + .await + .unwrap(); + assert!(auth["authorization"] + .as_str() + .unwrap() + .starts_with("Nostr ")); + assert_eq!(auth["authTag"], agent.auth); + } + // Its writes go through custody, which checks each kind. + assert!( + read_auth(&host, pubkey.clone(), agent.events_url(), "{}".into()) + .await + .is_err() + ); + let signed = login( + &host, + pubkey.clone(), + agent.relay.clone(), + "challenge".into(), + ) + .await + .unwrap(); + assert_eq!(signed["kind"], 22242); + assert!(login(&host, pubkey, "wss://elsewhere".into(), "c".into()) + .await + .is_err()); + } + fn hosting(dir: Result, packaged: bool) -> Hosting { Hosting { dir, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1f04427e09..b0fbda9cb6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ use browser::{ browser_action, browser_attach, browser_detach, browser_navigate, browser_set_bounds, browser_status, }; +mod agent_host; mod agent_state; mod app_agents; mod decisions; @@ -628,6 +629,18 @@ fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen ] } #[cfg_attr(mobile, tauri::mobile_entry_point)] +/// Serves custody of one agent for `buzz-agent-host`; see `agent_host`. +pub fn agent_host(pubkey: Option) -> i32 { + let Some(pubkey) = pubkey.filter(|key| { + key.len() == 64 && key.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) + }) else { + eprintln!("Usage: agent-host "); + return 2; + }; + let identifier = app_context::().config().identifier.clone(); + agent_host::serve(&identifier, &pubkey) +} + pub fn run() { let context = app_context(); // Reject unsafe test launch settings before any identity or pending erase work. diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index caba166905..4762b64ce3 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,5 +1,10 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + // `buzz-agent-host` runs an agent with the app closed through this. + let mut args = std::env::args().skip(1); + if args.next().as_deref() == Some("agent-host") { + std::process::exit(buzz_foundation_lib::agent_host(args.next())); + } buzz_foundation_lib::run(); } diff --git a/src-tauri/src/relay.rs b/src-tauri/src/relay.rs index 6394118ad4..beeaad08b8 100644 --- a/src-tauri/src/relay.rs +++ b/src-tauri/src/relay.rs @@ -3,8 +3,8 @@ pub(crate) mod agent; mod huddle; use crate::identity::{EventTemplate, IdentityHost}; pub(crate) use agent::{ - relay_agent_library, relay_agent_log_proof, relay_agent_memories_read, relay_agent_observer, - relay_agent_resolve, + own_memories_read, relay_agent_library, relay_agent_log_proof, relay_agent_memories_read, + relay_agent_observer, relay_agent_resolve, }; use base64::{engine::general_purpose::STANDARD, Engine}; pub(crate) use huddle::{huddle_close, huddle_open, huddle_pcm, huddle_touch, Huddles}; diff --git a/src-tauri/src/relay/agent.rs b/src-tauri/src/relay/agent.rs index 0f1582ba3d..43a9ba777b 100644 --- a/src-tauri/src/relay/agent.rs +++ b/src-tauri/src/relay/agent.rs @@ -427,6 +427,40 @@ pub(crate) async fn relay_agent_memories_read( }) }) }; + memory_listing( + budget, + filter, + slug, + |body| { + let url = url.clone(); + let host = host.inner(); + async move { + let response = + send(host, url, "POST", Some(body), true, MEMORY_RESPONSE_BYTES).await?; + Ok((response.status, response.body)) + } + }, + decode, + ) + .await +} + +/// The memory listing `filter` selects, paged with `send` (one `/query` body +/// to the status and body it got) and decoded with `decode`; only entry `slug` +/// when given. +async fn memory_listing( + budget: MemoryBudget, + filter: Value, + slug: Option, + send: S, + decode: D, +) -> Result +where + S: Fn(String) -> F, + F: std::future::Future>, + D: Fn(Vec>, MemoryDecoding, std::time::Instant) -> G, + G: std::future::Future>, +{ let mut listing = read_memories( budget, |cursor| { @@ -435,31 +469,19 @@ pub(crate) async fn relay_agent_memories_read( page["until"] = json!(until); page["before_id"] = json!(before_id); } - let url = url.clone(); - let host = host.inner(); + let sent = send(json!([page]).to_string()); async move { - let response = send( - host, - url, - "POST", - Some(json!([page]).to_string()), - true, - MEMORY_RESPONSE_BYTES, - ) - .await?; - if response.status == 429 { + let (status, body) = sent.await?; + if status == 429 { return Ok(MemoryPage::Limited); } - if response.status == 401 || response.status == 403 { + if status == 401 || status == 403 { return Err("MemoryDenied".into()); } - if response.status != 200 || response.body.len() > MEMORY_RESPONSE_BYTES { + if status != 200 || body.len() > MEMORY_RESPONSE_BYTES { return Err("Memory read failed".into()); } - Ok(MemoryPage::Records( - memory_wire_records(&response.body)?, - response.body.len(), - )) + Ok(MemoryPage::Records(memory_wire_records(&body)?, body.len())) } }, decode, @@ -473,6 +495,63 @@ pub(crate) async fn relay_agent_memories_read( Ok(listing) } +/// The agent's own memory, read with its key as its owner reads it with +/// theirs: the entries it wrote for `owner`, paged with `send` as the agent. +pub(crate) async fn own_memories_read( + secret: zeroize::Zeroizing<[u8; 32]>, + agent: String, + owner: String, + slug: Option, + send: S, +) -> Result +where + S: Fn(String) -> F, + F: std::future::Future>, +{ + if !key(&agent) + || !key(&owner) + || agent == owner + || slug.as_deref().is_some_and(|name| !self::slug(name)) + { + return Err("Invalid memory target".into()); + } + let mut filter = json!({"kinds":[30174], "authors":[agent], "#p":[owner], "limit":MEMORY_PAGE}); + if let Some(name) = &slug { + let secret = NostrSecretKey::from_slice(&secret[..]).map_err(|_| "Invalid identity")?; + let peer = NostrPublicKey::from_hex(&owner).map_err(|_| "Invalid memory target")?; + filter["#d"] = json!([memory_address( + &ConversationKey::derive(&secret, &peer).map_err(|_| "Invalid memory target")?, + name, + )?]); + } + let secret = std::sync::Arc::new(secret); + memory_listing( + MemoryBudget::new(), + filter, + slug, + send, + |records, decoder, stop| { + let (secret, owner, agent) = (secret.clone(), owner.clone(), agent.clone()); + async move { + tokio::task::spawn_blocking(move || { + decode_memory_events( + &decoder, + &secret, + &owner, + &owner, + &agent, + &records, + || std::time::Instant::now() >= stop, + ) + }) + .await + .map_err(|_| "Memory read failed")? + } + }, + ) + .await +} + type MemoryDecoding = std::sync::Arc>; /// How long decoding a page fetched in time may run past the read's deadline; @@ -660,7 +739,17 @@ fn decode_memory_events( } let secret_key = NostrSecretKey::from_slice(secret).map_err(|_| "Invalid identity")?; - let public_key = NostrPublicKey::from_hex(agent).map_err(|_| "Invalid memory target")?; + // The owner decrypts with its key and the agent's; the agent itself with + // its key and the owner's. + let own = Keypair::from_secret_key( + &Secp256k1::signing_only(), + &secp256k1::SecretKey::from_byte_array(*secret).map_err(|_| "Invalid identity")?, + ) + .x_only_public_key() + .0 + .to_string(); + let peer = if own == agent { viewer } else { agent }; + let public_key = NostrPublicKey::from_hex(peer).map_err(|_| "Invalid memory target")?; let conversation = ConversationKey::derive(&secret_key, &public_key).map_err(|_| "Invalid memory target")?; let lock = || decoder.lock().unwrap_or_else(|e| e.into_inner()); @@ -1013,6 +1102,61 @@ mod tests { assert_ne!(agent, owner); } + #[tokio::test] + async fn an_agent_reads_its_own_memory_with_its_key() { + let agent_key = buzz_agent_controller::Secret::generate().unwrap(); + let agent = agent_key.pubkey().to_owned(); + let owner = signed_event(&[2; 32], 1, vec![], String::new()).pubkey; + let events = [("core", "my core"), ("mem/one", "my entry")] + .into_iter() + .map(|(slug, body)| { + agent_key + .memory_event(&owner, slug, body, 1_700_000_001) + .unwrap() + }) + .collect::>(); + let secret = || { + zeroize::Zeroizing::new( + NostrSecretKey::from_hex(&agent_key.hex()) + .unwrap() + .to_secret_bytes(), + ) + }; + let sent = std::sync::Mutex::new(Vec::::new()); + let send = |body: String| { + sent.lock() + .unwrap() + .push(serde_json::from_str(&body).unwrap()); + let page = json!(events).to_string(); + async move { Ok((200, page)) } + }; + let listing = own_memories_read(secret(), agent.clone(), owner.clone(), None, &send) + .await + .unwrap(); + assert_eq!(listing["entries"][0]["body"], "my core"); + assert_eq!(listing["entries"][1]["body"], "my entry"); + let filter = sent.lock().unwrap()[0][0].clone(); + assert_eq!(filter["authors"], json!([agent])); + assert_eq!(filter["#p"], json!([owner])); + // One entry is read by the address its owner would read it by. + let listing = own_memories_read( + secret(), + agent.clone(), + owner.clone(), + Some("mem/one".into()), + &send, + ) + .await + .unwrap(); + assert_eq!(listing["entries"].as_array().unwrap().len(), 1); + assert_eq!(sent.lock().unwrap()[1][0]["#d"][0], events[1]["tags"][0][1]); + assert!( + own_memories_read(secret(), agent.clone(), agent, None, &send) + .await + .is_err() + ); + } + #[test] fn encrypted_memory_and_observer_fixtures() { let (viewer_secret, viewer) = fixture(); diff --git a/src/agent-host/claim.test.ts b/src/agent-host/claim.test.ts deleted file mode 100644 index 74ceb67b1d..0000000000 --- a/src/agent-host/claim.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -// @vitest-environment node -import { type ChildProcess, spawn } from "node:child_process"; -import { closeSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { Claims, hold } from "./claim"; - -const agent = "ab".repeat(32); -let dir: string; -const claims: Claims[] = []; -const children: ChildProcess[] = []; -const make = () => { - const made = new Claims(dir); - claims.push(made); - return made; -}; - -/** Another process that holds `path` with flock(2), as the app's - * `File::try_lock` does, until it is killed. */ -async function flockHolder(path: string, mode: "LOCK_EX" | "LOCK_SH") { - const child = spawn( - "python3", - [ - "-c", - `import fcntl,sys,time\nf=open(sys.argv[1],"a")\nfcntl.flock(f,fcntl.${mode})\nprint("held",flush=True)\ntime.sleep(60)`, - path, - ], - { stdio: ["ignore", "pipe", "inherit"] }, - ); - children.push(child); - await new Promise((resolve, reject) => { - child.stdout?.once("data", () => resolve()); - child.once("exit", () => reject(new Error("The holder did not start"))); - }); - return child; -} - -beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), "agent-host-claim-")); -}); -afterEach(() => { - for (const made of claims.splice(0)) made.dispose(); - for (const child of children.splice(0)) child.kill("SIGKILL"); - rmSync(dir, { recursive: true, force: true }); -}); - -describe.skipIf(process.platform !== "darwin")("on macOS", () => { - it("lets one copy run the agent until it lets go", async () => { - const first = make(); - const second = make(); - expect(await first.claim(agent)).toBe(true); - expect(await first.claim(agent)).toBe(true); - expect(await second.claim(agent)).toBe(false); - await first.release(agent); - expect(await second.claim(agent)).toBe(true); - }); - - it("lets the agent go while the installed app holds it", async () => { - const host = make(); - expect(await host.claim(agent)).toBe(true); - const packaged = hold(join(dir, `${agent}.packaged`), "exclusive"); - expect(packaged).toBeDefined(); - expect(await host.claim(agent)).toBe(false); - // The lock is free again, so another copy can take over after the app. - if (packaged !== undefined) closeSync(packaged); - expect(await make().claim(agent)).toBe(true); - }); - - it("shares the app's flock(2) locks, and gets them back when a holder dies", async () => { - const host = make(); - // The installed app holds the agent. - const app = await flockHolder(join(dir, `${agent}.packaged`), "LOCK_EX"); - expect(await host.claim(agent)).toBe(false); - app.kill("SIGKILL"); - await expect.poll(() => host.claim(agent)).toBe(true); - await host.release(agent); - // A dev build holds it. - const dev = await flockHolder(join(dir, `${agent}.lock`), "LOCK_EX"); - expect(await host.claim(agent)).toBe(false); - dev.kill("SIGKILL"); - await expect.poll(() => host.claim(agent)).toBe(true); - }); -}); - -it.skipIf(process.platform === "darwin")( - "runs no agent where it cannot check", - async () => { - await expect(make().claim(agent)).rejects.toThrow(/only on macOS/); - }, -); diff --git a/src/agent-host/claim.ts b/src/agent-host/claim.ts deleted file mode 100644 index 63f2c934fa..0000000000 --- a/src/agent-host/claim.ts +++ /dev/null @@ -1,72 +0,0 @@ -// The claim that lets one copy on this machine run each agent, made the way a -// dev build of the desktop app makes it (`Hosting::claim` in app_agents.rs): -// while the installed app holds `.packaged` this host lets the agent -// go; otherwise it runs the agent only while it holds `.lock`. Both are -// flock(2) locks in the app's `hosts` directory, so the OS lets go of them when -// a holder exits, however it exits. -// -// Node has no flock call, but macOS open(2) takes the same lock when it is -// given O_EXLOCK or O_SHLOCK, and Node passes those flags through. The lock is -// this process's own, held while its file stays open. -import { closeSync, constants, mkdirSync, openSync } from "node:fs"; -import { join } from "node:path"; - -/** macOS ; Node does not name them. */ -const O_SHLOCK = 0x10; -const O_EXLOCK = 0x20; - -/** Takes the flock at `path` without waiting: the open file that holds it, or - * undefined when another holder has it. */ -export function hold( - path: string, - mode: "shared" | "exclusive", -): number | undefined { - if (process.platform !== "darwin") - throw new Error("The agent host can check who runs an agent only on macOS"); - try { - return openSync( - path, - constants.O_RDWR | - constants.O_CREAT | - constants.O_NONBLOCK | - (mode === "shared" ? O_SHLOCK : O_EXLOCK), - 0o600, - ); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "EAGAIN") return undefined; - throw error; - } -} - -/** One host's claims, kept for as long as it runs. */ -export class Claims { - private readonly held = new Map(); - constructor(private readonly dir: string) {} - - /** Whether this host runs the agent now; asked on every wake, so it takes - * the agent over within a tick of the holder quitting or letting go. */ - async claim(pubkey: string): Promise { - if (!/^[0-9a-f]{64}$/.test(pubkey)) throw new Error("Invalid agent"); - mkdirSync(this.dir, { recursive: true }); - const packaged = hold(join(this.dir, `${pubkey}.packaged`), "shared"); - if (packaged === undefined) { - await this.release(pubkey); - return false; - } - closeSync(packaged); - if (!this.held.has(pubkey)) { - const lock = hold(join(this.dir, `${pubkey}.lock`), "exclusive"); - if (lock !== undefined) this.held.set(pubkey, lock); - } - return this.held.has(pubkey); - } - async release(pubkey: string): Promise { - const lock = this.held.get(pubkey); - if (lock !== undefined) closeSync(lock); - this.held.delete(pubkey); - } - dispose() { - for (const lock of this.held.values()) closeSync(lock); - this.held.clear(); - } -} diff --git a/src/agent-host/custody.test.ts b/src/agent-host/custody.test.ts new file mode 100644 index 0000000000..65ad4c00d3 --- /dev/null +++ b/src/agent-host/custody.test.ts @@ -0,0 +1,94 @@ +// @vitest-environment node +import type { ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import { PassThrough } from "node:stream"; +import { expect, it, vi } from "vitest"; +import { appExecutable, startCustody } from "./custody"; + +/** The app's executable in `agent-host` mode, answered by `answer`. */ +function fakeApp( + answer: (request: { id: number; command: string; args?: unknown }) => unknown, +) { + const child = Object.assign(new EventEmitter(), { + stdin: new PassThrough(), + stdout: new PassThrough(), + }); + const started: string[][] = []; + createInterface({ input: child.stdin }).on("line", (line) => { + const request = JSON.parse(line) as { id: number; command: string }; + child.stdout.write(`${JSON.stringify(answer(request))}\n`); + }); + child.stdin.on("finish", () => child.emit("exit", 0, null)); + const start = (app: string, args: string[]) => { + started.push([app, ...args]); + return child as unknown as ChildProcess; + }; + return { child, start, started }; +} + +it("asks the app's executable for one agent's custody, one line per request", async () => { + const onExit = vi.fn(); + const app = fakeApp(({ id, command, args }) => + command === "app_agent_claim" + ? { id, ok: true } + : { id, error: `No ${command} ${JSON.stringify(args)}` }, + ); + const custody = startCustody("/app", "a".repeat(64), onExit, app.start); + expect(app.started).toEqual([["/app", "agent-host", "a".repeat(64)]]); + await expect( + custody.call("app_agent_claim", { pubkey: "a".repeat(64) }), + ).resolves.toBe(true); + await expect(custody.call("app_agent_create", { x: 1 })).rejects.toThrow( + 'No app_agent_create {"x":1}', + ); + await custody.close(); + // Closing it is not its stopping on its own. + expect(onExit).not.toHaveBeenCalled(); + await expect(custody.call("app_agent_claim")).rejects.toThrow("stopped"); +}); + +it("fails what is pending and tells the host when it stops on its own", async () => { + const onExit = vi.fn(); + const app = fakeApp(() => undefined); + app.child.stdout.write = () => true; + const custody = startCustody("/app", "a".repeat(64), onExit, app.start); + const pending = custody.call("app_agent_claim"); + app.child.emit("exit", null, "SIGKILL"); + await expect(pending).rejects.toThrow( + "The app's agent custody stopped (SIGKILL)", + ); + expect(onExit).toHaveBeenCalledWith( + "The app's agent custody stopped (SIGKILL)", + ); +}); + +it("runs only Buzz Desktop's executable", () => { + const dir = mkdtempSync(join(tmpdir(), "agent-host-app-")); + const bundle = (identifier: string) => { + const path = join(dir, identifier, "Buzz.app"); + mkdirSync(join(path, "Contents/MacOS"), { recursive: true }); + writeFileSync( + join(path, "Contents/Info.plist"), + `CFBundleExecutablebuzz-foundationCFBundleIdentifier${identifier}`, + ); + return path; + }; + const desktop = bundle("dev.local.buzz.foundation"); + expect(appExecutable({ BUZZ_APP: desktop })).toBe( + join(desktop, "Contents/MacOS/buzz-foundation"), + ); + expect(() => appExecutable({ BUZZ_APP: bundle("xyz.block.buzz") })).toThrow( + "is not Buzz Desktop", + ); + // An executable, as a dev build's, is taken as it is. + const executable = join(desktop, "Contents/MacOS/buzz-foundation"); + writeFileSync(executable, ""); + expect(appExecutable({ BUZZ_APP: executable })).toBe(executable); + expect(() => appExecutable({ BUZZ_APP: join(dir, "missing") })).toThrow( + "Buzz is not installed", + ); +}); diff --git a/src/agent-host/custody.ts b/src/agent-host/custody.ts new file mode 100644 index 0000000000..e0a7976dab --- /dev/null +++ b/src/agent-host/custody.ts @@ -0,0 +1,105 @@ +// The app's custody of the agent, served by the installed app's own executable +// (` agent-host `, src-tauri agent_host.rs) without its window. +// The agent's key, its saved identity, its hosting locks and its storage stay +// with the app's code; this host asks for one signed request, read or write +// at a time, with the commands the app's WebView uses (`agentsNative`). +import { type ChildProcess, spawn } from "node:child_process"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; +import type { NativeCall } from "../features/agents/native"; + +/** The app's identifier: installed and dev builds share it, and its data. */ +const APP = "dev.local.buzz.foundation"; + +/** The app's executable: `BUZZ_APP`, a bundle or an executable, or else the + * installed app. A bundle must be this app, not another with its name. */ +export function appExecutable(env = process.env) { + const path = env.BUZZ_APP ?? "/Applications/Buzz.app"; + if (!existsSync(path)) throw new Error(`Buzz is not installed at ${path}`); + if (!statSync(path).isDirectory()) return path; + const plist = readFileSync(join(path, "Contents/Info.plist"), "utf8"); + const value = (key: string) => + new RegExp(`${key}\\s*([^<]*)`).exec( + plist, + )?.[1]; + if (value("CFBundleIdentifier") !== APP) + throw new Error(`${path} is not Buzz Desktop`); + const executable = value("CFBundleExecutable"); + if (!executable) throw new Error(`${path} has no executable`); + return join(path, "Contents/MacOS", executable); +} + +export type Custody = Readonly<{ + call: NativeCall; + /** Ends custody; the app's code lets go of the agent's locks as it exits. */ + close(): Promise; +}>; + +/** Starts the app's custody of agent `pubkey`. `onExit` hears it stop on its + * own: the agent is then no longer held, so it must stop running. */ +export function startCustody( + app: string, + pubkey: string, + onExit: (reason: string) => void, + start: (app: string, args: string[]) => ChildProcess = (app, args) => + // Its own process group: a Ctrl-C stops the host, which then stops it. + spawn(app, args, { stdio: ["pipe", "pipe", "inherit"], detached: true }), +): Custody { + const child = start(app, ["agent-host", pubkey]); + const pending = new Map< + number, + { resolve(value: unknown): void; reject(error: Error): void } + >(); + let next = 0; + let ended: string | undefined; + let closing = false; + const end = (reason: string) => { + if (ended) return; + ended = reason; + for (const { reject } of pending.values()) reject(new Error(reason)); + pending.clear(); + if (!closing) onExit(reason); + }; + const exited = new Promise((resolve) => { + child.once("exit", (code, signal) => { + end(`The app's agent custody stopped (${signal ?? code})`); + resolve(); + }); + child.once("error", (error) => { + end(`The app's agent custody could not run: ${error.message}`); + resolve(); + }); + }); + if (child.stdout) + createInterface({ input: child.stdout }).on("line", (line) => { + let answer: { id?: unknown; ok?: unknown; error?: unknown }; + try { + answer = JSON.parse(line) as typeof answer; + } catch { + return; + } + const waiting = + typeof answer.id === "number" ? pending.get(answer.id) : undefined; + if (!waiting) return; + pending.delete(answer.id as number); + if ("error" in answer) waiting.reject(new Error(String(answer.error))); + else waiting.resolve(answer.ok); + }); + const call: NativeCall = (command, args) => { + if (ended) return Promise.reject(new Error(ended)); + const id = next++; + return new Promise((resolve, reject) => { + pending.set(id, { resolve: resolve as (value: unknown) => void, reject }); + child.stdin?.write(`${JSON.stringify({ id, command, args })}\n`); + }); + }; + return Object.freeze({ + call, + close: async () => { + closing = true; + child.stdin?.end(); + await exited; + }, + }); +} diff --git a/src/agent-host/host.test.ts b/src/agent-host/host.test.ts index 0ff458a438..9e5b705abb 100644 --- a/src/agent-host/host.test.ts +++ b/src/agent-host/host.test.ts @@ -1,48 +1,24 @@ // @vitest-environment node -import { mkdtempSync, readFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { expect, it } from "vitest"; import { DEFAULT_CONFIG } from "../bundled/claude-code/runtime"; import { defaults as codexDefaults } from "../bundled/codex/config"; -import { localStore } from "../features/agents/store"; -import { checkKind, claudeResolver, codexResolver, KINDS } from "./host"; -import { stateStorage } from "./storage"; +import type { AgentIdentity } from "../features/agents/native"; +import { fileStore } from "../features/agents/state"; +import { changed, claudeResolver, codexResolver } from "./host"; -it("publishes the kinds the app's custody signs, and no others", () => { - const source = readFileSync( - join(__dirname, "../../crates/agent-controller/src/app_agent.rs"), - "utf8", - ); - const native = /const KINDS: \[u16; \d+\] = \[([^\]]*)\]/.exec(source)?.[1]; - expect(native).toBeDefined(); - expect(KINDS).toEqual( - native - ?.split(",") - .map((kind) => kind.trim()) - .filter(Boolean) - .map(Number), - ); - // A channel tool's creation and edit, and typing. - for (const kind of [9007, 9002, 20002]) - expect(() => checkKind(kind)).not.toThrow(); - expect(() => checkKind(0)).toThrow("Agents cannot publish kind 0"); - expect(() => checkKind(1)).toThrow("Agents cannot publish kind 1"); -}); - -it("resolves blank settings from the owner's saved defaults, as they are now", () => { - const storage = stateStorage(mkdtempSync(join(tmpdir(), "agent-host-"))); - const claude = claudeResolver(localStore(storage)); - const codex = codexResolver(localStore(storage)); +it("resolves blank settings from the owner's saved defaults, as they are now", async () => { + const storage = fileStore(new Map(), async () => {}); + const claude = claudeResolver(storage); + const codex = codexResolver(storage); // Without saved defaults, the CLI's own. expect(claude(DEFAULT_CONFIG).model).toBe(""); expect(codex(codexDefaults)).toMatchObject({ model: "", effort: "" }); // The app saves defaults while the host runs. - storage.setItem( + await storage.set( "buzz.claude-code.provider-defaults.v1", JSON.stringify({ version: 1, model: "sonnet" }), ); - storage.setItem( + await storage.set( "buzz.codex.provider-defaults.v1", JSON.stringify({ version: 1, model: "gpt-5", effort: "high" }), ); @@ -57,3 +33,29 @@ it("resolves blank settings from the owner's saved defaults, as they are now", ( { model: "o3", effort: "low" }, ); }); + +it("stops when the app deletes, erases or changes the agent", () => { + const agent: AgentIdentity = { + pubkey: "a".repeat(64), + relay: "wss://relay.example.test", + owner: "b".repeat(64), + type: "buzz.claude-code/claude-code", + name: "Ada", + deleted: false, + }; + expect(changed(agent, [{ ...agent, name: "Renamed" }])).toBeUndefined(); + for (const saved of [ + [], + [{ ...agent, deleted: true }], + [{ ...agent, deleting: true as const }], + ]) + expect(changed(agent, saved)).toBe( + "The agent is no longer saved on this machine", + ); + expect(changed(agent, [{ ...agent, type: "buzz.codex/codex" }])).toBe( + "The agent's type changed on this machine", + ); + expect(changed(agent, [{ ...agent, owner: "c".repeat(64) }])).toBe( + "The agent's owner changed on this machine", + ); +}); diff --git a/src/agent-host/host.ts b/src/agent-host/host.ts index 8cb12220cb..e3113bc454 100644 --- a/src/agent-host/host.ts +++ b/src/agent-host/host.ts @@ -3,9 +3,6 @@ // the app does not run it. The app's own claim protocol decides which copy // runs the agent, so opening the installed app takes it over, and quitting // the app lets this host take it back. -import { mkdirSync } from "node:fs"; -import { join } from "node:path"; -import { getPublicKey } from "nostr-tools/pure"; import claudeManifest from "../bundled/claude-code/manifest.json"; import { claudeRun } from "../bundled/claude-code/run"; import { withScript as claudeScript } from "../bundled/claude-code/setup"; @@ -23,16 +20,24 @@ import codexManifest from "../bundled/codex/manifest.json"; import { codexRun } from "../bundled/codex/run"; import { withScript as codexScript } from "../bundled/codex/setup"; import { CodexRuntime } from "../bundled/codex/runtime"; -import { nodeClient } from "../buzz-mcp/node"; import { CoreMemoryCache } from "../features/agents/core-memory-cache"; import { createLocalAgentActivity } from "../features/agents/local-activity"; -import { type AgentReads, ownerReads } from "../features/agents/reads"; +import { + type AgentReads, + type MemoryListing, + ownerReads, +} from "../features/agents/reads"; import { RecordChanges } from "../features/agents/record-changes"; import { rosterDm, rosterMember } from "../features/agents/roster"; import { soloChannel } from "../features/agents/solo-chat"; import { communityDestination } from "../features/communities/destination"; import type { Checkpoint } from "../features/agents/catch-up"; import type { Interrupted } from "../features/agents/interrupted"; +import { + type AgentIdentity, + agentsNative, + type NativeCall, +} from "../features/agents/native"; import { AgentRunners } from "../features/agents/runner"; import { type Agent, @@ -42,40 +47,20 @@ import { import { type AgentRecord, type AgentStore, - localStore, readOne, readTeams, recordKey, setAttention, writeRecord, } from "../features/agents/store"; -import { eventsDto, type RelayEvent } from "../features/relay/events"; +import { fileStore, nativeFiles } from "../features/agents/state"; import { relayPartition } from "../features/relay/partition"; -import { Claims } from "./claim"; -import { checkSaved, type Identity, readNames } from "./identity"; import { type AgentSession, connectAgentSession } from "./session"; import { type Declared, processHost } from "./spawn"; -import { stateStorage } from "./storage"; -/** The kinds the app's custody signs for an agent, with its owner - * attestation: agent-controller app_agent.rs `KINDS`, which a test keeps this - * list equal to. The relay decides which channel changes it may make. */ -export const KINDS: readonly number[] = Object.freeze([ - 5, 7, 9, 9000, 9001, 9002, 9007, 9008, 9021, 9022, 20002, 30023, 30617, 40003, - 40100, 41010, -]); -/** Throws for a kind the app's custody would not sign. */ -export function checkKind(kind: number) { - if (!KINDS.includes(kind)) - throw new Error(`Agents cannot publish kind ${kind}`); -} /** How often due timers are looked for, as in the app. */ const TICK_MS = 5_000; -/** Events checked as the app checks what custody gives back. */ -const verified = (events: readonly unknown[]): RelayEvent[] => - eventsDto([...events]); - /** An agent's `core` memory, as `Agents.core` gives it. */ type Core = ( pubkey: string, @@ -90,34 +75,70 @@ type TypeRun = Readonly<{ }>; export type HostOptions = Readonly<{ - identity: Identity; - secret: Uint8Array; - /** The app's agents directory: `identities.json`, the `hosts` locks, and - * the agent storage the app and this host share. */ - dir: string; + identity: AgentIdentity; + /** The app's custody of the agent (`custody.ts`): its key, the agents the + * app saved, the hosting locks, and the agent storage the app shares. */ + call: NativeCall; log(message: string): void; /** Called once the agent is deleted, erased or changed in the app: this * host has stopped hosting it, and must stop. */ onGone(reason: string): void; }>; +/** Agent storage as the app keeps it, read again at each check, since the app + * may save while this host runs. A read that a write of this host's own + * overlaps is dropped, so it cannot put back what the write replaced. */ +async function agentFiles(call: NativeCall) { + const files = nativeFiles(call); + const items = new Map(Object.entries(await files.load())); + const store = fileStore(items, files.set, files.add); + let writes = 0; + let pending = 0; + const counted = (write: Promise) => { + writes++; + pending++; + return write.finally(() => pending--); + }; + const storage: AgentStore = Object.freeze({ + ...store, + set: (key: string, value: string | null) => counted(store.set(key, value)), + add: (key: string, value: string) => counted(store.add(key, value)), + }); + const reload = async () => { + const before = writes; + if (pending) return; + const saved = await files.load(); + if (pending || writes !== before) return; + items.clear(); + for (const [key, value] of Object.entries(saved)) items.set(key, value); + }; + return { storage, reload }; +} + +/** Why this host must stop running `identity`, by the agents the app saved + * here now: it was deleted, an erase removed it, or the app changed it. */ +export function changed( + identity: AgentIdentity, + saved: readonly AgentIdentity[], +): string | undefined { + const now = saved.find((agent) => agent.pubkey === identity.pubkey); + if (!now || now.deleted || now.deleting) + return "The agent is no longer saved on this machine"; + for (const field of ["type", "owner", "relay"] as const) + if (now[field] !== identity[field]) + return `The agent's ${field} changed on this machine`; + return undefined; +} + /** Connects as the agent, then runs it until the returned stop is called. */ export async function host(options: HostOptions): Promise<() => Promise> { - const { identity, secret, dir, log, onGone } = options; + const { identity, call, log, onGone } = options; const { pubkey } = identity; - if (getPublicKey(secret) !== pubkey) - throw new Error("The key is not this agent's"); - const home = join(dir, "standalone", pubkey); - mkdirSync(home, { recursive: true, mode: 0o700 }); + const native = agentsNative(call); // The app's agent storage: the agent's settings, teams and sessions are the - // ones the app shows and saves. Each write here is saved before it settles. - const storage = localStore(stateStorage(join(dir, "state"))); - const client = nodeClient({ - relay: identity.relay, - key: Buffer.from(secret).toString("hex"), - ...(identity.auth ? { auth: identity.auth } : {}), - root: home, - }); + // ones the app shows and saves. + const files = await agentFiles(call); + const { storage } = files; // As the app scopes the agent's community, so saved sessions match. const scope = relayPartition( communityDestination(identity.relay).url, @@ -132,12 +153,8 @@ export async function host(options: HostOptions): Promise<() => Promise> { try { connected = await connectAgentSession({ relay: identity.relay, - secret, pubkey, - ...(identity.auth - ? { auth: JSON.parse(identity.auth) as string[] } - : {}), - client, + call, scope, }); } catch (error) { @@ -151,14 +168,16 @@ export async function host(options: HostOptions): Promise<() => Promise> { const { session } = connection; const reads: AgentReads = { ...(ownerReads(connection.relay, pubkey) as AgentReads), - // The agent's own client decrypts its memory. It skips entries it cannot - // decrypt, so it cannot show that a listing is complete; a turn then - // never takes a missing core as none. + // Memory is encrypted to the owner; the app's custody reads it as the + // agent, with the agent's key, as the owner's session reads it in the app. memoryListing: async (signal, slug) => { signal.throwIfAborted(); - const { entries } = await client.memories(slug); + const listing = await call("app_agent_memories", { + pubkey, + slug, + }); signal.throwIfAborted(); - return { entries, partial: true }; + return listing; }, // The channel's Solo label decides, as for every member and agent. soloChat: async (_owner, channel, signal) => { @@ -176,7 +195,6 @@ export async function host(options: HostOptions): Promise<() => Promise> { const [pluginId = "", typeId = ""] = identity.type.split("/"); const run = typeRun(typeId, pluginId, identity.type, readsOf, core, storage); - const claims = new Claims(join(dir, "hosts")); const activity = createLocalAgentActivity(); // The agent's record as last read, and the stored text it was read from: @@ -226,7 +244,7 @@ export async function host(options: HostOptions): Promise<() => Promise> { return view; }; /** Shows the agent's newest record to its runner. */ - const changed = () => { + const show = () => { view = undefined; run.sync([agentView()]); }; @@ -245,16 +263,19 @@ export async function host(options: HostOptions): Promise<() => Promise> { mine(agent); stored = next; }, - changed, + changed: show, settled: () => runners.wake(pubkey), }); // Whether the app deleted, erased or changed the agent: then this host // stops, and tells its caller why. let gone = false; - const isGone = () => { + // Every agent the app saved here, named in team instructions. + let names: readonly AgentIdentity[] = []; + const isGone = async () => { let reason: string | undefined; try { - reason = checkSaved(dir, identity); + names = await native.list(); + reason = changed(identity, names); } catch (error) { log(`Could not read the saved agent: ${String(error)}`); } @@ -319,7 +340,12 @@ export async function host(options: HostOptions): Promise<() => Promise> { // app binds no synced config to them: the synced copies are in the app's // web storage, which this host cannot read. start: (agent) => ({ ok: true, config: agent.config, revisions: [] }), - team: (agent) => teamPrompt(readTeams(storage), readNames(dir), agent), + team: (agent) => + teamPrompt( + readTeams(storage), + names.filter((saved) => !saved.deleted), + agent, + ), // Channel templates live in the app's web storage, which this host // cannot read; its agent offers none. templates: () => [], @@ -342,9 +368,9 @@ export async function host(options: HostOptions): Promise<() => Promise> { mine(agent); // Checked here too, not only each tick, so an event cannot start a // turn for an agent the app has deleted. - if (gone || isGone()) return false; + if (gone || (await isGone())) return false; try { - return await claims.claim(agent); + return await native.claim(agent); } catch (error) { // Unlike the app, which always runs its agents, this host runs one // only when it knows no other copy does. @@ -352,47 +378,14 @@ export async function host(options: HostOptions): Promise<() => Promise> { return false; } }, - release: (agent) => claims.release(agent), - publish: async (agent, event) => { - mine(agent); - checkKind(event.kind); - const posted = await client.publish({ - kind: event.kind, - content: event.content, - tags: event.tags ?? [], - }); - return verified([posted])[0] as RelayEvent; - }, - query: async (agent, filters) => { - mine(agent); - return verified( - await client.query(filters as readonly Record[]), - ); - }, - searchProfiles: async (agent, query, limit) => { - mine(agent); - const found = await client.searchProfiles?.(query, limit); - return found && verified(found); - }, - upload: (agent, data, mime) => { - mine(agent); - return client.upload(Buffer.from(data, "base64"), mime); - }, - media: (agent, url) => { - mine(agent); - return client.media(url); - }, - // The app signs Git access with the agent's custody; this host does - // not yet. - gitAuth: async () => { - throw new Error("Git access needs Buzz Desktop."); - }, - remember: async (agent, slug, body, after) => { - mine(agent); - return verified([ - await client.remember(slug, body, after), - ])[0] as RelayEvent; - }, + release: native.release, + publish: native.publish, + query: native.query, + searchProfiles: native.searchProfiles, + upload: native.upload, + media: native.media, + gitAuth: native.gitAuth, + remember: native.remember, }, activity, // Typing goes where the turn answers, read the same way. @@ -435,16 +428,22 @@ export async function host(options: HostOptions): Promise<() => Promise> { const stopRosters = session.channels.subscribeList?.(() => runners.releaseHeld(() => true), ); - const tick = setInterval(() => { - if (gone || isGone()) return; + let ticking = false; + const tick = setInterval(async () => { + if (ticking) return; + ticking = true; try { + if (gone || (await isGone())) return; + await files.reload(); reread(); } catch (error) { log(`Could not read the agent's settings: ${String(error)}`); + } finally { + ticking = false; } - runners.tick(); + if (!gone) runners.tick(); }, TICK_MS); - log(`Running ${identity.name} (${identity.type}) from ${home}`); + log(`Running ${identity.name} (${identity.type})`); let stopped: Promise | undefined; const stop = () => (stopped ??= stopHosting()); @@ -459,8 +458,7 @@ export async function host(options: HostOptions): Promise<() => Promise> { runners.clear(); // Only once its processes have exited may another copy run the agent. await run.dispose(); - await claims.release(pubkey); - claims.dispose(); + await native.release(pubkey); } } diff --git a/src/agent-host/identity.test.ts b/src/agent-host/identity.test.ts deleted file mode 100644 index 424b538058..0000000000 --- a/src/agent-host/identity.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -// @vitest-environment node -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, expect, it } from "vitest"; -import { checkSaved, readIdentity } from "./identity"; - -const saved = { - pubkey: "ab".repeat(32), - name: "Bot", - type: "buzz.claude-code/claude-code", - owner: "cd".repeat(32), - relay: "wss://relay.example", -}; -let dir: string; -const save = (list: unknown[]) => - writeFileSync(join(dir, "identities.json"), JSON.stringify(list)); - -beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), "agent-host-identity-")); -}); -afterEach(() => rmSync(dir, { recursive: true, force: true })); - -it("keeps hosting while the agent is saved as it started", () => { - save([saved]); - const identity = readIdentity(dir, saved.pubkey); - expect(checkSaved(dir, identity)).toBeUndefined(); - // A new name does not change who the agent is. - save([{ ...saved, name: "Renamed" }]); - expect(checkSaved(dir, identity)).toBeUndefined(); -}); - -it("stops hosting once the app deletes, erases or moves the agent", () => { - save([saved]); - const identity = readIdentity(dir, saved.pubkey); - save([{ ...saved, deleted: true }]); - expect(checkSaved(dir, identity)).toMatch(/no longer saved/); - save([]); - expect(checkSaved(dir, identity)).toMatch(/no longer saved/); - rmSync(join(dir, "identities.json")); - expect(checkSaved(dir, identity)).toMatch(/no longer saved/); - save([{ ...saved, owner: "ef".repeat(32) }]); - expect(checkSaved(dir, identity)).toMatch(/owner changed/); -}); - -it("keeps hosting through a list it cannot read", () => { - save([saved]); - const identity = readIdentity(dir, saved.pubkey); - writeFileSync(join(dir, "identities.json"), "{"); - expect(() => checkSaved(dir, identity)).toThrow(); -}); diff --git a/src/agent-host/identity.ts b/src/agent-host/identity.ts deleted file mode 100644 index 42642ecdb2..0000000000 --- a/src/agent-host/identity.ts +++ /dev/null @@ -1,142 +0,0 @@ -// The agent as the desktop app saved it on this machine: its identity from -// the app's `identities.json`, and its key from the environment or the app's -// Keychain item. -import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { decode } from "nostr-tools/nip19"; -import { getPublicKey } from "nostr-tools/pure"; -import { hexToBytes } from "nostr-tools/utils"; - -/** The app's identifier: installed and dev builds share it, and its data. */ -const APP = "dev.local.buzz.foundation"; -/** The app's Keychain item that holds every agent's key (agent-controller). */ -const KEYCHAIN_SERVICE = `${APP}.agents`; -const KEYCHAIN_ACCOUNT = "agent-bundle-v1"; - -export type Identity = Readonly<{ - pubkey: string; - name: string; - /** The agent type's key, `pluginId/typeId`. */ - type: string; - owner: string; - relay: string; - /** The owner's NIP-OA `auth` tag, as JSON. */ - auth?: string; -}>; - -/** Where the app keeps its agents: `identities.json` and the `hosts` locks. */ -export function agentsDir(env = process.env) { - if (env.BUZZ_AGENTS_DIR) return env.BUZZ_AGENTS_DIR; - const data = - process.platform === "darwin" - ? join(homedir(), "Library/Application Support") - : (env.XDG_DATA_HOME ?? join(homedir(), ".local/share")); - return join(data, APP, "agents"); -} - -type Saved = Record; - -/** The agents saved on this machine that are not deleted. */ -function readList(dir: string): Saved[] { - const saved = JSON.parse( - readFileSync(join(dir, "identities.json"), "utf8"), - ) as unknown; - return (Array.isArray(saved) ? saved : []).filter( - (item): item is Saved => - !!item && typeof item === "object" && item.deleted !== true, - ); -} - -/** The name of each agent saved on this machine, for team prompts. */ -export function readNames(dir: string): { pubkey: string; name: string }[] { - return readList(dir).flatMap(({ pubkey, name }) => - typeof pubkey === "string" && typeof name === "string" - ? [{ pubkey, name }] - : [], - ); -} - -export function readIdentity(dir: string, pubkey: string): Identity { - const found = readList(dir).find((item) => item.pubkey === pubkey); - if (!found) throw new Error(`No agent ${pubkey} is saved on this machine`); - const text = (name: string) => { - const value = found[name]; - if (typeof value !== "string" || !value) - throw new Error(`The saved agent has no ${name}`); - return value; - }; - const auth = found.auth; - return { - pubkey, - name: text("name"), - type: text("type"), - owner: text("owner"), - relay: text("relay"), - ...(typeof auth === "string" && auth ? { auth } : {}), - }; -} - -/** Whether the agent is still saved here as `identity`: undefined while it is, - * or why this host must stop running it. The app marks a deleted agent in - * `identities.json`, and an erase that removes agents deletes the file. */ -export function checkSaved( - dir: string, - identity: Identity, -): string | undefined { - let now: Identity | undefined; - try { - now = readList(dir).some((item) => item.pubkey === identity.pubkey) - ? readIdentity(dir, identity.pubkey) - : undefined; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } - if (!now) return "The agent is no longer saved on this machine"; - for (const field of ["type", "owner", "relay", "auth"] as const) - if (now[field] !== identity[field]) - return `The agent's ${field} changed on this machine`; - return undefined; -} - -/** The agent's secret key: `BUZZ_AGENT_KEY` (hex or nsec), or else the - * app's Keychain item, which macOS may ask the user to allow. */ -export function readKey(identity: Identity, env = process.env): Uint8Array { - let text = env.BUZZ_AGENT_KEY?.trim(); - if (!text) { - if (process.platform !== "darwin") - throw new Error("Set BUZZ_AGENT_KEY to the agent's key"); - const bundle = execFileSync( - "security", - [ - "find-generic-password", - "-s", - KEYCHAIN_SERVICE, - "-a", - KEYCHAIN_ACCOUNT, - "-w", - ], - { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - // macOS may ask the user to allow it; nobody may be there to answer. - timeout: 60_000, - }, - ).trim(); - const account = `agent:${identity.pubkey}-${createHash("sha256") - .update(identity.relay) - .digest("hex")}`; - const value = (JSON.parse(bundle) as Record)[account]; - if (typeof value !== "string") - throw new Error("The app's Keychain has no key for this agent"); - text = value; - } - const secret = text.startsWith("nsec1") - ? (decode(text as `nsec1${string}`).data as Uint8Array) - : hexToBytes(text); - if (getPublicKey(secret) !== identity.pubkey) - throw new Error("The key is not this agent's"); - return secret; -} diff --git a/src/agent-host/main.ts b/src/agent-host/main.ts index 0157e79075..38d6934fc7 100644 --- a/src/agent-host/main.ts +++ b/src/agent-host/main.ts @@ -3,12 +3,12 @@ // buzz-agent-host run // // The agent is one the app saved here, and it runs with the settings, teams -// and sessions the app saved for it. Its key comes from BUZZ_AGENT_KEY or the -// app's Keychain item; BUZZ_AGENTS_DIR overrides where the app keeps its -// agents. While the installed app runs the agent, this host waits. It runs -// agents only on macOS, where it can take the app's hosting locks. +// and sessions the app saved for it. The app's own executable holds its key +// and its claim on the agent (`custody.ts`): BUZZ_APP names it, or else it is +// the installed app. While the installed app runs the agent, this host waits. +import { agentsNative } from "../features/agents/native"; +import { appExecutable, startCustody } from "./custody"; import { host } from "./host"; -import { agentsDir, readIdentity, readKey } from "./identity"; const usage = "Usage: buzz-agent-host run "; const log = (message: string) => @@ -25,27 +25,33 @@ async function main(args: readonly string[]) { console.error(usage); process.exit(2); } - const dir = agentsDir(); - const identity = readIdentity(dir, pubkey); - const secret = readKey(identity); + const custody = startCustody(appExecutable(), pubkey, (reason) => { + log(`${reason}; stopped`); + process.exit(1); + }); + const identity = (await agentsNative(custody.call).list()).find( + (agent) => agent.pubkey === pubkey && !agent.deleted, + ); + if (!identity) throw new Error(`No agent ${pubkey} is saved on this machine`); let stopping = false; let stop: (() => Promise) | undefined; const quit = () => { if (stopping || !stop) process.exit(stop ? 1 : 0); stopping = true; log("Stopping"); - void stop().finally(() => process.exit(0)); + void stop() + .then(() => custody.close()) + .finally(() => process.exit(0)); }; process.on("SIGINT", quit); process.on("SIGTERM", quit); stop = await host({ identity, - secret, - dir, + call: custody.call, log, onGone: (reason) => { log(`${reason}; stopped`); - process.exit(0); + void custody.close().finally(() => process.exit(0)); }, }); } diff --git a/src/agent-host/session.test.ts b/src/agent-host/session.test.ts index 9b89969b7a..8c593aaea1 100644 --- a/src/agent-host/session.test.ts +++ b/src/agent-host/session.test.ts @@ -1,7 +1,12 @@ // @vitest-environment node -import { generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools/pure"; +import { + finalizeEvent, + generateSecretKey, + getPublicKey, + verifyEvent, +} from "nostr-tools/pure"; import { afterEach, expect, it, vi } from "vitest"; -import { nodeClient } from "../buzz-mcp/node"; +import type { NativeCall } from "../features/agents/native"; import { connectAgentSession } from "./session"; /** A relay connection the test answers by hand. */ @@ -28,33 +33,51 @@ class Socket { afterEach(() => vi.unstubAllGlobals()); -it("signs in as the agent with its owner's attestation, and publishes nothing", async () => { +it("signs in and reads through the app's custody, and publishes nothing", async () => { const secret = generateSecretKey(); const pubkey = getPublicKey(secret); const auth = ["auth", "b".repeat(64), "", "f".repeat(128)]; - const posted: string[] = []; + const posted: { path: string; headers: Headers }[] = []; vi.stubGlobal("WebSocket", Socket); vi.stubGlobal( "fetch", vi.fn(async (url: string, init?: RequestInit) => { if (init?.method !== "POST") return Response.json({ self: "c".repeat(64) }); - posted.push(new URL(url).pathname); + posted.push({ + path: new URL(url).pathname, + headers: new Headers(init.headers), + }); return Response.json([]); }), ); const relay = "wss://relay.example.test"; + // The app's custody, as `agent_host.rs` answers: it signs with the key. + const calls: [string, Record | undefined][] = []; + const call = (async (command: string, args?: Record) => { + calls.push([command, args]); + if (command === "app_agent_login") + return finalizeEvent( + { + kind: 22242, + content: "", + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["relay", String(args?.relay)], + ["challenge", String(args?.challenge)], + auth, + ], + }, + secret, + ); + if (command === "app_agent_read_auth") + return { authorization: "Nostr signed", authTag: JSON.stringify(auth) }; + throw new Error(`Unexpected ${command}`); + }) as NativeCall; const { session, relay: data } = await connectAgentSession({ relay, - secret, pubkey, - auth, - client: nodeClient({ - relay, - key: Buffer.from(secret).toString("hex"), - auth: JSON.stringify(auth), - root: "/tmp", - }), + call, scope: "scope", }); try { @@ -66,6 +89,10 @@ it("signs in as the agent with its owner's attestation, and publishes nothing", expect(login?.pubkey).toBe(pubkey); expect(verifyEvent(login as never)).toBe(true); expect(login?.tags).toContainEqual(auth); + expect(calls[0]).toEqual([ + "app_agent_login", + { pubkey, relay, challenge: "challenge" }, + ]); await socket.receive(["OK", login?.id, true, ""]); await vi.waitFor(() => expect(session.live.snapshot().status).toBe("connected"), @@ -80,7 +107,22 @@ it("signs in as the agent with its owner's attestation, and publishes nothing", ), ).toBe(false); expect(socket.sent.filter((message) => message[0] === "EVENT")).toEqual([]); - expect(posted).not.toContain("/events"); + expect(posted.map(({ path }) => path)).not.toContain("/events"); + // Its reads carry the headers custody signed for each one. + await session.read([{ kinds: [9], "#h": ["c"], limit: 1 }], { + signal: new AbortController().signal, + }); + const reads = calls.filter( + ([command]) => command === "app_agent_read_auth", + ); + expect(reads.length).toBeGreaterThan(0); + for (const [, args] of reads) + expect(args?.url).toBe("https://relay.example.test/query"); + expect(posted.length).toBe(reads.length); + for (const { headers } of posted) { + expect(headers.get("authorization")).toBe("Nostr signed"); + expect(headers.get("x-auth-tag")).toBe(JSON.stringify(auth)); + } } finally { data.disconnect(); } diff --git a/src/agent-host/session.ts b/src/agent-host/session.ts index 30070b3391..d6d96d7b3b 100644 --- a/src/agent-host/session.ts +++ b/src/agent-host/session.ts @@ -2,10 +2,9 @@ // relay session (`createRelaySession`), signed in as the agent. It hears the // agent's channels live, keeps their rosters and labels, and answers the // agent's reads, as the owner's session does in the app. It is read-only: the -// agent publishes only through the host's custody, which checks each kind. +// agent publishes only through the app's custody, which checks each kind. import type { EventTemplate, VerifiedEvent } from "nostr-tools"; -import { finalizeEvent } from "nostr-tools/pure"; -import type { NodeClient } from "../buzz-mcp/node"; +import type { NativeCall } from "../features/agents/native"; import { communityDestination } from "../features/communities/destination"; import { audienceAuthorityCommunity } from "../features/sessions/audience-authority"; import { @@ -39,19 +38,17 @@ export async function relayInfo(relay: string, signal?: AbortSignal) { return info; } -/** Connects as the agent. `scope` partitions what the app keeps for it. */ +/** Connects as the agent. `scope` partitions what the app keeps for it. + * `call` is the app's custody: it signs the agent's login and reads. */ export async function connectAgentSession( options: Readonly<{ relay: string; - secret: Uint8Array; pubkey: string; - /** The owner's NIP-OA `auth` tag, which the agent's login carries. */ - auth?: readonly string[]; - client: NodeClient; + call: NativeCall; scope: string; }>, ): Promise { - const { secret, pubkey, auth, client, scope } = options; + const { pubkey, call, scope } = options; const origin = communityDestination(options.relay).url; const info = await relayInfo(origin); // As the app reads it (`connectNativeTransport`). @@ -61,20 +58,35 @@ export async function connectAgentSession( const extensions = Array.isArray(info.supported_extensions) ? info.supported_extensions : []; + const tag = (template: EventTemplate, name: string) => + template.tags.find((item) => item[0] === name)?.[1]; const signer: Signer = { getPublicKey: async () => pubkey, - signEvent: async (template: EventTemplate) => - finalizeEvent( - { - ...template, - tags: - template.kind === 22242 && auth - ? [...template.tags, [...auth]] - : template.tags, + signEvent: async (template: EventTemplate) => { + if (template.kind !== 22242) + throw new Error("The agent's session signs only its login"); + return call("app_agent_login", { + pubkey, + relay: tag(template, "relay"), + challenge: tag(template, "challenge"), + }); + }, + request: async (url, body, signal) => { + const auth = await call<{ authorization: string; authTag: string }>( + "app_agent_read_auth", + { pubkey, url, body }, + ); + return fetch(url, { + method: "POST", + headers: { + authorization: auth.authorization, + "content-type": "application/json", + "x-auth-tag": auth.authTag, }, - secret, - ) as VerifiedEvent, - request: (url, body, signal) => client.signedRequest(url, body, signal), + body, + signal: signal ?? null, + }); + }, }; // Nothing the session does publishes as the agent: it has no writer, and // its live connection sends only the login and its subscriptions. diff --git a/src/agent-host/storage.test.ts b/src/agent-host/storage.test.ts deleted file mode 100644 index 7ecbd39cfb..0000000000 --- a/src/agent-host/storage.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -// @vitest-environment node -import { mkdtempSync, readdirSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { expect, it } from "vitest"; -import { - localStore, - readRecords, - recordKey, - writeRecord, -} from "../features/agents/store"; -import { decodeKey, encodeKey, fileName, stateStorage } from "./storage"; - -it("names files as the app does", () => { - // The same names as agent_state.rs `names_round_trip`. - expect(encodeKey("buzz.agents.v1")).toBe("buzz.agents.v1"); - expect(encodeKey("buzz.codex.sessions.v4:wss://relay.example/a:ab")).toBe( - "buzz.codex.sessions.v4%3Awss%3A%2F%2Frelay.example%2Fa%3Aab", - ); - expect(encodeKey("a/b c%d")).toBe("a%2Fb%20c%25d"); - for (const key of ["buzz.agent.v1:ab", "a/b c%d", "é"]) - expect(decodeKey(encodeKey(key))).toBe(key); - expect(decodeKey("x.tmp")).toBeUndefined(); - expect(decodeKey(".hidden")).toBeUndefined(); - expect(decodeKey("bad%Z")).toBeUndefined(); - expect(decodeKey("has space")).toBeUndefined(); -}); - -it("reads what another program saved, and skips writes in progress", async () => { - const dir = join(mkdtempSync(join(tmpdir(), "agent-state-")), "state"); - const storage = stateStorage(dir); - expect(storage.getItem("missing")).toBeNull(); - expect(storage.length).toBe(0); - const pubkey = "a".repeat(64); - await writeRecord(localStore(storage), { - pubkey, - attention: {}, - config: { word: "one" }, - }); - // The app saves a change to the same file. - writeFileSync( - join(dir, encodeKey(recordKey(pubkey))), - JSON.stringify({ pubkey, attention: {}, config: { word: "two" } }), - ); - writeFileSync(join(dir, ".123.tmp"), "partial"); - expect(readRecords(localStore(storage))[pubkey]?.config).toEqual({ - word: "two", - }); - expect(storage.length).toBe(1); - storage.removeItem(recordKey(pubkey)); - storage.removeItem(recordKey(pubkey)); - expect(readdirSync(dir)).toEqual([".123.tmp"]); -}); - -it("saves a key whose name is too long in a file named by its hash, as the app does", () => { - const dir = join(mkdtempSync(join(tmpdir(), "agent-state-")), "state"); - const storage = stateStorage(dir); - // 251 bytes, as Codex names it for a community on a 95-character host; - // encoded, 263. - const key = `buzz.codex.sessions.v4:https://agents.production.us-west-2.buzz-workspace.platform.engineering.internal.tools.example.com:${"a".repeat(64)}:${"b".repeat(64)}`; - expect(key.length).toBe(251); - expect(encodeKey(key).length).toBeGreaterThan(255); - // The name agent_state.rs `file_name` gives it. - expect(fileName(key)).toBe( - "~2418a80781b72ec15bca6c4423fa41f0937d29e13b19fe1247bf0daaa571773f", - ); - storage.setItem(key, "{}"); - expect(readdirSync(dir)).toEqual([fileName(key)]); - expect(storage.getItem(key)).toBe("{}"); - expect(storage.key(0)).toBe(key); - storage.setItem(key, "2"); - expect(storage.getItem(key)).toBe("2"); - // A hashed file whose key does not give its name is not a key. - writeFileSync( - join(dir, `~${"0".repeat(64)}`), - JSON.stringify({ key: "other", value: "x" }), - ); - writeFileSync(join(dir, "~bad"), "not json"); - expect(storage.length).toBe(1); - storage.removeItem(key); - expect(storage.getItem(key)).toBeNull(); - expect(storage.length).toBe(0); -}); diff --git a/src/agent-host/storage.ts b/src/agent-host/storage.ts deleted file mode 100644 index e8b8d29ef3..0000000000 --- a/src/agent-host/storage.ts +++ /dev/null @@ -1,131 +0,0 @@ -// The app's agent storage (`agents/state`, see src-tauri agent_state.rs) as a -// Storage: one file per key, each replaced whole through a rename. Reads go to -// the files every time, so this host sees what the app saves while it runs. -// A key whose encoded name is too long is saved, as the app saves it, in a file -// named `~` and the key's SHA-256 that holds `{"key": …, "value": …}`. -import { createHash } from "node:crypto"; -import { - closeSync, - fsyncSync, - mkdirSync, - openSync, - readdirSync, - readFileSync, - renameSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { join } from "node:path"; - -const SAFE = /^[A-Za-z0-9._-]$/; -/** The longest file name macOS, Linux and Windows all allow, in bytes. */ -const NAME_LIMIT = 255; -/** Starts the name of a file that holds its key. `encodeKey` never writes it. */ -const HASHED = "~"; - -/** The file name for `key`, as the app names it. */ -export function encodeKey(key: string) { - let name = ""; - for (const byte of new TextEncoder().encode(key)) { - const char = String.fromCharCode(byte); - name += SAFE.test(char) - ? char - : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`; - } - return name; -} - -/** The key a file name holds, or undefined for a file that is not one. */ -export function decodeKey(name: string) { - if (!name || name.startsWith(".") || name.endsWith(".tmp")) return undefined; - if (!/^(?:[A-Za-z0-9._-]|%[0-9A-Fa-f]{2})+$/.test(name)) return undefined; - try { - return decodeURIComponent(name); - } catch { - return undefined; - } -} - -/** The file `key` is saved in, as the app names it. */ -export function fileName(key: string) { - const name = encodeKey(key); - if (name.length <= NAME_LIMIT) return name; - return HASHED + createHash("sha256").update(key).digest("hex"); -} - -export function stateStorage(dir: string): Storage { - const read = (name: string) => { - try { - return readFileSync(join(dir, name), "utf8"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; - throw error; - } - }; - /** The key and value a hashed file holds, if its key gives its name. */ - const hashed = (name: string) => { - try { - const saved = JSON.parse(read(name) ?? "null") as unknown; - if ( - typeof saved === "object" && - saved !== null && - "key" in saved && - "value" in saved && - typeof saved.key === "string" && - typeof saved.value === "string" && - fileName(saved.key) === name - ) - return { key: saved.key, value: saved.value }; - } catch {} - return undefined; - }; - const keys = () => { - let names: string[]; - try { - names = readdirSync(dir); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; - throw error; - } - return names.flatMap( - (name) => - (name.startsWith(HASHED) ? hashed(name)?.key : decodeKey(name)) ?? [], - ); - }; - const remove = (key: string) => - rmSync(join(dir, fileName(key)), { force: true }); - return { - get length() { - return keys().length; - }, - key: (index: number) => keys()[index] ?? null, - getItem(key: string) { - const name = fileName(key); - if (!name.startsWith(HASHED)) return read(name) ?? null; - const saved = hashed(name); - return saved?.key === key ? saved.value : null; - }, - setItem(key: string, value: string) { - const name = fileName(key); - const text = name.startsWith(HASHED) - ? JSON.stringify({ key, value: String(value) }) - : String(value); - mkdirSync(dir, { recursive: true, mode: 0o700 }); - const temporary = join(dir, `.${process.pid}.${Date.now()}.tmp`); - // On disk before it replaces the file, as the app's `sync_all`, so a - // power loss cannot leave an empty file in its place. - const file = openSync(temporary, "w", 0o600); - try { - writeFileSync(file, text); - fsyncSync(file); - } finally { - closeSync(file); - } - renameSync(temporary, join(dir, name)); - }, - removeItem: remove, - clear() { - for (const key of keys()) remove(key); - }, - }; -} diff --git a/src/buzz-mcp/client.ts b/src/buzz-mcp/client.ts index dcbc30b9d5..90e01c884d 100644 --- a/src/buzz-mcp/client.ts +++ b/src/buzz-mcp/client.ts @@ -111,7 +111,7 @@ export interface BuzzClient { /** The agent's memory entries, decrypted; only entry `slug` when given. */ memories(slug?: string): Promise; /** Writes entry `slug`, newer than the one it replaces (`after`, or 0). */ - remember(slug: string, body: string, after: number): Promise; + remember(slug: string, body: string, after: number): Promise; /** The owner's channel templates, where the host has them. */ templates?(): Promise; /** The agent's attention, where its host keeps one. Without it the diff --git a/src/buzz-mcp/node.ts b/src/buzz-mcp/node.ts index f39c1788e9..ceb4b10652 100644 --- a/src/buzz-mcp/node.ts +++ b/src/buzz-mcp/node.ts @@ -37,17 +37,6 @@ const MEDIA_FILE_LIMIT = 500 * 1024 * 1024; /** Images an agent can be shown. */ const SHOWN_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]; -/** A BuzzClient whose memory writes give back the event they posted. */ -export type NodeClient = Omit & { - remember(slug: string, body: string, after: number): Promise; - /** POSTs `body` to one of the community's URLs, signed as the agent. */ - signedRequest( - url: string, - body: string, - signal?: AbortSignal, - ): Promise; -}; - export function nodeClient( options: Readonly<{ relay: string; @@ -60,7 +49,7 @@ export function nodeClient( /** Where fetched media is saved; a temporary directory by default. */ media?: string; }>, -): NodeClient { +): BuzzClient { const secret = options.key.startsWith("nsec1") ? (decode(options.key as `nsec1${string}`).data as Uint8Array) : hexToBytes(options.key); @@ -95,7 +84,7 @@ export function nodeClient( return JSON.parse(text) as unknown; } - function send(path: string, body: string, signal?: AbortSignal) { + function send(path: string, body: string) { const url = `${base}${path}`; const nip98 = sign({ kind: 27235, @@ -115,7 +104,6 @@ export function nodeClient( ...(options.auth ? { "x-auth-tag": options.auth } : {}), }, body, - signal: signal ?? null, }); } @@ -136,11 +124,6 @@ export function nodeClient( return { pubkey, - signedRequest(url, body, signal) { - if (!url.startsWith(`${base}/`)) - throw new Error("Not a URL of the agent's community"); - return send(url.slice(base.length), body, signal); - }, query: async (filters) => eventsDto(await request("/query", JSON.stringify(filters))), async searchProfiles(query, limit) { @@ -338,7 +321,7 @@ export function nodeClient( const plaintext = JSON.stringify( slug === "core" ? { slug, profile: body } : { slug, value: body }, ); - return post( + await post( sign( { kind: 30174, diff --git a/src/features/agents/native.ts b/src/features/agents/native.ts index deab7d24d6..e1f2d2c36e 100644 --- a/src/features/agents/native.ts +++ b/src/features/agents/native.ts @@ -164,63 +164,69 @@ export type AgentsNative = { release(pubkey: string): Promise; }; +/** Native custody through `call`: the app's own commands, or the same + * commands as the app serves them to `buzz-agent-host`. */ +export type NativeCall = ( + command: string, + args?: Record, +) => Promise; + export const nativeAgents = (): AgentsNative | undefined => - isTauri() - ? { - list: () => invoke("app_agent_list"), - create: (destination, owner, agentType, name) => - invoke("app_agent_create", { destination, owner, agentType, name }), - portable: () => invoke("app_agent_portable"), - port: (source, pubkey, destination, owner, agentType) => - invoke("app_agent_port", { - source, - pubkey, - destination, - owner, - agentType, - }), - rename: (pubkey, name) => invoke("app_agent_rename", { pubkey, name }), - setPicture: (pubkey, picture) => - invoke("app_agent_set_picture", { pubkey, picture }), - setType: (pubkey, agentType) => - invoke("app_agent_set_type", { pubkey, agentType }), - beginDelete: (pubkey) => invoke("app_agent_begin_delete", { pubkey }), - remove: (pubkey) => invoke("app_agent_delete", { pubkey }), - forget: (pubkey) => invoke("app_agent_forget", { pubkey }), - publish: (pubkey, event) => - invoke("app_agent_publish", { - pubkey, - event: { - kind: event.kind, - content: event.content, - tags: event.tags ?? [], - }, - }), - query: async (pubkey, filters) => - eventsDto(await invoke("app_agent_query", { pubkey, filters })), - audienceAuthority: (pubkey, destination, sources) => - invoke("app_agent_query", { - pubkey, - filters: audienceAuthorityFilter(destination, sources), - }), - searchProfiles: async (pubkey, query, limit) => { - const found = await invoke("app_agent_search_profiles", { - pubkey, - query, - limit, - }); - return found == null ? undefined : eventsDto(found); - }, - upload: (pubkey, data, mime) => - invoke("app_agent_upload", { pubkey, data, mime }), - media: (pubkey, url) => invoke("app_agent_media", { pubkey, url }), - gitAuth: (pubkey, owner, repo) => - invoke("app_agent_git_auth", { pubkey, owner, repo }), - remember: (pubkey, slug, body, after) => - invoke("app_agent_remember", { pubkey, slug, body, after }), - publishProfile: (pubkey) => - invoke("app_agent_publish_profile", { pubkey }), - claim: (pubkey) => invoke("app_agent_claim", { pubkey }), - release: (pubkey) => invoke("app_agent_release", { pubkey }), - } - : undefined; + isTauri() ? agentsNative(invoke) : undefined; + +export const agentsNative = (invoke: NativeCall): AgentsNative => ({ + list: () => invoke("app_agent_list"), + create: (destination, owner, agentType, name) => + invoke("app_agent_create", { destination, owner, agentType, name }), + portable: () => invoke("app_agent_portable"), + port: (source, pubkey, destination, owner, agentType) => + invoke("app_agent_port", { + source, + pubkey, + destination, + owner, + agentType, + }), + rename: (pubkey, name) => invoke("app_agent_rename", { pubkey, name }), + setPicture: (pubkey, picture) => + invoke("app_agent_set_picture", { pubkey, picture }), + setType: (pubkey, agentType) => + invoke("app_agent_set_type", { pubkey, agentType }), + beginDelete: (pubkey) => invoke("app_agent_begin_delete", { pubkey }), + remove: (pubkey) => invoke("app_agent_delete", { pubkey }), + forget: (pubkey) => invoke("app_agent_forget", { pubkey }), + publish: (pubkey, event) => + invoke("app_agent_publish", { + pubkey, + event: { + kind: event.kind, + content: event.content, + tags: event.tags ?? [], + }, + }), + query: async (pubkey, filters) => + eventsDto(await invoke("app_agent_query", { pubkey, filters })), + audienceAuthority: (pubkey, destination, sources) => + invoke("app_agent_query", { + pubkey, + filters: audienceAuthorityFilter(destination, sources), + }), + searchProfiles: async (pubkey, query, limit) => { + const found = await invoke("app_agent_search_profiles", { + pubkey, + query, + limit, + }); + return found == null ? undefined : eventsDto(found); + }, + upload: (pubkey, data, mime) => + invoke("app_agent_upload", { pubkey, data, mime }), + media: (pubkey, url) => invoke("app_agent_media", { pubkey, url }), + gitAuth: (pubkey, owner, repo) => + invoke("app_agent_git_auth", { pubkey, owner, repo }), + remember: (pubkey, slug, body, after) => + invoke("app_agent_remember", { pubkey, slug, body, after }), + publishProfile: (pubkey) => invoke("app_agent_publish_profile", { pubkey }), + claim: (pubkey) => invoke("app_agent_claim", { pubkey }), + release: (pubkey) => invoke("app_agent_release", { pubkey }), +}); diff --git a/src/features/agents/state.ts b/src/features/agents/state.ts index 125f3b33fe..97dac51218 100644 --- a/src/features/agents/state.ts +++ b/src/features/agents/state.ts @@ -12,6 +12,7 @@ import { invoke, isTauri } from "@tauri-apps/api/core"; import { CONFIG_STORAGE_KEY } from "./agent-config"; import { CATCH_UP_KEY, CATCH_UP_PREFIX } from "./catch-up"; import { INTERRUPTED_PREFIX } from "./interrupted"; +import type { NativeCall } from "./native"; import { localStore, type AgentStore, @@ -64,14 +65,17 @@ type Native = Readonly<{ add?(key: string, value: string): Promise; }>; -const native: Native = { - load: () => invoke("app_agent_state_load"), +/** The agent storage files through `call`, as `nativeAgents` uses them. */ +export const nativeFiles = (call: NativeCall): Native => ({ + load: () => call("app_agent_state_load"), set: async (key, value) => { - await invoke("app_agent_state_set", { key, value }); + await call("app_agent_state_set", { key, value }); }, add: (key, value) => - invoke("app_agent_state_set", { key, value, absent: true }), -}; + call("app_agent_state_set", { key, value, absent: true }), +}); +// Looked up per call, so a module that mocks only part of Tauri still loads. +const native = nativeFiles((command, args) => invoke(command, args)); /** Agent storage holds this key once every agent key in localStorage was * moved into it. */