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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions src/bundled/claude-code/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
// this computer. Each conversation the agent is in is one continuing session;
// Claude answers with the in-process Buzz tools.
import { isTauri } from "@tauri-apps/api/core";
import { ownerReads } from "../../features/agents/reads";
import type { PluginModule } from "../../plugins/api";
import { claudeRun, TYPE_ID } from "./run";
import { ClaudeRuntime, type Config, config, DEFAULT_CONFIG } from "./runtime";
import { ClaudeSetup } from "./setup";
import { Create, createTabs } from "./tabs";

export const TYPE_ID = "claude-code";
/** A turn's run returns once Claude has it, so this bounds only the hand-over. */
const HANDOVER_MS = 2 * 60_000;
export { TYPE_ID };

export const inject = ["agents", "host"];
export const apply: PluginModule["apply"] = (ctx) => {
Expand All @@ -20,7 +20,9 @@ export const apply: PluginModule["apply"] = (ctx) => {
const typeKey = `${ctx.pluginOwner?.id ?? "buzz.claude-code"}/${TYPE_ID}`;
const spawn = host.spawn?.bind(host);
const setup = new ClaudeSetup(spawn);
const runtime = new ClaudeRuntime(host, agents.relay);
const runtime = new ClaudeRuntime(host, (pubkey) =>
ownerReads(agents.relay(pubkey), pubkey),
);
const sync = () => {
const snapshot = agents.snapshot();
// Agents that stop running, the app signing out included, stop their
Expand Down Expand Up @@ -57,8 +59,6 @@ export const apply: PluginModule["apply"] = (ctx) => {
{ id: "claude", title: "Claude", component: ClaudeTab },
{ id: "settings", title: "Settings", component: SettingsTab },
],
models: (agent) => runtime.models(agent.pubkey),
run: (delivery) => runtime.run(delivery),
timeoutMs: HANDOVER_MS,
...claudeRun(runtime),
});
};
17 changes: 17 additions & 0 deletions src/bundled/claude-code/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// The part of the Claude agent type that runs agents, without its views: the
// desktop app registers it with its tabs, and a standalone host runs it alone.
import type { AgentType } from "../../features/agents/service";
import type { ClaudeRuntime, Config } from "./runtime";

export const TYPE_ID = "claude-code";
/** A turn's run returns once Claude has it, so this bounds only the hand-over. */
const HANDOVER_MS = 2 * 60_000;

