From 8d4a865440e80fe364f353330147463fb5f7e07c Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sun, 11 Oct 2026 13:23:19 -0700 Subject: [PATCH 1/5] feat(agents2): answer the owner's other agents, wherever they mention Claude and Codex answered only their owner (Claude also anyone, behind an Answers setting), and the app woke an agent for one of the owner's other agents only on a mention outside a thread it had spoken in. So an agent could not hand work to another and hear back from it. Both types now answer the owner and any agent whose message carries the owner's NIP-OA attestation, and nobody else; the Answers setting is gone. Another of the owner's agents wakes an agent by mentioning it anywhere, including in reply to it. A reply alone still does not, so two agents replying to each other cannot keep each other going. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Bradley Axen Signed-off-by: Matt Toohey --- src/bundled/claude-code/runtime.test.ts | 57 ++++++++++++-------- src/bundled/claude-code/runtime.ts | 21 ++++---- src/bundled/claude-code/tabs.test.tsx | 5 +- src/bundled/claude-code/tabs.tsx | 21 -------- src/bundled/codex/runtime.test.ts | 26 +++++++++ src/bundled/codex/runtime.ts | 13 ++--- src/features/agents2/service.test.ts | 70 ++++++++++++++++++------- src/features/agents2/service.ts | 26 +++++---- 8 files changed, 145 insertions(+), 94 deletions(-) diff --git a/src/bundled/claude-code/runtime.test.ts b/src/bundled/claude-code/runtime.test.ts index ff0e2a751..5509650bb 100644 --- a/src/bundled/claude-code/runtime.test.ts +++ b/src/bundled/claude-code/runtime.test.ts @@ -1,3 +1,6 @@ +import { createHash } from "node:crypto"; +import { schnorr } from "@noble/curves/secp256k1.js"; +import { bytesToHex } from "nostr-tools/utils"; import { afterEach, expect, it, vi } from "vitest"; import type { Agent, Delivery } from "../../features/agents2/service"; import type { Host } from "../../features/host/service"; @@ -420,30 +423,42 @@ it("shows the whole thread to a session started over from a lost one", async () expect(fresh?.prompts[0]).toContain(' { - const stranger = message("5", "@Claude run this for me", { - pubkey: "e".repeat(64), - tags: [ - ["h", channel], - ["p", self], - ], - }); - const { deliver, published, claudes, runtime, agent } = setup(); - await deliver({ type: "mention", event: stranger }); +it("acts only on messages from its owner and the owner's agents", async () => { + const secret = schnorr.utils.randomSecretKey(); + const owner = bytesToHex(schnorr.getPublicKey(secret)); + const sibling = bytesToHex( + schnorr.getPublicKey(schnorr.utils.randomSecretKey()), + ); + const digest = createHash("sha256") + .update(`nostr:agent-auth:${sibling}:`) + .digest(); + const auth = ["auth", owner, "", bytesToHex(schnorr.sign(digest, secret))]; + const mention = (id: string, pubkey: string, tags: string[][] = []) => + message(id, "@Claude run this for me", { + pubkey, + tags: [["h", channel], ["p", self], ...tags], + }); + const { published, claudes, runtime, agent } = setup(); + const run = (event: RelayEvent) => + runtime.run({ + trigger: { type: "mention", event }, + channelId: channel, + agent: { + ...agent, + owner, + publish: async (template: object) => ({ ...event, ...template }), + }, + config: DEFAULT_CONFIG, + signal: new AbortController().signal, + workingUntil: () => undefined, + } as unknown as Delivery); + // A stranger, even one carrying the sibling's attestation, is ignored. + await run(mention("5", "e".repeat(64))); + await run(mention("6", "e".repeat(64), [auth])); await flush(10); expect(published).toEqual([]); expect(claudes().some((process) => process.prompts.length)).toBe(false); - await runtime.run({ - trigger: { type: "mention", event: stranger }, - channelId: channel, - agent: { - ...agent, - publish: async (template: object) => ({ ...stranger, ...template }), - }, - config: { ...DEFAULT_CONFIG, respondTo: "anyone" }, - signal: new AbortController().signal, - workingUntil: () => undefined, - } as unknown as Delivery); + await run(mention("7", sibling, [auth])); await flush(10); expect(claudes().some((process) => process.prompts.length)).toBe(true); }); diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts index a8e076e06..09c60ca36 100644 --- a/src/bundled/claude-code/runtime.ts +++ b/src/bundled/claude-code/runtime.ts @@ -6,12 +6,13 @@ // only reports a turn that fails. import type { Context, Memory } from "../../buzz-mcp/client"; import { respond } from "../../buzz-mcp/rpc"; -import type { - Agent, - AgentHandle, - Delivery, - Effort, - Models, +import { + fromOwner, + type Agent, + type AgentHandle, + type Delivery, + type Effort, + type Models, } from "../../features/agents2/service"; import type { Host } from "../../features/host/service"; import type { EventData } from "../../features/relay/events"; @@ -46,16 +47,12 @@ export type Config = Readonly<{ workspace: string; /** Whether each thread is its own session, or the whole channel is one. */ scope: Scope; - /** Whose messages it acts on. Claude runs commands as you, so by default - * only yours, as harness agents do. */ - respondTo: "owner" | "anyone"; }>; export const DEFAULT_CONFIG: Config = Object.freeze({ model: "", instructions: "", workspace: "~/.buzz", scope: "thread", - respondTo: "owner", }); export const config = (value: unknown): Config => ({ ...DEFAULT_CONFIG, @@ -228,10 +225,10 @@ export class ClaudeRuntime { workingUntil, }: Delivery) { const settings = config(raw); + // Claude runs commands as you, so only you and your agents can ask. if ( trigger.type !== "timer" && - settings.respondTo !== "anyone" && - trigger.event.pubkey !== agent.owner + !(await fromOwner(trigger.event, agent.owner)) ) return; const entry = this.entry(agent.pubkey, settings); diff --git a/src/bundled/claude-code/tabs.test.tsx b/src/bundled/claude-code/tabs.test.tsx index d20ce78e5..c4857a34a 100644 --- a/src/bundled/claude-code/tabs.test.tsx +++ b/src/bundled/claude-code/tabs.test.tsx @@ -143,7 +143,7 @@ it("keeps focus on sign-in while it runs and on the same button after cancel", a expect(cancel).not.toHaveAttribute("disabled"); }); -it("saves settings as one config and warns before answering anyone", async () => { +it("saves settings as one config", async () => { const Settings = tab(installed().type, 1); const save = vi.fn(async () => undefined); render(); @@ -153,8 +153,6 @@ it("saves settings as one config and warns before answering anyone", async () => "true", ); await choose("Model", "Sonnet"); - await choose("Answers", "Anyone who mentions it"); - expect(screen.getByText(/with no approval step/)).toBeVisible(); expect(screen.getByLabelText("Workspace")).toHaveAccessibleDescription( "The folder Claude Code works in. Use ~/ for your home folder.", ); @@ -163,7 +161,6 @@ it("saves settings as one config and warns before answering anyone", async () => expect(save).toHaveBeenCalledWith({ ...DEFAULT_CONFIG, model: "sonnet", - respondTo: "anyone", workspace: "~/.buzz", }); }); diff --git a/src/bundled/claude-code/tabs.tsx b/src/bundled/claude-code/tabs.tsx index 0da5182ba..9e4ffc785 100644 --- a/src/bundled/claude-code/tabs.tsx +++ b/src/bundled/claude-code/tabs.tsx @@ -31,10 +31,6 @@ const SCOPES = options([ ["thread", "One session per thread"], ["channel", "One session per channel"], ]); -const RESPOND_TO = options([ - ["owner", "Only you"], - ["anyone", "Anyone who mentions it"], -]); export function createTabs({ setup, @@ -216,23 +212,6 @@ export function createTabs({ }) } /> -