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
5 changes: 4 additions & 1 deletion docs/communities.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,10 @@ community origin and viewer; channel IDs alone are not sufficient keys.
## Session lifetime

Selecting a saved membership lazily acquires its session. Sessions survive page
navigation and switching; startup opens only the selected one. Opened communities
navigation and switching; startup opens only the selected one. Once its agent
list loads, Agents2 also opens each joined community where the viewer has an
agent, through `open`, which connects without selecting so those agents keep
running, and retries one whose connection failed while it is not selected. Opened communities
retain their existing per-session query/cache budgets. Arbitrary destinations mean
there is **no longer a two-session maximum**: retained session count grows with
communities opened until app disposal. This slice does not add background eviction
Expand Down
2 changes: 1 addition & 1 deletion src/app/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export function createServices() {
subscribe: communities.subscribe,
});
const relay = communities.relay;
const agents2 = new Agents2Service(ctx, relay);
const agents2 = new Agents2Service(ctx, communities);
ctx.effect(() => bindAgentMentions(agentControl, communities));
ctx.effect(() => bindTeamTextSync(agentControl, communities));
const notifications = new NotificationsService(
Expand Down
3 changes: 2 additions & 1 deletion src/bundled/agents2/Agents2Page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const agent = (name: string, n: number): Agent => ({
});

function setup(agents: Agent[], { shown = true } = {}) {
let snapshot: AgentsSnapshot = { status: "ready", agents };
let snapshot: AgentsSnapshot = { status: "ready", agents, running: agents };
const listeners = new Set<() => void>();
const type = {
key: "test/echo",
Expand All @@ -73,6 +73,7 @@ function setup(agents: Agent[], { shown = true } = {}) {
return () => listeners.delete(listener);
},
find: (pubkey: string) => snapshot.agents.find((a) => a.pubkey === pubkey),
relay: () => undefined,
register: vi.fn(),
create: vi.fn(async () => {
// `shown: false`: it was made for a community no longer selected.
Expand Down
12 changes: 10 additions & 2 deletions src/bundled/builderlab/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HostService } from "../../features/host/service";
import { SettingsCardsService } from "../../features/settings/service";
import { Agents2Service } from "../../features/agents2/service";
import {
Agents2Service,
type AgentCommunities,
} from "../../features/agents2/service";
import type { RelayData } from "../../features/relay/service";
import { Agents2Page } from "../agents2/Agents2Page";
import * as builderlab from "./index";
Expand Down Expand Up @@ -42,7 +45,12 @@ afterEach(() => {
});

function provideAgents2(root: Context, relay: RelayData) {
return new Agents2Service(root, relay, {
const communities = {
snapshot: () => ({ memberships: [], selected: null }),
subscribe: () => () => {},
open: () => relay,
} as unknown as AgentCommunities;
return new Agents2Service(root, communities, {
list: async () => [],
create: vi.fn(),
rename: vi.fn(),
Expand Down
21 changes: 17 additions & 4 deletions src/bundled/claude-code/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export class ClaudeProcess {
private constructor(
private readonly process: HostProcess,
resume: string | undefined,
private readonly tools: ToolServer | undefined,
private tools: ToolServer | undefined,
) {
this.sessionId = resume;
this.exited = process.exited.then((code) => {
Expand All @@ -104,6 +104,14 @@ export class ClaudeProcess {
}

static async start(spawn: Spawn, launch: ClaudeLaunch) {
const claude = await ClaudeProcess.spawn(spawn, launch);
await claude.initialize(launch);
return claude;
}

/** Starts `claude` without its prompt. This is most of what a start costs,
* and fixes nothing about the agent it serves but its folder and model. */
static async spawn(spawn: Spawn, launch: Omit<ClaudeLaunch, "systemPrompt">) {
let claude: ClaudeProcess | undefined;
const early: string[] = [];
const earlyErrors: string[] = [];
Expand Down Expand Up @@ -150,8 +158,14 @@ export class ClaudeProcess {
);
for (const data of earlyErrors) claude.readError(data);
for (const data of early) claude.read(data);
// Loads settings, tools and MCP servers now rather than on the first message.
await claude.request({
return claude;
}

/** Gives a started process its prompt, and its tools to whoever it now
* works for. Loads settings and tools now rather than on the first message. */
async initialize(launch: Pick<ClaudeLaunch, "systemPrompt" | "tools">) {
if (launch.tools) this.tools = launch.tools;
await this.request({
subtype: "initialize",
appendSystemPrompt: launch.systemPrompt,
...(launch.tools
Expand All @@ -165,7 +179,6 @@ export class ClaudeProcess {
}
: {}),
});
return claude;
}

get running() {
Expand Down
13 changes: 7 additions & 6 deletions src/bundled/claude-code/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,24 @@ 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 const inject = ["agents2", "host", "relay"];
export const inject = ["agents2", "host"];
export const apply: PluginModule["apply"] = (ctx) => {
// Only the desktop app can start `claude`, and setup and file uploads run
// `bash` and `base64`, which stock Windows lacks; no unusable type elsewhere.
if (!isTauri() || !/Mac|Linux/i.test(navigator.platform)) return;
const { agents2, host, relay } = ctx;
const { agents2, host } = 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, relay);
const runtime = new ClaudeRuntime(host, agents2.relay);
const sync = () => {
const snapshot = agents2.snapshot();
// Agents leaving view, the app signing out included, stop their processes.
// Agents that stop running, the app signing out included, stop their
// processes; switching communities does not.
if (snapshot.status === "loading") return;
runtime.sync(snapshot.agents.filter((agent) => agent.type === typeKey));
runtime.sync(snapshot.running.filter((agent) => agent.type === typeKey));
};
// Agents come and go with the community; a spare session is warmed for each.
// One spare session waits for whichever running agent needs it next.
if (spawn) {
const unsubscribe = agents2.subscribe(sync);
sync();
Expand Down
29 changes: 24 additions & 5 deletions src/bundled/claude-code/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ function setup(thread: readonly EventData[] = []) {
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, () => relay, storage);
const published: { kind: number; content: string; tags: string[][] }[] = [];
const publish = vi.fn(async (template: (typeof published)[number]) => {
published.push(template);
Expand Down Expand Up @@ -294,7 +294,8 @@ it("shows the whole thread to a session started over from a lost one", async ()
);
await deliver({ type: "mention", event: mention });
await flush(20);
const [lost, fresh] = claudes();
const [lost] = claudes();
const fresh = claudes().find((process) => process.prompts.length);
expect(lost?.options.args).toContain("lost");
expect(lost?.prompts).toEqual([]);
expect(fresh?.prompts[0]).toContain('<thread-context included="1" total="2"');
Expand Down Expand Up @@ -339,13 +340,31 @@ it("runs a timer in its own session", async () => {
expect(claude?.prompts[0]).toContain("Timer: watch/daily");
});

it("warms a spare for each agent of its type and stops those that go away", async () => {
it("keeps one spare for all its agents, and none once they are gone", async () => {
const { runtime, claudes } = setup();
const agent = { pubkey: self, config: DEFAULT_CONFIG } as Agent;
runtime.sync([agent]);
const agent = (pubkey: string) =>
({ pubkey, config: DEFAULT_CONFIG }) as Agent;
runtime.sync([agent(self), agent(alice)]);
await flush(10);
expect(claudes()).toHaveLength(1);
expect(claudes()[0]?.options.cwd).toBe(DEFAULT_CONFIG.workspace);
runtime.sync([]);
await flush(10);
expect(claudes().every((process) => process.killed)).toBe(true);
});

it("replaces the spare when no agent runs where it was started", async () => {
const { runtime, claudes } = setup();
runtime.sync([{ pubkey: self, config: DEFAULT_CONFIG } as Agent]);
await flush(10);
runtime.sync([
{ pubkey: self, config: { ...DEFAULT_CONFIG, model: "sonnet" } } as Agent,
]);
await flush(10);
const [old, next] = claudes();
expect(old?.killed).toBe(true);
expect(next?.options.args).toEqual(
expect.arrayContaining(["--model", "sonnet"]),
);
expect(claudes()).toHaveLength(2);
});
46 changes: 31 additions & 15 deletions src/bundled/claude-code/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
timerPrompt,
turnPrompt,
} from "./prompt";
import { AgentSessions, localSessions } from "./sessions";
import { AgentSessions, localSessions, Spare } from "./sessions";

export type Config = Readonly<{
/** A Claude model or alias; empty uses Claude Code's own default. */
Expand Down Expand Up @@ -67,6 +67,12 @@ type Entry = {
contexts: Map<string, Context>;
};

/** Where an agent's processes run, and with which model. */
const where = ({ workspace, model }: Config) => ({
cwd: workspace,
...(model.trim() ? { model: model.trim() } : {}),
});

export class ClaudeRuntime {
private readonly agents = new Map<string, Entry>();
private readonly reading = new Map<string, Promise<readonly Memory[]>>();
Expand All @@ -77,10 +83,16 @@ export class ClaudeRuntime {
string,
ReturnType<AgentSessions["snapshot"]>
>();
/** One process started ahead of need, for any agent's next new conversation. */
private readonly spare = new Spare(
(id, options) => this.spawn(id, options),
(_conversation, message) => respond(undefined, {}, message),
);

constructor(
private readonly host: Host,
private readonly relay: RelayData,
/** The owner's connection to each agent's own community. */
private readonly relay: (pubkey: string) => RelayData | undefined,
private readonly storage: Storage = globalThis.localStorage,
) {}

Expand All @@ -91,11 +103,12 @@ export class ClaudeRuntime {
return spawn;
}

/** Brings the runtime in line with this type's agents: new ones get a warm
* session, changed settings restart idle sessions, removed agents stop. */
/** Brings the runtime in line with this type's agents: changed settings
* restart idle sessions, removed agents stop, and a spare waits while any
* agent is here. */
sync(agents: readonly Agent[]) {
const current = new Set(agents.map((agent) => agent.pubkey));
// Out of view (another community, offline) or deleted: either way its
// Deleted, signed out or its community disconnected: either way its
// processes stop. Saved sessions stay so it resumes when it is back.
for (const [pubkey, entry] of this.agents)
if (!current.has(pubkey)) {
Expand All @@ -105,12 +118,13 @@ export class ClaudeRuntime {
for (const agent of agents) {
const next = config(agent.config);
const entry = this.agents.get(agent.pubkey);
if (!entry) this.entry(agent.pubkey, next).sessions.warm();
if (!entry) this.entry(agent.pubkey, next);
else if (JSON.stringify(entry.config) !== JSON.stringify(next)) {
entry.config = next;
entry.sessions.reconfigure();
}
}
this.spare.warm(...agents.map((agent) => where(config(agent.config))));
this.notify();
}

Expand All @@ -135,6 +149,7 @@ export class ClaudeRuntime {
}

dispose() {
this.spare.dispose();
for (const entry of this.agents.values()) entry.sessions.dispose();
this.agents.clear();
}
Expand All @@ -149,6 +164,9 @@ export class ClaudeRuntime {
return;
const entry = this.entry(agent.pubkey, settings);
entry.handle = agent;
// Read while the turn's prompt is built, not after: a new session's prompt
// needs it, and a spare is only faster if nothing else is awaited.
void this.memory(agent.pubkey, entry);
if (trigger.type === "timer") {
const done = entry.sessions.deliver(
`timer/${trigger.slug}`,
Expand Down Expand Up @@ -242,8 +260,8 @@ export class ClaudeRuntime {
channelId: string,
fresh = false,
) {
const snapshot = this.relay.snapshot();
const session = snapshot.status === "ready" ? snapshot.session : undefined;
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);
Expand Down Expand Up @@ -327,8 +345,6 @@ export class ClaudeRuntime {
const tools =
(cwd: string): ToolServer =>
async (conversation, message) => {
// A spare starts before any delivery: it can list the tools, and has a
// handle to call them with by the time it is given a turn.
const handle = entry.handle;
const client = handle && {
...appClient(handle, {
Expand All @@ -347,6 +363,7 @@ export class ClaudeRuntime {
};
entry.sessions = new AgentSessions({
spawn: (id, options) => this.spawn(id, options),
spare: this.spare,
store: localSessions(this.storage, pubkey),
onChange: () => this.notify(),
fingerprint: () =>
Expand All @@ -357,12 +374,11 @@ export class ClaudeRuntime {
entry.config.scope,
]),
launch: async () => {
const { model, workspace, instructions, scope } = entry.config;
const { workspace, instructions, scope } = entry.config;
const memory = await this.memory(pubkey, entry);
return {
cwd: workspace,
...where(entry.config),
tools: tools(workspace),
...(model.trim() ? { model: model.trim() } : {}),
systemPrompt: systemPrompt({
scope,
cwd: workspace,
Expand Down Expand Up @@ -406,8 +422,8 @@ export class ClaudeRuntime {
return reading;
}
private async readMemories(pubkey: string): Promise<readonly Memory[]> {
const snapshot = this.relay.snapshot();
if (snapshot.status !== "ready") throw new Error("Buzz is not connected");
const snapshot = this.relay(pubkey)?.snapshot();
if (snapshot?.status !== "ready") throw new Error("Buzz is not connected");
const view = snapshot.session.agentMemories.open(pubkey);
try {
const done = new Promise<void>((resolve) => {
Expand Down
Loading
Loading