/** How agents of this type run on `runtime`. */
export const claudeRun = (
runtime: ClaudeRuntime,
): Pick<AgentType<Config>, "models" | "run" | "timeoutMs"> => ({
models: (agent) => runtime.models(agent.pubkey),
run: (delivery) => runtime.run(delivery),
timeoutMs: HANDOVER_MS,
});
7 changes: 6 additions & 1 deletion src/bundled/claude-code/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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 { ownerReads } from "../../features/agents/reads";
import type { Agent, Delivery } from "../../features/agents/service";
import type { Host } from "../../features/host/service";
import type { EventData, RelayEvent } from "../../features/relay/events";
Expand Down Expand Up @@ -71,7 +72,11 @@ function setup(
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => void data.set(key, value),
} as Storage;
const runtime = new ClaudeRuntime(host, () => relay, storage);
const runtime = new ClaudeRuntime(
host,
(pubkey) => ownerReads(relay, pubkey),
storage,
);
const published: { kind: number; content: string; tags: string[][] }[] = [];
const publish = vi.fn(async (template: (typeof published)[number]) => {
published.push(template);
Expand Down
82 changes: 30 additions & 52 deletions src/bundled/claude-code/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,17 @@
// only reports a turn that fails.
import type { Context, Memory } from "../../buzz-mcp/client";
import { respond } from "../../buzz-mcp/rpc";
import {
fromOwner,
type Agent,
type AgentHandle,
type Delivery,
type Effort,
type Models,
import { fromOwner } from "../../features/agents/owner-attestation";
import type { AgentReads } from "../../features/agents/reads";
import type {
Agent,
AgentHandle,
Delivery,
Effort,
Models,
} from "../../features/agents/service";
import type { Host } from "../../features/host/service";
import type { EventData } from "../../features/relay/events";
import type { RelayData } from "../../features/relay/service";
import { threadReference } from "../../features/relay/thread-reference";
import type { Settled, Spawn, ToolServer } from "./claude";
import { appClient } from "../../buzz-mcp/app-client";
Expand Down Expand Up @@ -111,9 +111,9 @@ export class ClaudeRuntime {
);

constructor(
private readonly host: Host,
/** The owner's connection to each agent's own community. */
private readonly relay: (pubkey: string) => RelayData | undefined,
private readonly host: Pick<Host, "spawn">,
/** What each agent's turns read of its community, while it can be read. */
private readonly reads: (pubkey: string) => AgentReads | undefined,
private readonly storage: Storage = globalThis.localStorage,
) {}

Expand Down Expand Up @@ -464,29 +464,26 @@ export class ClaudeRuntime {
channelId: string,
fresh = false,
) {
const snapshot = this.relay(self)?.snapshot();
const session = snapshot?.status === "ready" ? snapshot.session : undefined;
const summary =
session?.channels.list().channels.find((item) => item.id === channelId) ??
session?.channels.get?.(channelId);
const dm = summary?.channelType === "dm";
const reads = this.reads(self);
const summary = await reads?.channel(channelId);
const dm = summary?.dm === true;
const thread = threadReference(event);
const rootId = thread?.rootId ?? event.id;
const scope = entry.config.scope;
const key =
dm || scope === "channel" ? channelId : `${channelId}/${rootId}`;
const seen = fresh ? 0 : entry.sessions.seen(key);
let earlier: readonly EventData[] = [];
if (session && (thread || dm))
if (reads && (thread || dm))
try {
const events = await session.read(
const events = await reads.read(
thread
? [
{ ids: [rootId], limit: 1 },
{ kinds: CHAT, "#h": [channelId], "#e": [rootId], limit: 200 },
]
: [{ kinds: CHAT, "#h": [channelId], limit: CONTEXT_LIMIT + 1 }],
{ signal: AbortSignal.timeout(NAMES_TIMEOUT_MS * 2) },
AbortSignal.timeout(NAMES_TIMEOUT_MS * 2),
);
earlier = events
.filter(
Expand All @@ -512,9 +509,9 @@ export class ClaudeRuntime {
.map((tag) => tag[1] ?? ""),
]),
].filter(Boolean);
if (session)
if (reads)
await Promise.race([
session.profiles.ensure(pubkeys, "foreground").catch(() => undefined),
reads.profiles(pubkeys).catch(() => undefined),
new Promise((resolve) => setTimeout(resolve, NAMES_TIMEOUT_MS)),
]);
const members = summary?.members ?? [];
Expand All @@ -530,10 +527,7 @@ export class ClaudeRuntime {
...(thread ? { thread } : {}),
context: unseen,
total: earlier.length + 1,
name: (pubkey: string) =>
session?.names.resolve(pubkey, undefined, members) ??
session?.profiles.snapshot().get(pubkey)?.name ??
undefined,
name: (pubkey: string) => reads?.name(pubkey, members),
};
}

Expand Down Expand Up @@ -626,8 +620,7 @@ export class ClaudeRuntime {
return value;
}

/** The agent's memory, read back through the owner's session: it is
* encrypted to the owner, so no agent key is needed to read it. */
/** The agent's memory, as its community's reads give it. */
private memories(pubkey: string): Promise<readonly Memory[]> {
// The owner's session holds few memory views at once, so calls share one.
let reading = this.reading.get(pubkey);
Expand All @@ -640,32 +633,17 @@ export class ClaudeRuntime {
return reading;
}
private async readMemories(pubkey: string): Promise<readonly Memory[]> {
const snapshot = this.relay(pubkey)?.snapshot();
if (snapshot?.status !== "ready") throw new Error("Buzz is not connected");
const view = snapshot.session.agentMemories.open(pubkey);
const reads = this.reads(pubkey);
if (!reads) throw new Error("Buzz is not connected");
const stop = new AbortController();
const timer = setTimeout(
() => stop.abort(new Error("Memory read timed out")),
MEMORY_TIMEOUT_MS,
);
try {
const done = new Promise<void>((resolve) => {
const settled = () =>
!["idle", "loading"].includes(view.snapshot().status) && resolve();
view.subscribe(settled);
settled();
});
void view.refresh();
await Promise.race([
done,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error("Memory read timed out")),
MEMORY_TIMEOUT_MS,
),
),
]);
const { status, listing } = view.snapshot();
if (status !== "ready" || !listing)
throw new Error(`Memory unavailable (${status})`);
return listing.entries;
return await reads.memories(stop.signal);
} finally {
view.dispose();
clearTimeout(timer);
}
}
}
Expand Down
14 changes: 8 additions & 6 deletions src/bundled/codex/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { isTauri } from "@tauri-apps/api/core";
import { ownerReads } from "../../features/agents/reads";
import type { PluginModule } from "../../plugins/api";
import { config, defaults, validWorkspace } from "./config";
import { codexRun, TYPE_ID } from "./run";
import { CodexRuntime } from "./runtime";
import { createTabs } from "./tabs";
export const inject = ["agents", "host"];
Expand All @@ -14,8 +16,10 @@ export const apply: PluginModule["apply"] = (ctx) => {
(async () => {
throw new Error("Codex agents run only in the desktop app");
});
const runtime = new CodexRuntime(start, ctx.agents.relay);
const typeKey = `${ctx.pluginOwner?.id ?? "buzz.codex"}/codex`;
const runtime = new CodexRuntime(start, (pubkey) =>
ownerReads(ctx.agents.relay(pubkey), pubkey),
);
const typeKey = `${ctx.pluginOwner?.id ?? "buzz.codex"}/${TYPE_ID}`;
// Agents that stop running stop their turns, and the last one stops the
// server; switching communities or a reconnect does not.
const sync = () => {
Expand All @@ -31,7 +35,7 @@ export const apply: PluginModule["apply"] = (ctx) => {
});
const { CodexTab, SettingsTab, Create } = createTabs(start, runtime);
ctx.agents.register({
id: "codex",
id: TYPE_ID,
title: "Codex",
description:
"Codex on this computer, using its native coding tools and your account.",
Expand All @@ -51,8 +55,6 @@ export const apply: PluginModule["apply"] = (ctx) => {
{ id: "codex", title: "Codex", component: CodexTab },
{ id: "settings", title: "Settings", component: SettingsTab },
],
models: (agent) => runtime.preview(agent),
run: (delivery) => runtime.run(delivery),
timeoutMs: 2 * 60_000,
...codexRun(runtime),
});
};
18 changes: 18 additions & 0 deletions src/bundled/codex/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// The part of the Codex agent type that runs agents, without its views: the
// desktop app registers it with its tabs, and a standalone host runs it alone.
import type { AgentType } from "../../features/agents/service";
import type { Config } from "./config";
import type { CodexRuntime } from "./runtime";

export const TYPE_ID = "codex";
/** A turn's run returns once Codex has it, so this bounds only the hand-over. */
const HANDOVER_MS = 2 * 60_000;

/** How agents of this type run on `runtime`. */
export const codexRun = (
runtime: CodexRuntime,
): Pick<AgentType<Config>, "models" | "run" | "timeoutMs"> => ({
models: (agent) => runtime.preview(agent),
run: (delivery) => runtime.run(delivery),
timeoutMs: HANDOVER_MS,
});
7 changes: 6 additions & 1 deletion src/bundled/codex/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { schnorr } from "@noble/curves/secp256k1.js";
import { bytesToHex } from "nostr-tools/utils";
import { apply } from "./index";
import { describe, afterEach, expect, it, vi } from "vitest";
import { ownerReads } from "../../features/agents/reads";
import type { Agent, Delivery } from "../../features/agents/service";
import type { RelayData } from "../../features/relay/service";
import type { EventData } from "../../features/relay/events";
Expand Down Expand Up @@ -203,7 +204,11 @@ function fixture(
storage.set(key, value);
},
} as Storage;
const runtime = new CodexRuntime(spawn, () => relay, store);
const runtime = new CodexRuntime(
spawn,
(pubkey) => ownerReads(relay, pubkey),
store,
);
runtimes.push(runtime);
const events: EventData[] = [];
const query = vi.fn(async (filters: readonly Record<string, unknown>[]) =>
Expand Down
Loading
Loading