diff --git a/src/bundled/claude-code/base_prompt.md b/src/bundled/claude-code/base_prompt.md index 194f92a97..ce33c3c8d 100644 --- a/src/bundled/claude-code/base_prompt.md +++ b/src/bundled/claude-code/base_prompt.md @@ -32,9 +32,7 @@ Projects, issues, pull requests, repositories and agent drafts are not available `send` replies to the thread in the `` block by default; use that for ordinary replies in this turn. Do not reuse a remembered thread id, an older event id from prior work, or a stale conversation root. -For human-facing work, keep the conversation flat and easy to read. The app/harness will choose the correct reply destination: the root of the triggering thread when the turn is already threaded, or the triggering top-level event when the human started a new thread. - -For agent-to-agent coordination with no human in the loop, deeper nesting is allowed when it helps preserve task structure. Do not flatten agent-only subthreads just because they are inside a thread. +Conversations stay flat: a reply to any message in a thread goes to the thread's root. The app/harness will choose the correct reply destination: the root of the triggering thread when the turn is already threaded, or the triggering top-level event when the human started a new thread. When in doubt, prefer the reply destination explicitly supplied in ``. If you intentionally choose a different destination, explain why briefly in the message. @@ -52,7 +50,7 @@ All replies and delegations — including task assignments to other agents — g - Use GitHub-flavored Markdown. Fenced code blocks with language tags for syntax highlighting. - An @mention wakes you, but not while a tool call is running. Never use a tool call (`sleep`, `--watch`) to wait on CI, teammates, or other external events; hand off and end your turn. To catch up once, use `read`. - Address people using the name shown in their own message header. Preserve it exactly; do not infer, expand, or look up a surname merely to address them. -- Use top-level channel-visible posts for milestones teammates must act on: picked up, blocked + need input, PR up, done. +- Use the `everyone` audience for milestones teammates must act on: picked up, blocked + need input, PR up, done. - Praise in public; correct in the work, not the person. ## Workspace Layout @@ -91,7 +89,7 @@ Your `core` memory is auto-injected into your context every turn — it holds id These are guidelines, not a fixed procedure — apply judgment to the task in front of you. -- **Work in the open.** Your tool calls and reasoning are invisible to humans — narrate as you go in brief messages, and never go dark between "picked up" and "done." If you didn't post it, it didn't happen. +- **Work in the open.** Your tool calls and reasoning are invisible to humans — narrate progress as you go to the `agents` audience, and never go dark between "picked up" and "done." If you didn't post it, it didn't happen. - **Be candid.** Say "I don't know" instead of bluffing, then find out when the answer is knowable. - **Understand before changing.** Read the actual files, trace call paths, and confirm helpers and types exist before you plan or edit. - **Plan briefly, then build.** Be opinionated about the safest concrete approach. Solve the stated problem and nothing more — avoid opportunistic refactors and premature abstraction. diff --git a/src/bundled/claude-code/prompt.test.ts b/src/bundled/claude-code/prompt.test.ts index 91a4aa5be..a3d0b7756 100644 --- a/src/bundled/claude-code/prompt.test.ts +++ b/src/bundled/claude-code/prompt.test.ts @@ -1,6 +1,7 @@ import { npubEncode } from "nostr-tools/nip19"; import { expect, it } from "vitest"; import type { EventData } from "../../features/relay/events"; +import { BUZZ_PROMPT } from "../../buzz-mcp/prompt"; import { steerPrompt, systemPrompt, timerPrompt, turnPrompt } from "./prompt"; const alice = "a".repeat(64); @@ -35,6 +36,7 @@ it("builds the harness system prompt with instructions and core memory", () => { memory: "I am Claude.", }); expect(prompt).toMatch(/^\n/); + expect(prompt).toContain(BUZZ_PROMPT); expect(prompt).toContain("## Session Model"); expect(prompt).toContain( "\nCurrent working directory: ~/.buzz\n", diff --git a/src/bundled/claude-code/prompt.ts b/src/bundled/claude-code/prompt.ts index 8c813d2da..fb9e0979e 100644 --- a/src/bundled/claude-code/prompt.ts +++ b/src/bundled/claude-code/prompt.ts @@ -1,6 +1,7 @@ // Prompts in the harness's format (buzz-acp), so a Claude agent here reads the // same sections and follows the same base prompt as a harness agent. import { npubEncode } from "nostr-tools/nip19"; +import { BUZZ_PROMPT } from "../../buzz-mcp/prompt"; import type { EventData } from "../../features/relay/events"; import basePrompt from "./base_prompt.md?raw"; import channelModel from "./session_model_channel.md?raw"; @@ -24,7 +25,10 @@ export function systemPrompt( ) { const model = input.scope === "thread" ? threadModel : channelModel; return [ - section("base", `${basePrompt.trim()}\n\n${model.trim()}`), + section( + "base", + `${basePrompt.trim()}\n\n${BUZZ_PROMPT}\n\n${model.trim()}`, + ), section("workspace", `Current working directory: ${input.cwd}`), input.instructions?.trim() ? section("agent-instructions", input.instructions) diff --git a/src/bundled/claude-code/runtime.test.ts b/src/bundled/claude-code/runtime.test.ts index ff0e2a751..577fe0063 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"; @@ -253,7 +256,10 @@ it("answers Claude's tool calls as the agent, in the thread it is working in", a jsonrpc: "2.0", id: 7, method: "tools/call", - params: { name: "send", arguments: { text: "Looking now" } }, + params: { + name: "send", + arguments: { audience: "everyone", text: "Looking now" }, + }, }, }, }); @@ -310,7 +316,10 @@ it("reads a busy turn's files from the workspace it started in", async () => { jsonrpc: "2.0", id: 7, method: "tools/call", - params: { name: "send", arguments: { path: "report.md" } }, + params: { + name: "send", + arguments: { audience: "everyone", path: "report.md" }, + }, }, }, }); @@ -420,30 +429,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..c18f52df5 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); @@ -311,15 +308,13 @@ export class ClaudeRuntime { event: EventData, error: string, ) { - const root = threadReference(event)?.rootId ?? event.id; return agent .publish({ kind: 9, content: `⚠️ I couldn't finish that: ${error.slice(0, 500)}`, tags: [ ["h", channel], - ...(root === event.id ? [] : [["e", root, "", "root"]]), - ["e", event.id, "", "reply"], + ["e", threadReference(event)?.rootId ?? event.id, "", "reply"], ["p", event.pubkey], ], }) 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({ }) } /> -