From 769892f29844d4d5dd0090a61cba11101e8bed01 Mon Sep 17 00:00:00 2001 From: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz> Date: Fri, 9 Oct 2026 17:38:00 -0700 Subject: [PATCH 01/39] fix(agents2): keep agents running when another community is selected Agents2 listened only to the selected community, and the Claude Code and Codex runtimes stopped the processes of agents that left that list, so switching communities killed work mid-turn and left the other community unheard. Agents2 now binds each joined community the viewer has an agent in (opened through a new Communities.open, without selecting it), delivers each community's live events only to its own agents, and exposes the running agents and each agent's community connection. The runtimes keep processes for every running agent and read conversations, names and memory from the agent's own community. Processes stop when the agent is deleted, its community is left or disconnected, or the viewer signs out. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz> --- docs/communities.md | 4 +- src/app/services.ts | 2 +- src/bundled/agents2/Agents2Page.test.tsx | 3 +- src/bundled/builderlab/index.test.tsx | 12 +- src/bundled/claude-code/index.ts | 13 +- src/bundled/claude-code/runtime.test.ts | 2 +- src/bundled/claude-code/runtime.ts | 13 +- src/bundled/claude-code/tabs.test.tsx | 6 +- src/bundled/codex/README.md | 3 +- src/bundled/codex/index.test.ts | 5 +- src/bundled/codex/index.ts | 29 +-- src/bundled/codex/runtime.test.ts | 39 +--- src/bundled/codex/runtime.ts | 33 ++- src/bundled/codex/tabs.test.tsx | 3 +- src/features/agents2/service.test.ts | 125 ++++++++++- src/features/agents2/service.ts | 256 ++++++++++++++++------- src/features/communities/service.ts | 8 + 17 files changed, 382 insertions(+), 174 deletions(-) diff --git a/docs/communities.md b/docs/communities.md index 66479b7a3..dd1ee1106 100644 --- a/docs/communities.md +++ b/docs/communities.md @@ -114,7 +114,9 @@ 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, plus each joined +community where the viewer has an Agents2 agent, which `open` connects without +selecting so its agents keep running. 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 diff --git a/src/app/services.ts b/src/app/services.ts index 287dd7e29..7193908fb 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -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( diff --git a/src/bundled/agents2/Agents2Page.test.tsx b/src/bundled/agents2/Agents2Page.test.tsx index 1e54dbb7e..3eb719e8a 100644 --- a/src/bundled/agents2/Agents2Page.test.tsx +++ b/src/bundled/agents2/Agents2Page.test.tsx @@ -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", @@ -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. diff --git a/src/bundled/builderlab/index.test.tsx b/src/bundled/builderlab/index.test.tsx index 900d6310d..c07d2ad43 100644 --- a/src/bundled/builderlab/index.test.tsx +++ b/src/bundled/builderlab/index.test.tsx @@ -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"; @@ -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(), diff --git a/src/bundled/claude-code/index.ts b/src/bundled/claude-code/index.ts index 17c510d96..89eaec9c0 100644 --- a/src/bundled/claude-code/index.ts +++ b/src/bundled/claude-code/index.ts @@ -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. + // A spare session is warmed for each running agent. if (spawn) { const unsubscribe = agents2.subscribe(sync); sync(); diff --git a/src/bundled/claude-code/runtime.test.ts b/src/bundled/claude-code/runtime.test.ts index 437c2410f..a044deacd 100644 --- a/src/bundled/claude-code/runtime.test.ts +++ b/src/bundled/claude-code/runtime.test.ts @@ -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); diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts index 874fc8251..69955251e 100644 --- a/src/bundled/claude-code/runtime.ts +++ b/src/bundled/claude-code/runtime.ts @@ -80,7 +80,8 @@ export class ClaudeRuntime { 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, ) {} @@ -95,7 +96,7 @@ export class ClaudeRuntime { * session, changed settings restart idle sessions, removed agents stop. */ 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)) { @@ -242,8 +243,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); @@ -406,8 +407,8 @@ export class ClaudeRuntime { return reading; } private async readMemories(pubkey: string): Promise { - 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((resolve) => { diff --git a/src/bundled/claude-code/tabs.test.tsx b/src/bundled/claude-code/tabs.test.tsx index 5d1a128ea..d20ce78e5 100644 --- a/src/bundled/claude-code/tabs.test.tsx +++ b/src/bundled/claude-code/tabs.test.tsx @@ -61,12 +61,12 @@ function applied({ loggedIn = true } = {}) { const ctx = { pluginOwner: { id: "buzz.claude-code", revision: "r1" }, host: { spawn, request: vi.fn() }, - relay: { snapshot: () => ({ status: "signed-out" }) }, agents2: { register: (registered: AgentType) => { type = registered; }, - snapshot: () => ({ status: "ready", agents: [] }), + snapshot: () => ({ status: "ready", agents: [], running: [] }), + relay: () => undefined, subscribe: () => () => undefined, }, effect: vi.fn(), @@ -91,7 +91,7 @@ async function choose(label: string, option: string) { } it("is an agent type on the desktop app", () => { - expect(inject).toEqual(["agents2", "host", "relay"]); + expect(inject).toEqual(["agents2", "host"]); const { type } = installed(); expect(type).toMatchObject({ id: "claude-code", title: "Claude Code" }); expect(type.defaults()).toEqual({ config: DEFAULT_CONFIG }); diff --git a/src/bundled/codex/README.md b/src/bundled/codex/README.md index 72edcd631..b13bedc13 100644 --- a/src/bundled/codex/README.md +++ b/src/bundled/codex/README.md @@ -47,7 +47,8 @@ current turn finishes. Expect the marker. Thread scope is the default. Channel scope shares a session across channel threads; DMs always share a conversation. Workspace changes start a fresh session. Bindings persist locally under the community and agent identity. Earlier bindings without dynamic tools are ignored; new conversations start fresh. Switching -communities, removing an agent, or disabling the plugin ends its server. +communities does not end its server; removing the agent, leaving or disconnecting +from its community, or disabling the plugin does. If a saved Codex thread no longer exists, the next mention starts a fresh one. To inspect a saved session, see [View a Codex session](../../../docs/view-codex-session.md). diff --git a/src/bundled/codex/index.test.ts b/src/bundled/codex/index.test.ts index 8bb490d70..4672d3643 100644 --- a/src/bundled/codex/index.test.ts +++ b/src/bundled/codex/index.test.ts @@ -23,13 +23,12 @@ function applied() { react: React, pluginOwner: { id: "buzz.codex", revision: "r1" }, host: { spawn: vi.fn(), request: vi.fn() }, - relay: { snapshot: () => ({ status: "signed-out" }), subscribe }, - communityReader: { snapshot: () => ({}), subscribe }, agents2: { register: (registered: AgentType) => { type = registered; }, - snapshot: () => ({ status: "ready", agents: [] }), + snapshot: () => ({ status: "ready", agents: [], running: [] }), + relay: () => undefined, subscribe, }, effect: vi.fn(), diff --git a/src/bundled/codex/index.ts b/src/bundled/codex/index.ts index 1cdb8405d..fb4fa90aa 100644 --- a/src/bundled/codex/index.ts +++ b/src/bundled/codex/index.ts @@ -3,7 +3,7 @@ import type { PluginModule } from "../../plugins/api"; import { defaults, config } from "./config"; import { CodexRuntime } from "./runtime"; import { createTabs } from "./tabs"; -export const inject = ["react", "agents2", "host", "relay", "communityReader"]; +export const inject = ["react", "agents2", "host"]; export const apply: PluginModule["apply"] = (ctx) => { // Only the desktop app can start `codex`, and file uploads run `base64`, // which stock Windows lacks; no unusable type elsewhere. @@ -14,36 +14,19 @@ export const apply: PluginModule["apply"] = (ctx) => { (async () => { throw new Error("Codex agents run only in the desktop app"); }); - const runtime = new CodexRuntime(start, ctx.relay); + const runtime = new CodexRuntime(start, ctx.agents2.relay); const typeKey = `${ctx.pluginOwner?.id ?? "buzz.codex"}/codex`; - let selection = ""; + // Agents that stop running stop their servers; switching communities or a + // reconnect does not. const sync = () => { - const community = ctx.communityReader.snapshot(); - const selected = JSON.stringify([community.selected, community.viewer]); - if (selected !== selection) { - runtime.dispose(); - selection = selected; - } const snapshot = ctx.agents2.snapshot(); - const relay = ctx.relay.snapshot(); - if (relay.status === "disconnected") { - runtime.sync([], ""); - return; - } - if (snapshot.status !== "ready" || relay.status !== "ready") return; - runtime.sync( - snapshot.agents.filter((agent) => agent.type === typeKey), - relay.scope ?? "", - ); + if (snapshot.status !== "ready") return; + runtime.sync(snapshot.running.filter((agent) => agent.type === typeKey)); }; const offAgents = ctx.agents2.subscribe(sync); - const offRelay = ctx.relay.subscribe(sync); - const offCommunity = ctx.communityReader.subscribe(sync); sync(); ctx.effect(() => () => { offAgents(); - offRelay(); - offCommunity(); runtime.dispose(); }); const { CodexTab, SettingsTab } = createTabs(ctx.react, start, runtime); diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts index 7931b6875..058db43cb 100644 --- a/src/bundled/codex/runtime.test.ts +++ b/src/bundled/codex/runtime.test.ts @@ -186,7 +186,7 @@ function fixture( storage.set(key, value); }, } as Storage; - const runtime = new CodexRuntime(spawn, relay, store); + const runtime = new CodexRuntime(spawn, () => relay, store); runtimes.push(runtime); const events: EventData[] = []; const query = vi.fn(async (filters: readonly Record[]) => @@ -226,7 +226,7 @@ function fixture( upload, remember, }; - runtime.sync([{ pubkey } as Agent], scope); + runtime.sync([{ pubkey } as Agent]); let id = 0; const delivery = (content: string, threadRoot = root): Delivery => { const result: Delivery = { @@ -258,7 +258,8 @@ function fixture( events.push(result.trigger.event as EventData); return result; }; - const selection = { selected: "community", viewer: owner }; + /** Agents2's running agents, whichever community is selected. */ + const running = [{ ...agent, type: "buzz.codex/codex" }]; const listeners: (() => void)[] = []; let runPlugin: (delivery: Delivery) => Promise = (delivery) => runtime.run(delivery); @@ -269,25 +270,9 @@ function fixture( apply({ react: React, host: { spawn }, - communityReader: { - snapshot: () => selection, - subscribe: (listener: () => void) => { - listeners.push(listener); - return () => {}; - }, - }, - relay: { - ...relay, - subscribe: (listener: () => void) => { - listeners.push(listener); - return () => {}; - }, - }, agents2: { - snapshot: () => ({ - status: snapshot.status === "ready" ? "ready" : "loading", - agents: [{ ...agent, type: "buzz.codex/codex" }], - }), + snapshot: () => ({ status: "ready", agents: [], running }), + relay: () => relay, subscribe: (listener: () => void) => { listeners.push(listener); return () => {}; @@ -370,7 +355,7 @@ function fixture( memoryView, remember, upload, - selection, + running, runPlugin: (delivery: Delivery) => runPlugin(delivery), notify: () => listeners.forEach((listener) => { @@ -655,11 +640,11 @@ it("publishes unexpected exit and empty-completion failures instead of silently content: expect.stringContaining("Codex exited"), }); }); -it("ends the server on community change and suppresses a late final answer", async () => { +it("ends the server when its agent stops running and suppresses a late final answer", async () => { const f = fixture(); await f.runtime.run(f.delivery("work")); await vi.waitFor(() => expect(f.starts()).toHaveLength(1)); - f.runtime.sync([], "other-community"); + f.runtime.sync([]); f.complete(); await f.exits.promise; expect(f.process.end).toHaveBeenCalled(); @@ -880,7 +865,7 @@ it("delivers a plain follow-up after startup without waiting for the running tur await vi.waitFor(() => expect(f.publish).toHaveBeenCalledTimes(1)); }); -it("keeps plugin work alive through relay reconnect and disposes on real community change", async () => { +it("keeps plugin work alive through relay reconnect and ends it when its agent stops running", async () => { const f = fixture([], undefined, true); try { await f.runPlugin(f.delivery("work")); @@ -896,9 +881,7 @@ it("keeps plugin work alive through relay reconnect and disposes on real communi expect(f.process.end).not.toHaveBeenCalled(); await f.runPlugin(f.delivery("more work")); await vi.waitFor(() => expect(f.starts()).toHaveLength(2)); - f.selection.selected = "other-community"; - f.snapshot.status = "connecting"; - f.snapshot.scope = undefined; + f.running.length = 0; f.notify(); f.complete(); await f.exits.promise; diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index 5804a7177..27c29768f 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -30,6 +30,8 @@ import { } from "./rpc"; type Request = { + /** The agent's community and owner, as `origin:viewer`. */ + scope: string; event: EventData; agent: AgentHandle; settings: Config; @@ -79,10 +81,10 @@ export class CodexRuntime { private entries = new Map(); private views = new Map(); private listeners = new Set<() => void>(); - private scope = ""; constructor( private readonly spawn: Spawn, - 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 = localStorage, ) {} subscribe = (listener: () => void) => { @@ -101,11 +103,7 @@ export class CodexRuntime { ]); for (const listener of this.listeners) listener(); } - sync(agents: readonly Agent[], scope: string) { - if (scope !== this.scope) { - this.dispose(); - this.scope = scope; - } + sync(agents: readonly Agent[]) { const present = new Set(agents.map((agent) => agent.pubkey)); for (const [pubkey, entry] of this.entries) if (!present.has(pubkey)) { @@ -131,7 +129,7 @@ export class CodexRuntime { let saved: Record = {}; try { const stored: unknown = JSON.parse( - this.storage.getItem(this.storageKey(pubkey)) ?? "{}", + this.storage.getItem(this.storageKey(request)) ?? "{}", ); if (stored && typeof stored === "object" && !Array.isArray(stored)) saved = Object.fromEntries( @@ -147,7 +145,7 @@ export class CodexRuntime { } const rpc = new AppServer((call) => this.tool(entry, call)); const entry: Entry = { - storageKey: this.storageKey(pubkey), + storageKey: this.storageKey(request), abort: new AbortController(), rpc, saved, @@ -170,9 +168,9 @@ export class CodexRuntime { this.entries.set(pubkey, entry); return entry; } - private storageKey(pubkey: string) { + private storageKey(request: Request) { // Resume restores the persisted tool schema; pre-tools bindings start fresh. - return `buzz.codex.sessions.v3:${this.scope}:${pubkey}`; + return `buzz.codex.sessions.v3:${request.scope}:${request.agent.pubkey}`; } private save(request: Request, entry: Entry, key: string, saved: Saved) { entry.saved[key] = saved; @@ -197,8 +195,8 @@ export class CodexRuntime { "Codex timers are not supported yet; use mentions or event watches.", ); if (trigger.event.pubkey !== agent.owner) return; - const snapshot = this.relay.snapshot(); - if (snapshot.status !== "ready") + const snapshot = this.relay(agent.pubkey)?.snapshot(); + if (snapshot?.status !== "ready") throw new Error("Connect to the agent's community first"); const channelId = delivery.channelId; if (!channelId) throw new Error("The event has no unambiguous channel"); @@ -210,6 +208,7 @@ export class CodexRuntime { channel?.channelType === "dm" ? undefined : rootOf(trigger.event); const root = settings.scope === "channel" ? undefined : toolRoot; const request: Request = { + scope: snapshot.scope ?? "", event: trigger.event, agent, settings, @@ -308,8 +307,8 @@ export class CodexRuntime { } private async history(request: Request, signal: AbortSignal) { signal.throwIfAborted(); - const snapshot = this.relay.snapshot(); - if (snapshot.status !== "ready" || snapshot.scope !== this.scope) + const snapshot = this.relay(request.agent.pubkey)?.snapshot(); + if (snapshot?.status !== "ready" || snapshot.scope !== request.scope) throw new Error("The community changed"); return conversationHistory(request.event, request.conversation, (filters) => snapshot.session.read(filters, { signal }), @@ -362,8 +361,8 @@ export class CodexRuntime { check, signal: active.signal, memories: async () => { - const snapshot = this.relay.snapshot(); - if (snapshot.status !== "ready" || snapshot.scope !== this.scope) + const snapshot = this.relay(request.agent.pubkey)?.snapshot(); + if (snapshot?.status !== "ready" || snapshot.scope !== request.scope) throw new Error("Buzz is not connected to this community"); const view = snapshot.session.agentMemories.open(request.agent.pubkey); const abort = () => view.dispose(); diff --git a/src/bundled/codex/tabs.test.tsx b/src/bundled/codex/tabs.test.tsx index 4a7aacd24..0034f5c92 100644 --- a/src/bundled/codex/tabs.test.tsx +++ b/src/bundled/codex/tabs.test.tsx @@ -11,7 +11,6 @@ import { import { afterEach, expect, it, vi } from "vitest"; import type { AgentViewProps } from "../../features/agents2/service"; import type { HostProcessOptions } from "../../features/host/service"; -import type { RelayData } from "../../features/relay/service"; import { defaults, type Config } from "./config"; import { createTabs } from "./tabs"; import { CodexRuntime } from "./runtime"; @@ -72,7 +71,7 @@ function fixture() { processes.push(process); return process; }); - const runtime = new CodexRuntime(spawn, {} as RelayData, {} as Storage); + const runtime = new CodexRuntime(spawn, () => undefined, {} as Storage); const tabs = createTabs(React, spawn, runtime); const props = (settings = defaults, save = vi.fn(async () => {})) => ({ diff --git a/src/features/agents2/service.test.ts b/src/features/agents2/service.test.ts index 19fe94cb7..7cd0e9416 100644 --- a/src/features/agents2/service.test.ts +++ b/src/features/agents2/service.test.ts @@ -5,7 +5,12 @@ import type { LiveBatch, LiveListener } from "../relay/incoming"; import type { RelayData, RelaySnapshot } from "../relay/service"; import type { RelaySession } from "../relay/session"; import type { AgentIdentity, AgentsNative } from "./native"; -import { Agents2Service, type AgentType, type Delivery } from "./service"; +import { + Agents2Service, + type AgentCommunities, + type AgentType, + type Delivery, +} from "./service"; import { memoryStorage } from "./test-fakes"; // Cleanup's channel step has its own coverage (relay-removal); here only its @@ -33,7 +38,7 @@ const event = (id: string, patch: Partial = {}) => ...patch, }) as unknown as RelayEvent; -function fakeRelay() { +function fakeRelay(origin = "https://relay.example.test") { const live = new Set(); const changes = new Set<() => void>(); const archived = new Set(); @@ -76,8 +81,8 @@ function fakeRelay() { status: "ready", generation: 1, viewer, - origin: "https://relay.example.test", - scope: `https://relay.example.test:${viewer}`, + origin, + scope: `${origin}:${viewer}`, session, }; const connected_ = snapshot; @@ -96,6 +101,11 @@ function fakeRelay() { : { status: "disconnected", generation: 2, session }; for (const listener of changes) listener(); }, + /** Replaces the session, as after the transport was lost. */ + reconnecting() { + snapshot = { status: "connecting", generation: 3, session }; + for (const listener of changes) listener(); + }, /** Drops and restores the socket inside the same session, as live.ts does. */ recover() { for (const next of ["retrying", "connected"]) { @@ -107,6 +117,39 @@ function fakeRelay() { for (const listener of live) listener(batch); }, archives, + origin, + }; +} + +/** The viewer's joined communities, one selected; `open` connects any of them. */ +function fakeCommunities(...joined: ReturnType[]) { + let relays = joined; + const changes = new Set<() => void>(); + let selected: string | null = relays[0]?.origin ?? null; + return { + communities: { + snapshot: () => ({ + viewer, + selected, + memberships: relays.map(({ origin }) => ({ id: origin, name: origin })), + }), + subscribe: (listener: () => void) => { + changes.add(listener); + return () => changes.delete(listener); + }, + open: vi.fn( + (id: string) => relays.find((fake) => fake.origin === id)?.relay, + ), + } as unknown as AgentCommunities, + select(next: string | null) { + selected = next; + for (const listener of changes) listener(); + }, + leave(origin: string) { + relays = relays.filter((fake) => fake.origin !== origin); + if (selected === origin) selected = null; + for (const listener of changes) listener(); + }, }; } @@ -171,8 +214,10 @@ async function setup({ subscribe: () => () => {}, }); const fake = fakeRelay(); + const second = fakeRelay("https://other.example.test"); + const client = fakeCommunities(fake, second); const native = fakeNative(identities); - const service = new Agents2Service(ctx, fake.relay, native, storage); + const service = new Agents2Service(ctx, client.communities, native, storage); const plugin = ctx.extend({ pluginOwner: Object.freeze({ id: "example", revision: "one" }), }); @@ -206,8 +251,13 @@ async function setup({ run, emit: fake.emit, connect: fake.connect, + reconnecting: fake.reconnecting, recover: fake.recover, archives: fake.archives, + second, + select: client.select, + leave: client.leave, + communities: client.communities, ctx, }; } @@ -424,6 +474,64 @@ it("claims an agent as it comes into view, and releases it as it leaves, without await vi.waitFor(() => expect(native.claim).toHaveBeenCalledWith(bot)); }); +it("keeps running an agent while another community is selected, and gives it only its community's events", async () => { + const { service, run, emit, second, select } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + select(second.origin); + expect(service.find(bot)).toBeUndefined(); + expect(service.snapshot().running.map((agent) => agent.pubkey)).toEqual([ + bot, + ]); + expect(service.relay(bot)?.snapshot().origin).toBe( + "https://relay.example.test", + ); + second.emit({ events: [event("2", { tags: [["p", bot]] })] }); + const mention = event("1", { tags: [["p", bot]] }); + emit({ events: [mention] }); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1)); + expect(run.mock.calls[0]?.[0].trigger).toEqual({ + type: "mention", + event: mention, + }); + await settle(); + expect(run).toHaveBeenCalledTimes(1); +}); + +it("connects an agent's community at start without selecting it", async () => { + const { service, run, second, communities } = await setup({ + identities: [ + { + pubkey: bot, + relay: "wss://other.example.test", + owner: viewer, + type: "example/echo", + name: "Echo", + deleted: false, + }, + ], + }); + expect(communities.open).toHaveBeenCalledWith("https://other.example.test"); + expect(service.find(bot)).toBeUndefined(); + second.emit({ events: [event("1", { tags: [["p", bot]] })] }); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1)); +}); + +it("keeps an agent running while its community reconnects, and stops it on disconnect or leave", async () => { + const { service, connect, reconnecting, leave } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + const running = () => service.snapshot().running.map((agent) => agent.pubkey); + reconnecting(); + expect(service.find(bot)).toBeUndefined(); + expect(running()).toEqual([bot]); + connect(false); + expect(running()).toEqual([]); + connect(true); + expect(running()).toEqual([bot]); + leave("https://relay.example.test"); + expect(running()).toEqual([]); + expect(service.relay(bot)).toBeUndefined(); +}); + it("is not woken by reactions, deletions or DMs", async () => { const { service, run, emit } = await setup(); await service.create({ type: "example/echo", name: "Echo" }); @@ -639,7 +747,12 @@ it("keeps an agent created while identities are still loading", async () => { native.list.mockImplementationOnce( () => new Promise((resolve) => (release = resolve)), ); - const service = new Agents2Service(ctx, fakeRelay().relay, native, storage); + const service = new Agents2Service( + ctx, + fakeCommunities(fakeRelay()).communities, + native, + storage, + ); ctx .extend({ pluginOwner: Object.freeze({ id: "example", revision: "one" }) }) .agents2.register({ diff --git a/src/features/agents2/service.ts b/src/features/agents2/service.ts index dfe86bfa9..cbd1191b7 100644 --- a/src/features/agents2/service.ts +++ b/src/features/agents2/service.ts @@ -2,8 +2,8 @@ // 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 // app owns and edits, and a config blob the type owns. The app delivers matching live events to -// `run` from the stream the owner already receives, so no plugin opens a socket -// or a REQ. +// `run` from the stream the owner already receives in the agent's community, +// selected or not, so no plugin opens a socket or a REQ. import { Service, type Context } from "@deepseek-ai/cordis"; import type { ComponentType } from "react"; import { @@ -11,7 +11,8 @@ import { type Contribution, } from "../../plugins/contributions"; import { removeAgentFromChannels } from "../agents/relay-removal"; -import { relayOrigin } from "../communities/destination"; +import { communityDestination, relayOrigin } from "../communities/destination"; +import type { Communities } from "../communities/service"; import type { RelayEvent } from "../relay/events"; import type { LiveBatch } from "../relay/incoming"; import { relayPartition } from "../relay/partition"; @@ -48,7 +49,7 @@ import { type SkippedObject, } from "./store"; -/** One agent in the selected community. */ +/** One of the viewer's agents, in the community at `relay`. */ export type Agent = Readonly<{ pubkey: string; name: string; @@ -154,7 +155,11 @@ export type AgentType = { export type RegisteredAgentType = Contribution; export type AgentsSnapshot = Readonly<{ status: "unavailable" | "loading" | "ready" | "error"; + /** The selected community's agents, while it is connected. */ agents: readonly Agent[]; + /** Every agent kept running here, whichever community is selected: the + * viewer's agents in each joined community not disconnected. */ + running: readonly Agent[]; error?: string; }>; export type Agents2 = { @@ -164,6 +169,9 @@ export type Agents2 = { subscribe(listener: () => void): () => void; /** The agent of this pubkey in the selected community, if it is an Agents2 agent. */ find(pubkey: string): Agent | undefined; + /** The owner's connection to a running agent's community, which need not be + * the selected one: read the agent's conversations through this. */ + relay(pubkey: string): RelayData | undefined; create(input: Readonly<{ type: string; name: string }>): Promise; save(pubkey: string, change: AgentChange): Promise; /** Deletes its key at once. Leaving its channels and archiving it follow in @@ -207,6 +215,12 @@ const bounded = (set: Set, id: string) => { if (set.size > SEEN_LIMIT) set.delete(set.values().next().value as string); }; +/** What Agents2 needs of the communities: the viewer, the selected one, and + * a connection to any joined one without selecting it. */ +export type AgentCommunities = Pick< + Communities, + "snapshot" | "subscribe" | "open" +>; type Binding = { scope: string; origin: string; @@ -214,6 +228,21 @@ type Binding = { session: RelaySession; /** Aborts when this binding is replaced, so no work outlives its session. */ controller: AbortController; + stopLive: () => void; +}; +/** A community the viewer has an agent in, and its session while live. */ +type Bound = { + relay: RelayData; + stop: () => void; + binding?: Binding | undefined; +}; +/** The origin a relay URL or community ID names, if it is a valid one. */ +const originOf = (value: string) => { + try { + return communityDestination(value).url; + } catch { + return undefined; + } }; type Job = { trigger: Trigger; channelId?: string }; type WatchTrigger = Extract; @@ -253,8 +282,10 @@ export class Agents2Service extends Service implements Agents2 { private identities: readonly AgentIdentity[] = []; private records: Record; private state: AgentsSnapshot; - private binding: Binding | undefined; - private stopLive: (() => void) | undefined; + /** By origin, so each agent listens in its own community, selected or not. */ + private bound = new Map(); + /** Agents whose community was live at the last update. */ + private connected = new Set(); private runners = new Map(); /** Counts list requests, so only the latest one is applied. */ private listed = 0; @@ -264,7 +295,7 @@ export class Agents2Service extends Service implements Agents2 { constructor( ctx: Context, - private readonly relay: RelayData, + private readonly communities: AgentCommunities, private readonly native: AgentsNative | undefined = nativeAgents(), private readonly storage: Storage = globalThis.localStorage, ) { @@ -274,6 +305,7 @@ export class Agents2Service extends Service implements Agents2 { this.state = Object.freeze({ status: native ? "loading" : "unavailable", agents: [], + running: [], }); ctx.effect(() => { const stops = [ @@ -281,9 +313,12 @@ export class Agents2Service extends Service implements Agents2 { this.update(); this.notify(); }), - relay.subscribe(() => this.bind()), + communities.subscribe(() => { + this.watch(); + this.update(); + }), ]; - this.bind(); + this.watch(); void this.refresh(); // Wakes each runner, which starts any timer that is due. const tick = setInterval(() => { @@ -292,16 +327,14 @@ export class Agents2Service extends Service implements Agents2 { return () => { clearInterval(tick); for (const stop of stops) stop(); - this.stopLive?.(); - this.stopLive = undefined; - this.binding?.controller.abort(); - this.binding = undefined; + for (const origin of [...this.bound.keys()]) this.unbind(origin); for (const runner of this.runners.values()) this.retire(runner); this.runners.clear(); // Clear what is shown without reconciling, which would make new runners. this.state = Object.freeze({ ...this.state, agents: Object.freeze([]), + running: Object.freeze([]), }); this.notify(); }; @@ -338,10 +371,17 @@ export class Agents2Service extends Service implements Agents2 { }; find = (pubkey: string) => this.state.agents.find((agent) => agent.pubkey === pubkey); + relay = (pubkey: string) => { + const origin = originOf(this.agent(pubkey)?.relay ?? ""); + return origin ? this.bound.get(origin)?.relay : undefined; + }; async create({ type, name }: Readonly<{ type: string; name: string }>) { const native = this.native; - const binding = this.binding; + const selected = this.communities.snapshot().selected; + const binding = selected + ? this.bound.get(originOf(selected) ?? "")?.binding + : undefined; const kind = this.types().find((entry) => entry.key === type); if (!native) throw new Error("Agents run only in the desktop app"); if (!binding) throw new Error("Connect to a community first"); @@ -401,7 +441,7 @@ export class Agents2Service extends Service implements Agents2 { async remove(pubkey: string) { if (!this.native) throw new Error("Agents run only in the desktop app"); await this.native.remove(pubkey); - // Drops its settings, and starts its cleanup if its community is open. + // Drops its settings, and starts its cleanup if its community is connected. await this.refresh(); } @@ -442,16 +482,33 @@ export class Agents2Service extends Service implements Agents2 { config: record.config, }); } - private inScope(identity: AgentIdentity, binding: Binding) { - try { - return ( - identity.owner === binding.viewer && - relayPartition(relayOrigin(identity.relay), binding.viewer) === - binding.scope - ); - } catch { - return false; - } + /** The live binding of the agent's community, if it has one now. */ + private bindingOf({ relay, owner }: Pick) { + const origin = originOf(relay); + const binding = origin ? this.bound.get(origin)?.binding : undefined; + return binding?.scope === relayPartition(origin ?? "", owner) + ? binding + : undefined; + } + /** Whether the agent stays running: it is the viewer's, and its community is + * joined and not disconnected. A reconnect does not stop it. */ + private alive({ relay, owner }: AgentIdentity) { + const origin = originOf(relay); + const entry = origin ? this.bound.get(origin) : undefined; + return ( + owner === this.communities.snapshot().viewer && + !!entry && + entry.relay.snapshot().status !== "disconnected" + ); + } + /** A running agent, in any community. */ + private agent(pubkey: string) { + return this.state.running.find((agent) => agent.pubkey === pubkey); + } + /** The agent, while its community's session is live to run it in. */ + private ready(pubkey: string) { + const agent = this.agent(pubkey); + return agent && this.bindingOf(agent) ? agent : undefined; } /** Reads the agent list from native. Every change is made there first and @@ -485,17 +542,17 @@ export class Agents2Service extends Service implements Agents2 { error: message(error), }); } + this.watch(); this.update(); this.notify(); this.sync(); } /** Brings the relay in line with the saved identities: publishes each name - * its community lacks, and finishes Delete for the open community. Each step + * its community lacks, and finishes Delete where connected. Each step * is safe to repeat; what fails is tried again on the next refresh or connect. */ private sync() { const native = this.native; - const binding = this.binding; for (const identity of this.identities) if (!identity.deleted) native @@ -506,8 +563,10 @@ export class Agents2Service extends Service implements Agents2 { error, ), ); - else if (binding && this.inScope(identity, binding)) - void this.cleanup(identity.pubkey, binding); + else { + const binding = this.bindingOf(identity); + if (binding) void this.cleanup(identity.pubkey, binding); + } } /** As harness Delete does: leave every channel, then archive the identity so * it drops out of member lists and mention suggestions. The owner signs both, @@ -554,8 +613,44 @@ export class Agents2Service extends Service implements Agents2 { } } - private bind() { - const snapshot = this.relay.snapshot(); + /** Connects the selected community, where agents are made, and each one + * the viewer has an agent in, deleted ones included so their cleanup can + * finish; lets go of the rest. */ + private watch() { + const { viewer, selected } = this.communities.snapshot(); + const origins = new Set(); + const shown = viewer && originOf(selected ?? ""); + if (shown) origins.add(shown); + for (const identity of this.identities) { + const origin = identity.owner === viewer && originOf(identity.relay); + if (origin) origins.add(origin); + } + // Left, or left and joined again with a new session. + for (const [origin, entry] of this.bound) + if (!origins.has(origin) || this.communities.open(origin) !== entry.relay) + this.unbind(origin); + for (const origin of origins) { + if (this.bound.has(origin)) continue; + const relay = this.communities.open(origin); + if (!relay) continue; + this.bound.set(origin, { + relay, + stop: relay.subscribe(() => this.bind(origin)), + }); + this.bind(origin); + } + } + private unbind(origin: string) { + const entry = this.bound.get(origin); + this.bound.delete(origin); + entry?.stop(); + entry?.binding?.stopLive(); + entry?.binding?.controller.abort(); + } + private bind(origin: string) { + const entry = this.bound.get(origin); + if (!entry) return; + const snapshot = entry.relay.snapshot(); const live = snapshot.status === "ready" && !snapshot.cached && @@ -570,24 +665,29 @@ export class Agents2Service extends Service implements Agents2 { controller: new AbortController(), } : undefined; - if ( - this.binding?.session === live?.session && - this.binding?.scope === live?.scope - ) - return; - this.stopLive?.(); - this.binding?.controller.abort(); - this.stopLive = live && this.listen(live.session); - this.binding = live; + const changed = + entry.binding?.session !== live?.session || + entry.binding?.scope !== live?.scope; + if (changed) { + entry.binding?.stopLive(); + entry.binding?.controller.abort(); + entry.binding = live && { + ...live, + stopLive: this.listen(live.session, origin), + }; + } + // Disconnecting stops its agents even when no binding changed. this.update(); - if (live) this.sync(); + if (changed && live) this.sync(); } /** Delivers the session's live events, and retries what failed whenever its * connection recovers, which happens within the same session. */ - private listen(session: RelaySession) { + private listen(session: RelaySession, origin: string) { let connected = session.live.snapshot().status === "connected"; - const stopEvents = session.subscribeLive((batch) => this.dispatch(batch)); + const stopEvents = session.subscribeLive((batch) => + this.dispatch(batch, origin), + ); const stopStatus = session.live.subscribe(() => { const now = session.live.snapshot().status === "connected"; if (now && !connected) this.sync(); @@ -599,22 +699,20 @@ export class Agents2Service extends Service implements Agents2 { }; } - // Joins identities, records and the connection into the visible agents, and - // keeps one runner per saved agent. Runners outlive edits and reconnects; one is - // retired only when its agent is deleted, and its run is aborted when the - // agent's type is replaced. + // Joins identities, records and the connections into the running and visible + // agents, and keeps one runner per saved agent. Runners outlive edits and + // reconnects; one is retired only when its agent is deleted, and its run is + // aborted when the agent's type is replaced. private update() { - const binding = this.binding; - const wasShown = new Set(this.state.agents.map((agent) => agent.pubkey)); const identities = this.identities.filter((identity) => !identity.deleted); - const agents: Agent[] = []; + const running: Agent[] = []; for (const identity of identities) { - if (!binding || !this.inScope(identity, binding)) continue; + if (!this.alive(identity)) continue; const record = this.recordOf(identity); // Reuse the frozen agent while nothing visible changed, so subscribers see // a stable snapshot. - const prior = this.find(identity.pubkey); - agents.push( + const prior = this.agent(identity.pubkey); + running.push( prior && prior.name === identity.name && prior.attention === record.attention && @@ -625,6 +723,10 @@ export class Agents2Service extends Service implements Agents2 { : this.view(identity, record), ); } + const selected = originOf(this.communities.snapshot().selected ?? ""); + const agents = running.filter( + (agent) => originOf(agent.relay) === selected && this.bindingOf(agent), + ); const types = new Map(this.types().map((type) => [type.key, type])); for (const [pubkey, runner] of this.runners) if (!identities.some((identity) => identity.pubkey === pubkey)) { @@ -643,26 +745,33 @@ export class Agents2Service extends Service implements Agents2 { void this.drain(runner); } } - const same = - agents.length === this.state.agents.length && - agents.every((agent, index) => agent === this.state.agents[index]); - if (!same) { + const same = (next: readonly Agent[], prior: readonly Agent[]) => + next.length === prior.length && + next.every((agent, index) => agent === prior[index]); + if ( + !same(agents, this.state.agents) || + !same(running, this.state.running) + ) { this.state = Object.freeze({ ...this.state, agents: Object.freeze(agents), + running: Object.freeze(running), }); this.notify(); } - // Resume runners whose queue paused while their agent was out of view, and - // claim or release each agent as it comes into or leaves view, so a copy of - // the app takes over without waiting for an event or tick. - const shown = new Set(agents.map((agent) => agent.pubkey)); + // Resume runners whose queue paused while their community was not live, + // and claim or release each agent as its community goes live or not, so a + // copy of the app takes over without waiting for an event or tick. + const ready = new Set( + running.filter((agent) => this.bindingOf(agent)).map((a) => a.pubkey), + ); for (const runner of this.runners.values()) if ( runner.queue.length || - shown.has(runner.pubkey) !== wasShown.has(runner.pubkey) + ready.has(runner.pubkey) !== this.connected.has(runner.pubkey) ) void this.drain(runner); + this.connected = ready; } private notify() { for (const listener of this.listeners) listener(); @@ -711,10 +820,12 @@ export class Agents2Service extends Service implements Agents2 { return watches; } - private dispatch(batch: LiveBatch) { + /** Gives a community's live events to the agents in it. */ + private dispatch(batch: LiveBatch, origin: string) { for (const runner of this.runners.values()) { - const agent = this.find(runner.pubkey); - if (!agent || !runner.type?.run) continue; + const agent = this.ready(runner.pubkey); + if (!agent || originOf(agent.relay) !== origin || !runner.type?.run) + continue; for (const event of batch.events) { if (runner.seen.has(event.id)) continue; bounded(runner.seen, event.id); @@ -818,7 +929,7 @@ export class Agents2Service extends Service implements Agents2 { runner.admitted = 0; } if (runner.queue.length >= QUEUE_LIMIT || runner.admitted >= RATE_LIMIT) { - console.warn(`Agent ${this.find(runner.pubkey)?.name} dropped an event`); + console.warn(`Agent ${this.agent(runner.pubkey)?.name} dropped an event`); return; } runner.admitted++; @@ -827,16 +938,15 @@ export class Agents2Service extends Service implements Agents2 { } // One run at a time per agent: events in arrival order, then a due timer. - // Each job runs the agent as it is when the job starts. While the agent is out - // of view (disconnected, another community) the queue pauses and update() - // resumes it; a type that cannot run drops what is queued. A run that ignores + // Each job runs the agent as it is when the job starts. While its community's + // session is not live the queue pauses and update() resumes it; a type that cannot run drops what is queued. A run that ignores // its deadline stops holding the queue when the deadline passes; it is not // otherwise fenced. private async drain(runner: Runner) { if (runner.running) return; runner.running = true; while (this.runners.get(runner.pubkey) === runner) { - const agent = this.find(runner.pubkey); + const agent = this.ready(runner.pubkey); const type = runner.type; const run = type?.run; // An agent this copy cannot run is left to another copy of the app. @@ -844,7 +954,7 @@ export class Agents2Service extends Service implements Agents2 { if (agent) runner.queue.length = 0; await this.release(runner); // It may have come back into view, or gained its type, meanwhile. - if (this.find(runner.pubkey) !== agent || runner.type !== type) + if (this.ready(runner.pubkey) !== agent || runner.type !== type) continue; break; } @@ -856,7 +966,7 @@ export class Agents2Service extends Service implements Agents2 { break; } // The agent or its type may have changed meanwhile. - if (this.find(runner.pubkey) !== agent || runner.type !== type) continue; + if (this.ready(runner.pubkey) !== agent || runner.type !== type) continue; const job = runner.queue.shift() ?? this.dueTimer(agent); if (!job) break; const lifetime = runner.controller.signal; @@ -912,7 +1022,7 @@ export class Agents2Service extends Service implements Agents2 { runner.mayHold = true; // Answering twice beats not answering at all. console.warn( - `Agent ${this.find(runner.pubkey)?.name} was not claimed`, + `Agent ${this.agent(runner.pubkey)?.name} was not claimed`, error, ); return true; diff --git a/src/features/communities/service.ts b/src/features/communities/service.ts index 7345bad55..c15e88918 100644 --- a/src/features/communities/service.ts +++ b/src/features/communities/service.ts @@ -291,6 +291,14 @@ export function createCommunities( if (id) acquire(id); update({ selected: id }); }, + /** A joined community's session, connected without selecting it, for + * background work such as agents; it lives until the community is left. */ + open(id: string): RelayData | undefined { + id = communityDestination(id).id; + return state.memberships.some((m) => m.id === id) + ? acquire(id) + : undefined; + }, saveProfile(profile: PersonalProfile) { update({ profile }); }, From 9011eb8ee64dcc9b1a6faca3e9113015787932c4 Mon Sep 17 00:00:00 2001 From: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz> Date: Fri, 9 Oct 2026 17:44:05 -0700 Subject: [PATCH 02/39] fix(agents2): retry a failed unselected community connection A first connect that fails leaves the session in error, and only the selected community has a Retry the viewer can press. Agents2 now tries an unselected community's failed connection again every 30 seconds, so its agents recover without the viewer opening it. Also covers Communities.open directly. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Honey <8e307ae0076a4dab6b94b036ea3edc7e08f823a625269c1e6919e881a048b4d2@buzz.block.builderlab.xyz> --- docs/communities.md | 7 ++++--- src/features/agents2/service.test.ts | 24 ++++++++++++++++++++++++ src/features/agents2/service.ts | 20 ++++++++++++++++++++ src/features/communities/service.test.ts | 22 ++++++++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/docs/communities.md b/docs/communities.md index dd1ee1106..c9e8713bf 100644 --- a/docs/communities.md +++ b/docs/communities.md @@ -114,9 +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, plus each joined -community where the viewer has an Agents2 agent, which `open` connects without -selecting so its agents keep running. 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 diff --git a/src/features/agents2/service.test.ts b/src/features/agents2/service.test.ts index 7cd0e9416..593cb46c4 100644 --- a/src/features/agents2/service.test.ts +++ b/src/features/agents2/service.test.ts @@ -93,6 +93,7 @@ function fakeRelay(origin = "https://relay.example.test") { changes.add(listener); return () => changes.delete(listener); }, + retry: vi.fn(), } as unknown as RelayData, /** Drops the connection, or restores it with a new session. */ connect(connected: boolean) { @@ -101,6 +102,11 @@ function fakeRelay(origin = "https://relay.example.test") { : { status: "disconnected", generation: 2, session }; for (const listener of changes) listener(); }, + /** Fails the connection, as a first connect that timed out does. */ + fail() { + snapshot = { status: "error", generation: 4, session, error: "down" }; + for (const listener of changes) listener(); + }, /** Replaces the session, as after the transport was lost. */ reconnecting() { snapshot = { status: "connecting", generation: 3, session }; @@ -251,6 +257,8 @@ async function setup({ run, emit: fake.emit, connect: fake.connect, + fail: fake.fail, + relay: fake.relay, reconnecting: fake.reconnecting, recover: fake.recover, archives: fake.archives, @@ -532,6 +540,22 @@ it("keeps an agent running while its community reconnects, and stops it on disco expect(service.relay(bot)).toBeUndefined(); }); +it("tries a failed unselected community again every so often, and leaves the selected one's Retry to the viewer", async () => { + vi.useFakeTimers(); + const { service, relay, fail, second, select } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + fail(); + await vi.advanceTimersByTimeAsync(10_000); + expect(relay.retry).not.toHaveBeenCalled(); + select(second.origin); + await vi.advanceTimersByTimeAsync(5_000); + expect(relay.retry).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(20_000); + expect(relay.retry).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(10_000); + expect(relay.retry).toHaveBeenCalledTimes(2); +}); + it("is not woken by reactions, deletions or DMs", async () => { const { service, run, emit } = await setup(); await service.create({ type: "example/echo", name: "Echo" }); diff --git a/src/features/agents2/service.ts b/src/features/agents2/service.ts index cbd1191b7..d0a16c10d 100644 --- a/src/features/agents2/service.ts +++ b/src/features/agents2/service.ts @@ -194,6 +194,8 @@ const RATE_LIMIT = 60; const RATE_WINDOW_MS = 60_000; const TIMEOUT_LIMIT_MS = 30 * 60_000; const TIMER_TICK_MS = 5_000; +/** How often a failed connection to an unselected community is tried again. */ +const RETRY_MS = 30_000; const EMPTY = Object.freeze({}); const blank = (pubkey: string): AgentRecord => ({ pubkey, @@ -235,6 +237,8 @@ type Bound = { relay: RelayData; stop: () => void; binding?: Binding | undefined; + /** When its failed connection was last tried again. */ + retried?: number; }; /** The origin a relay URL or community ID names, if it is a valid one. */ const originOf = (value: string) => { @@ -323,6 +327,7 @@ export class Agents2Service extends Service implements Agents2 { // Wakes each runner, which starts any timer that is due. const tick = setInterval(() => { for (const runner of this.runners.values()) void this.drain(runner); + this.reconnect(); }, TIMER_TICK_MS); return () => { clearInterval(tick); @@ -640,6 +645,21 @@ export class Agents2Service extends Service implements Agents2 { this.bind(origin); } } + /** Tries each unselected community whose connection failed again, since + * nobody is there to press its Retry; its agents wait on it. */ + private reconnect() { + const selected = originOf(this.communities.snapshot().selected ?? ""); + const now = Date.now(); + for (const [origin, entry] of this.bound) + if ( + origin !== selected && + entry.relay.snapshot().status === "error" && + now - (entry.retried ?? 0) >= RETRY_MS + ) { + entry.retried = now; + entry.relay.retry(); + } + } private unbind(origin: string) { const entry = this.bound.get(origin); this.bound.delete(origin); diff --git a/src/features/communities/service.test.ts b/src/features/communities/service.test.ts index 4d952c1b5..c0a82f3d7 100644 --- a/src/features/communities/service.test.ts +++ b/src/features/communities/service.test.ts @@ -133,6 +133,28 @@ it("restores only the selected membership on launch", async () => { ]); }); +it("opens a joined community without selecting it, and never one that is not joined", async () => { + const client = setup({ + profile: { name: "Local", picture: "" }, + memberships: [ + { id: "primary", name: "Primary" }, + { id: "secondary", name: "Secondary" }, + ], + selected: "secondary", + }); + await flush(); + await flush(); + const primary = client.open("primary"); + await flush(); + expect(client.snapshot().selected).toBe("secondary"); + expect(primary?.snapshot().status).toBe("ready"); + expect(client.open("primary")).toBe(primary); + expect(client.open("https://unjoined.example.test")).toBeUndefined(); + expect(requests.filter((url) => url.endsWith("/session"))).toEqual([ + "/api/relay/secondary/session", + "/api/relay/primary/session", + ]); +}); it("a failing community does not replace its healthy sibling or the local profile", async () => { const client = setup(); await flush(); From cb99f1c81c5abb9aba19e5c3051f469e5593fb7c Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 10 Oct 2026 16:29:36 -0700 Subject: [PATCH 03/39] perf(claude-code): one shared spare process instead of one per agent Each Claude Code agent kept its own warm `claude` process and up to six processes in all. With agents in many communities kept running, that is one idle ~300 MB process per agent. Starting the process is what a spare saves (time to turn start ~530ms cold vs ~45ms warm, measured against claude 2.1.296); sending `initialize` costs almost nothing. So the spare is now spawned without `initialize`, fixing only its folder and model, and the agent that takes it sends its own prompt and installs its own tool handler. One spare serves every agent; it follows the latest new conversation, and is replaced when no agent runs where it was started. The per-agent process cap is removed. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Bradley Axen --- src/bundled/claude-code/claude.ts | 21 +- src/bundled/claude-code/index.ts | 2 +- src/bundled/claude-code/runtime.test.ts | 27 ++- src/bundled/claude-code/runtime.ts | 35 +++- src/bundled/claude-code/sessions.test.ts | 249 +++++++++++++++++------ src/bundled/claude-code/sessions.ts | 192 +++++++++-------- 6 files changed, 364 insertions(+), 162 deletions(-) diff --git a/src/bundled/claude-code/claude.ts b/src/bundled/claude-code/claude.ts index 048b44225..c163acc52 100644 --- a/src/bundled/claude-code/claude.ts +++ b/src/bundled/claude-code/claude.ts @@ -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) => { @@ -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) { let claude: ClaudeProcess | undefined; const early: string[] = []; const earlyErrors: string[] = []; @@ -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) { + if (launch.tools) this.tools = launch.tools; + await this.request({ subtype: "initialize", appendSystemPrompt: launch.systemPrompt, ...(launch.tools @@ -165,7 +179,6 @@ export class ClaudeProcess { } : {}), }); - return claude; } get running() { diff --git a/src/bundled/claude-code/index.ts b/src/bundled/claude-code/index.ts index 89eaec9c0..a31de4171 100644 --- a/src/bundled/claude-code/index.ts +++ b/src/bundled/claude-code/index.ts @@ -28,7 +28,7 @@ export const apply: PluginModule["apply"] = (ctx) => { if (snapshot.status === "loading") return; runtime.sync(snapshot.running.filter((agent) => agent.type === typeKey)); }; - // A spare session is warmed for each running agent. + // One spare session waits for whichever running agent needs it next. if (spawn) { const unsubscribe = agents2.subscribe(sync); sync(); diff --git a/src/bundled/claude-code/runtime.test.ts b/src/bundled/claude-code/runtime.test.ts index a044deacd..00e8eac8b 100644 --- a/src/bundled/claude-code/runtime.test.ts +++ b/src/bundled/claude-code/runtime.test.ts @@ -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(' { 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); +}); diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts index 69955251e..aa9da19ff 100644 --- a/src/bundled/claude-code/runtime.ts +++ b/src/bundled/claude-code/runtime.ts @@ -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. */ @@ -67,6 +67,12 @@ type Entry = { contexts: Map; }; +/** 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(); private readonly reading = new Map>(); @@ -77,6 +83,11 @@ export class ClaudeRuntime { string, ReturnType >(); + /** 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, @@ -92,8 +103,9 @@ 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)); // Deleted, signed out or its community disconnected: either way its @@ -106,12 +118,15 @@ 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(); } } + if (agents.length) + this.spare.warm(...agents.map((agent) => where(config(agent.config)))); + else this.spare.stop(); this.notify(); } @@ -136,6 +151,7 @@ export class ClaudeRuntime { } dispose() { + this.spare.dispose(); for (const entry of this.agents.values()) entry.sessions.dispose(); this.agents.clear(); } @@ -150,6 +166,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}`, @@ -328,8 +347,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, { @@ -348,6 +365,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: () => @@ -358,12 +376,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, diff --git a/src/bundled/claude-code/sessions.test.ts b/src/bundled/claude-code/sessions.test.ts index b96e5724a..d200c9e56 100644 --- a/src/bundled/claude-code/sessions.test.ts +++ b/src/bundled/claude-code/sessions.test.ts @@ -1,11 +1,12 @@ import { afterEach, expect, it, vi } from "vitest"; import { fakeSpawn, flush } from "./claude-testing"; +import type { ToolServer } from "./claude"; import { AgentSessions, IDLE_MS, localSessions, - MAX_LIVE, type SavedSession, + Spare, type SessionStore, } from "./sessions"; @@ -31,25 +32,35 @@ function sessions( hold: boolean; /** Holds each launch after it reads the settings, as a memory read does. */ launched: Promise; + /** The shared spare its new conversations look for. */ + spare: Spare; + tools: ToolServer; }> = {}, ) { const fake = fakeSpawn(options.known); const store = options.store ?? memoryStore(); let model = "opus"; let ids = 0; + const spawn = (id: string, spawnOptions?: object) => { + // The fake records each process as the call begins. + const process = fake.spawn(id, spawnOptions); + const started = fake.processes.at(-1); + if (options.hold && started) started.hold = true; + return process; + }; const pool = new AgentSessions({ - spawn: (id, spawnOptions) => { - // The fake records each process as the call begins. - const process = fake.spawn(id, spawnOptions); - const started = fake.processes.at(-1); - if (options.hold && started) started.hold = true; - return process; - }, + spawn, + ...(options.spare ? { spare: options.spare } : {}), store, launch: async () => { const current = model; await options.launched; - return { cwd: "/work", systemPrompt: "prompt", model: current }; + return { + cwd: "/work", + systemPrompt: "prompt", + model: current, + ...(options.tools ? { tools: options.tools } : {}), + }; }, fingerprint: () => model, newId: () => `new-${++ids}`, @@ -59,6 +70,7 @@ function sessions( return { pool, store, + spawn, claudes, live: () => claudes().filter((process) => !process.killed), setModel: (next: string) => { @@ -67,21 +79,165 @@ function sessions( }; } -it("starts a new conversation on the warm spare and warms another", async () => { - const { pool, store, claudes } = sessions(); - pool.warm(); +/** Sessions with a spare, spawned like their own processes. */ +function withSpare(options: Parameters[0] = {}) { + let ids = 0; + let parts: ReturnType | undefined; + const spare = new Spare( + (id, spawnOptions) => { + if (!parts) throw new Error("not ready"); + return parts.spawn(id, spawnOptions); + }, + async (_conversation, message) => ({ from: "spare", message }), + () => `spare-${++ids}`, + ); + parts = sessions({ ...options, spare }); + return { ...parts, spare }; +} + +const tool = (process: { emit(message: object): void }, id: string) => + process.emit({ + type: "control_request", + request_id: id, + request: { + subtype: "mcp_message", + server_name: "buzz", + message: { jsonrpc: "2.0", id: 1, method: "tools/list" }, + }, + }); +const answered = ( + process: { received: Record[] }, + id: string, +) => + process.received.find( + (message) => + message.type === "control_response" && + (message.response as { request_id?: string }).request_id === id, + )?.response; + +it("starts a new conversation on the shared spare and warms another", async () => { + const { pool, spare, store, claudes } = withSpare(); + spare.warm({ cwd: "/work", model: "opus" }); await flush(); expect(claudes()).toHaveLength(1); + // Started before any agent asked: no prompt yet. + expect(claudes()[0]?.received).toEqual([]); await expect(pool.deliver("c/root", "hello", 10)).resolves.toEqual({ ok: true, }); await flush(); expect(claudes()).toHaveLength(2); - expect(claudes()[0]?.prompts).toEqual(["hello"]); - expect(store.get("c/root")).toMatchObject({ id: "new-1", seen: 10 }); + const [used] = claudes(); + expect(used?.received[0]).toMatchObject({ + type: "control_request", + request: { subtype: "initialize", appendSystemPrompt: "prompt" }, + }); + expect(used?.prompts).toEqual(["hello"]); + expect(store.get("c/root")).toMatchObject({ id: "spare-1", seen: 10 }); expect(pool.snapshot()).toEqual([{ key: "c/root", busy: false }]); }); +it("hands the spare's tools to the agent that takes it", async () => { + const agentTools = vi.fn(async () => ({ from: "agent" })); + const { pool, spare, claudes } = withSpare({ tools: agentTools }); + spare.warm({ cwd: "/work", model: "opus" }); + await flush(); + const [process] = claudes(); + if (!process) throw new Error("no spare"); + // Claude lists the tools as it starts, before any agent has the spare. + tool(process, "early"); + await flush(); + expect(answered(process, "early")).toMatchObject({ + response: { mcp_response: { from: "spare" } }, + }); + await pool.deliver("c/root", "hi", 1); + tool(process, "later"); + await flush(); + expect(answered(process, "later")).toMatchObject({ + response: { mcp_response: { from: "agent" } }, + }); +}); + +it("serves whichever agent starts the next conversation", async () => { + const one = withSpare(); + const two = sessions({ spare: one.spare }); + one.spare.warm({ cwd: "/work", model: "opus" }); + await flush(); + await one.pool.deliver("c/a", "for one", 1); + await flush(); + await two.pool.deliver("c/b", "for two", 2); + // Each took the spare (all started by the first's spawn); neither agent + // started a process of its own. + expect(one.claudes().map((process) => process.prompts)).toEqual([ + ["for one"], + ["for two"], + [], + ]); + expect(two.claudes()).toEqual([]); +}); + +it("starts a conversation elsewhere afresh, and the spare follows it", async () => { + const { pool, spare, claudes } = withSpare(); + spare.warm({ cwd: "/other", model: "opus" }); + await flush(); + const [other] = claudes(); + await pool.deliver("c/root", "hi", 1); + await flush(); + expect(other?.killed).toBe(true); + expect(other?.prompts).toEqual([]); + const used = claudes().filter((process) => process.prompts.length); + expect(used[0]?.options.args).toContain("new-1"); + const next = claudes().find((process) => + process.options.args?.includes("spare-2"), + ); + expect(next?.options.cwd).toBe("/work"); + expect(next?.killed).toBe(false); +}); + +it("does not wait for a spare started elsewhere", async () => { + // The spare for another folder never finishes starting. + const spare = new Spare( + () => new Promise(() => undefined), + async () => ({}), + ); + const { pool, claudes } = sessions({ spare }); + spare.warm({ cwd: "/other", model: "opus" }); + await expect(pool.deliver("c/root", "hi", 1)).resolves.toEqual({ ok: true }); + expect(claudes()[0]?.prompts).toEqual(["hi"]); +}); + +it("keeps a spare that fits any agent, and replaces one that fits none", async () => { + const { spare, claudes } = withSpare(); + const work = { cwd: "/work", model: "opus" }; + const other = { cwd: "/other", model: "opus" }; + spare.warm(work); + await flush(); + spare.warm(other, work); + await flush(); + expect(claudes()).toHaveLength(1); + spare.warm(other); + await flush(); + expect(claudes()).toHaveLength(2); + expect(claudes()[0]?.killed).toBe(true); + expect(claudes()[1]?.options.cwd).toBe("/other"); +}); + +it("starts no spare again after one dies, until a conversation starts", async () => { + const { pool, spare, claudes } = withSpare(); + const where = { cwd: "/work", model: "opus" }; + spare.warm(where); + await flush(); + claudes()[0]?.exit(1, "not signed in"); + await flush(); + spare.warm(where); + await flush(); + expect(claudes()).toHaveLength(1); + await pool.deliver("c/root", "hi", 1); + await flush(); + // One for the conversation, and a spare again. + expect(claudes()).toHaveLength(3); +}); + it("continues a conversation in its own process, apart from others", async () => { const { pool, claudes } = sessions(); await pool.deliver("c/one", "first", 1); @@ -93,14 +249,11 @@ it("continues a conversation in its own process, apart from others", async () => }); it("steers a conversation that is still working instead of queueing", async () => { - const { pool, claudes } = sessions(); - pool.warm(); - await flush(); - const process = claudes()[0]; - if (!process) throw new Error("no spare"); - process.hold = true; + const { pool, claudes } = sessions({ hold: true }); const first = pool.deliver("c/root", "first", 1); await flush(); + const process = claudes()[0]; + if (!process) throw new Error("no process"); const second = pool.deliver("c/root", "plain", 2, { steer: "steer" }); await flush(); expect(process.prompts).toEqual(["first", "steer"]); @@ -154,47 +307,13 @@ it("starts over when a saved session no longer loads", async () => { expect(store.get("c/root")).toMatchObject({ id: "new-1", seen: 9 }); }); -it("stops the longest-idle conversation to stay within its process limit", async () => { - const { pool, live } = sessions(); - for (let index = 0; index < MAX_LIVE + 2; index++) - await pool.deliver(`c/${index}`, "hi", index); - await flush(); - expect(live().length).toBeLessThanOrEqual(MAX_LIVE); - const keys = pool.snapshot().map((session) => session.key); - expect(keys).not.toContain("c/0"); - expect(keys).toContain(`c/${MAX_LIVE + 1}`); -}); - -it("says it is busy rather than start a process past its limit", async () => { +it("runs any number of conversations at once", async () => { const { pool, live } = sessions({ hold: true }); - pool.warm(); - for (let index = 0; index < MAX_LIVE; index++) + for (let index = 0; index < 12; index++) void pool.deliver(`c/${index}`, "hi", index); await flush(); - expect(live()).toHaveLength(MAX_LIVE); - await expect(pool.deliver("c/extra", "hi", 9)).resolves.toEqual({ - ok: false, - error: `Claude Code is already working in ${MAX_LIVE} conversations; try again when one finishes`, - }); - expect(live()).toHaveLength(MAX_LIVE); - expect(pool.snapshot().map((session) => session.key)).not.toContain( - "c/extra", - ); -}); - -it("counts conversations still starting toward its limit", async () => { - let release!: () => void; - const launched = new Promise((resolve) => { - release = resolve; - }); - const { pool, live } = sessions({ hold: true, launched }); - const results = Array.from({ length: MAX_LIVE + 1 }, (_, index) => - pool.deliver(`c/${index}`, "hi", index), - ); - await expect(results[MAX_LIVE]).resolves.toMatchObject({ ok: false }); - release(); - await flush(); - expect(live()).toHaveLength(MAX_LIVE); + expect(live()).toHaveLength(12); + expect(pool.snapshot().every((session) => session.busy)).toBe(true); }); it("starts again with settings saved while a conversation's process started", async () => { @@ -217,17 +336,15 @@ it("starts again with settings saved while a conversation's process started", as expect(used[0]?.options.args).toContain("sonnet"); }); -it("restarts idle processes and the spare when settings change", async () => { +it("restarts idle processes when settings change", async () => { const { pool, claudes, setModel } = sessions(); await pool.deliver("c/root", "hi", 1); await flush(); - const [conversation, spare] = claudes(); + const [conversation] = claudes(); setModel("sonnet"); pool.reconfigure(); await flush(); expect(conversation?.killed).toBe(true); - expect(spare?.killed).toBe(true); - expect(claudes().at(-1)?.options.args).toContain("sonnet"); await pool.deliver("c/root", "again", 2); const resumed = claudes().at(-1); expect(resumed?.options.args).toEqual( @@ -236,16 +353,20 @@ it("restarts idle processes and the spare when settings change", async () => { }); it("stops every process when disposed", async () => { - const { pool, claudes } = sessions(); - pool.warm(); + const { pool, spare, claudes } = withSpare(); await pool.deliver("c/root", "hi", 1); await flush(); pool.dispose(); + spare.dispose(); await flush(); + expect(claudes()).toHaveLength(2); expect(claudes().every((process) => process.killed)).toBe(true); await expect(pool.deliver("c/root", "late", 2)).resolves.toMatchObject({ ok: false, }); + spare.warm({ cwd: "/work", model: "opus" }); + await flush(); + expect(claudes()).toHaveLength(2); }); it("keeps saved sessions per agent in storage", () => { diff --git a/src/bundled/claude-code/sessions.ts b/src/bundled/claude-code/sessions.ts index ca1ea59f5..0a49ebe98 100644 --- a/src/bundled/claude-code/sessions.ts +++ b/src/bundled/claude-code/sessions.ts @@ -1,17 +1,16 @@ // One agent's Claude sessions: one per conversation (a thread, or a whole DM), // each continuing across messages and app restarts. A conversation in use keeps // its process; a quiet one is stopped and resumed from its saved session when it -// is next addressed. A spare process waits for the next new conversation, so -// that starts without a spawn. +// is next addressed. One spare process, shared by every agent, waits for the +// next new conversation, so that starts without a spawn. import { ClaudeProcess, type ClaudeLaunch, type Settled, type Spawn, + type ToolServer, } from "./claude"; -/** Processes an agent keeps, the spare included. */ -export const MAX_LIVE = 6; /** A conversation's process stops after this long without a message. */ export const IDLE_MS = 15 * 60_000; /** Saved sessions per agent; the least recently used are forgotten. */ @@ -32,6 +31,8 @@ export type SessionStore = { export type SessionsOptions = Readonly<{ spawn: Spawn; + /** Where a new conversation looks for a process already started. */ + spare?: Spare; store: SessionStore; /** How to start a process with the agent's current settings. */ launch(): Promise>; @@ -53,7 +54,6 @@ type Live = { export class AgentSessions { private readonly live = new Map(); private readonly opening = new Map>(); - private spare: Promise | undefined; private disposed = false; private readonly now: () => number; private readonly newId: () => string; @@ -75,22 +75,6 @@ export class AgentSessions { })); } - /** Starts a spare process unless one exists or the agent is at its limit. */ - warm() { - if (this.disposed || this.spare || this.count() >= MAX_LIVE) return; - const spare = this.start(undefined).catch((error) => { - console.warn("Claude Code could not start a spare session", error); - return undefined; - }); - this.spare = spare; - void spare.then((live) => { - // A spare that dies, or was started with old settings, is not kept. - live?.process.exited.then(() => { - if (this.spare === spare) this.spare = undefined; - }); - }); - } - /** Sends `text` into the conversation `key`, and resolves when the session * has answered it. `steer` replaces `text` when the session is mid-turn; * `fresh` builds it again for a new session when the saved one is lost. */ @@ -122,22 +106,10 @@ export class AgentSessions { return this.answer(key, live, live.process.busy ? steer : text); } const saved = this.options.store.get(key); - // A new conversation takes the spare's place; any other start needs room, - // reserved here, before anything is awaited. - if ((saved || !this.spare) && !this.room()) - return { - ok: false, - error: `Claude Code is already working in ${MAX_LIVE} conversations; try again when one finishes`, - }; - const starting = (async () => - saved - ? this.start(saved.id) - : ((await this.claimSpare()) ?? this.start(undefined)))().catch( - (error) => { - console.warn("Claude Code could not start", error); - return undefined; - }, - ); + const starting = this.start(saved?.id).catch((error) => { + console.warn("Claude Code could not start", error); + return undefined; + }); this.opening.set(key, starting); live = await starting; if (this.opening.get(key) === starting) this.opening.delete(key); @@ -158,51 +130,47 @@ export class AgentSessions { return delivered; } - /** Stops idle processes started with old settings and replaces the spare; - * busy ones stop when they next fall idle. */ + /** Stops idle processes started with old settings; busy ones stop when they + * next fall idle. */ reconfigure() { const fingerprint = this.options.fingerprint(); for (const [key, live] of this.live) if (live.fingerprint !== fingerprint && !live.process.busy) this.drop(key, live); - const spare = this.spare; - this.spare = undefined; - void spare?.then((live) => live?.process.kill()); - this.warm(); } dispose() { this.disposed = true; for (const [key, live] of this.live) this.drop(key, live); - void this.spare?.then((live) => live?.process.kill()); - this.spare = undefined; } private async start(resume: string | undefined): Promise { // Read before `launch` reads the settings, so it never describes newer ones. const fingerprint = this.options.fingerprint(); const launch = await this.options.launch(); - const process = await ClaudeProcess.start(this.options.spawn, { - ...launch, - ...(resume ? { resume } : { sessionId: this.newId() }), - }); + const process = resume + ? await ClaudeProcess.start(this.options.spawn, { ...launch, resume }) + : await this.fresh(launch); if (fingerprint === this.options.fingerprint() || this.disposed) return { process, fingerprint, used: this.now() }; // Settings saved while it started: it would work with the old ones. void process.kill(); return this.start(resume); } - private async claimSpare() { - const spare = this.spare; - this.spare = undefined; - const live = await spare; - if ( - live?.process.running && - live.fingerprint === this.options.fingerprint() - ) - return live; - void live?.process.kill(); - return undefined; + /** A process for a new session: the spare when it fits, else a new one. */ + private async fresh(launch: Omit) { + const spare = await this.options.spare?.take(launch); + if (spare) + try { + await spare.initialize(launch); + return spare; + } catch { + void spare.kill(); + } + return ClaudeProcess.start(this.options.spawn, { + ...launch, + sessionId: this.newId(), + }); } private adopt(key: string, live: Live, seen: number) { live.process.conversation = key; @@ -212,7 +180,6 @@ export class AgentSessions { void live.process.exited.then(() => { if (this.live.get(key) === live) this.drop(key, live); }); - this.warm(); } private async answer(key: string, live: Live, text: string) { const settled = await live.process.send(text); @@ -240,25 +207,6 @@ export class AgentSessions { at: live.used, }); } - /** Processes running or starting, the spare included. */ - private count() { - return this.live.size + this.opening.size + (this.spare ? 1 : 0); - } - /** Makes room for one more process by stopping the longest-idle - * conversation, else the spare. False when every process is working. */ - private room() { - while (this.count() >= MAX_LIVE) { - const idle = [...this.live] - .filter(([, live]) => !live.process.busy) - .sort(([, a], [, b]) => a.used - b.used)[0]; - if (idle) this.drop(...idle); - else if (this.spare) { - void this.spare.then((live) => live?.process.kill()); - this.spare = undefined; - } else return false; - } - return true; - } private drop(key: string, live: Live) { clearTimeout(live.timer); if (this.live.get(key) === live) { @@ -269,6 +217,90 @@ export class AgentSessions { } } +/** Where a process runs and its model: all a spare is started with. */ +type Where = Pick; +const place = ({ cwd, model }: Where) => JSON.stringify([cwd, model ?? ""]); + +/** One process started ahead of need, for whichever agent next starts a + * conversation where it runs and with its model. Starting is most of the wait + * before a new conversation's first reply; giving the process its prompt is not. + * It follows the latest new conversation, the likeliest place for the next. */ +export class Spare { + private next: + | { place: string; process: Promise } + | undefined; + /** Not started again after it failed to start or died, until a conversation + * asks for one, so a `claude` that cannot run is not started over and over. */ + private stopped = false; + private disposed = false; + + constructor( + private readonly spawn: Spawn, + /** Answers the tool server before any agent has it: it lists the tools. */ + private readonly tools: ToolServer, + private readonly newId: () => string = () => crypto.randomUUID(), + ) {} + + /** Keeps a spare for one of `wheres`: the one there is, when it fits one, + * else a new one for the first. */ + warm(...wheres: Where[]) { + const [first] = wheres; + if (this.disposed || this.stopped || !first) return; + if (this.next) { + if (wheres.some((where) => place(where) === this.next?.place)) return; + this.stop(); + } + const next = { + place: place(first), + process: ClaudeProcess.spawn(this.spawn, { + ...first, + sessionId: this.newId(), + tools: this.tools, + }).catch((error) => { + console.warn("Claude Code could not start a spare session", error); + return undefined; + }), + }; + this.next = next; + void next.process.then(async (process) => { + await process?.exited; + if (this.next !== next) return; + this.next = undefined; + this.stopped = true; + }); + } + + /** The spare, when it was started for `where`; a spare for `where` is + * started next either way. */ + async take(where: Where) { + const next = this.next; + this.next = undefined; + this.stopped = false; + this.warm(where); + if (next?.place !== place(where)) { + void next?.process.then((process) => process?.kill()); + return undefined; + } + const process = await next.process; + if (process?.running) return process; + void process?.kill(); + return undefined; + } + + /** Stops the spare until `warm` is called again. */ + stop() { + const next = this.next; + this.next = undefined; + this.stopped = false; + void next?.process.then((process) => process?.kill()); + } + + dispose() { + this.disposed = true; + this.stop(); + } +} + /** Sessions saved in local storage, by agent and then conversation. */ export function localSessions( storage: Storage, From c06f6b57f9efd971c83555bcd72b8529dc940982 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 10 Oct 2026 21:06:11 -0700 Subject: [PATCH 04/39] fix(claude-code): only the runtime decides where a spare is kept A conversation whose launch finished after its last agent was removed took the spare and started a replacement, which then stayed with no agents. Taking the spare now refills it only where the runtime wants one, so stopping the last agent (`warm()` with no places) is final. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Bradley Axen --- src/bundled/claude-code/runtime.ts | 4 +- src/bundled/claude-code/sessions.test.ts | 21 ++++++- src/bundled/claude-code/sessions.ts | 80 ++++++++++++------------ 3 files changed, 61 insertions(+), 44 deletions(-) diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts index aa9da19ff..c0f2b271e 100644 --- a/src/bundled/claude-code/runtime.ts +++ b/src/bundled/claude-code/runtime.ts @@ -124,9 +124,7 @@ export class ClaudeRuntime { entry.sessions.reconfigure(); } } - if (agents.length) - this.spare.warm(...agents.map((agent) => where(config(agent.config)))); - else this.spare.stop(); + this.spare.warm(...agents.map((agent) => where(config(agent.config)))); this.notify(); } diff --git a/src/bundled/claude-code/sessions.test.ts b/src/bundled/claude-code/sessions.test.ts index d200c9e56..478c70500 100644 --- a/src/bundled/claude-code/sessions.test.ts +++ b/src/bundled/claude-code/sessions.test.ts @@ -178,7 +178,7 @@ it("serves whichever agent starts the next conversation", async () => { it("starts a conversation elsewhere afresh, and the spare follows it", async () => { const { pool, spare, claudes } = withSpare(); - spare.warm({ cwd: "/other", model: "opus" }); + spare.warm({ cwd: "/other", model: "opus" }, { cwd: "/work", model: "opus" }); await flush(); const [other] = claudes(); await pool.deliver("c/root", "hi", 1); @@ -354,6 +354,7 @@ it("restarts idle processes when settings change", async () => { it("stops every process when disposed", async () => { const { pool, spare, claudes } = withSpare(); + spare.warm({ cwd: "/work", model: "opus" }); await pool.deliver("c/root", "hi", 1); await flush(); pool.dispose(); @@ -364,9 +365,25 @@ it("stops every process when disposed", async () => { await expect(pool.deliver("c/root", "late", 2)).resolves.toMatchObject({ ok: false, }); + expect(claudes()).toHaveLength(2); +}); + +it("starts no spare for a conversation that starts after its agents are gone", async () => { + let release!: () => void; + const launched = new Promise((resolve) => { + release = resolve; + }); + const { pool, spare, live } = withSpare({ launched }); spare.warm({ cwd: "/work", model: "opus" }); + const done = pool.deliver("c/root", "hi", 1); await flush(); - expect(claudes()).toHaveLength(2); + // As the runtime does when its last agent goes, mid-launch. + pool.dispose(); + spare.warm(); + release(); + await expect(done).resolves.toMatchObject({ ok: false }); + await flush(); + expect(live()).toEqual([]); }); it("keeps saved sessions per agent in storage", () => { diff --git a/src/bundled/claude-code/sessions.ts b/src/bundled/claude-code/sessions.ts index 0a49ebe98..0b80ab674 100644 --- a/src/bundled/claude-code/sessions.ts +++ b/src/bundled/claude-code/sessions.ts @@ -224,15 +224,17 @@ const place = ({ cwd, model }: Where) => JSON.stringify([cwd, model ?? ""]); /** One process started ahead of need, for whichever agent next starts a * conversation where it runs and with its model. Starting is most of the wait * before a new conversation's first reply; giving the process its prompt is not. - * It follows the latest new conversation, the likeliest place for the next. */ + * It is kept only where the runtime's agents run, and follows the latest new + * conversation among those places, the likeliest place for the next. */ export class Spare { private next: | { place: string; process: Promise } | undefined; + /** Where a spare is wanted: set by the runtime alone. */ + private wanted: readonly Where[] = []; /** Not started again after it failed to start or died, until a conversation * asks for one, so a `claude` that cannot run is not started over and over. */ private stopped = false; - private disposed = false; constructor( private readonly spawn: Spawn, @@ -241,15 +243,45 @@ export class Spare { private readonly newId: () => string = () => crypto.randomUUID(), ) {} - /** Keeps a spare for one of `wheres`: the one there is, when it fits one, - * else a new one for the first. */ + /** Wants a spare for one of `wheres`, or none when there are none. */ warm(...wheres: Where[]) { - const [first] = wheres; - if (this.disposed || this.stopped || !first) return; - if (this.next) { - if (wheres.some((where) => place(where) === this.next?.place)) return; - this.stop(); + this.wanted = wheres; + if (!wheres.length) this.stopped = false; + this.fill(wheres); + } + + /** The spare, when it was started for `where`. Another is started for + * `where` when a spare is wanted there, else where one is wanted. */ + async take(where: Where) { + const next = this.next; + this.next = undefined; + this.stopped = false; + const here = this.wanted.some((wanted) => place(wanted) === place(where)); + this.fill(here ? [where] : this.wanted); + if (next?.place !== place(where)) { + void next?.process.then((process) => process?.kill()); + return undefined; } + const process = await next.process; + if (process?.running) return process; + void process?.kill(); + return undefined; + } + + dispose() { + this.warm(); + } + + /** Keeps the spare when it fits one of `wheres`, else starts one for the + * first; with none, keeps none. */ + private fill(wheres: readonly Where[]) { + const current = this.next; + if (current && wheres.some((where) => place(where) === current.place)) + return; + this.next = undefined; + void current?.process.then((process) => process?.kill()); + const [first] = wheres; + if (this.stopped || !first) return; const next = { place: place(first), process: ClaudeProcess.spawn(this.spawn, { @@ -269,36 +301,6 @@ export class Spare { this.stopped = true; }); } - - /** The spare, when it was started for `where`; a spare for `where` is - * started next either way. */ - async take(where: Where) { - const next = this.next; - this.next = undefined; - this.stopped = false; - this.warm(where); - if (next?.place !== place(where)) { - void next?.process.then((process) => process?.kill()); - return undefined; - } - const process = await next.process; - if (process?.running) return process; - void process?.kill(); - return undefined; - } - - /** Stops the spare until `warm` is called again. */ - stop() { - const next = this.next; - this.next = undefined; - this.stopped = false; - void next?.process.then((process) => process?.kill()); - } - - dispose() { - this.disposed = true; - this.stop(); - } } /** Sessions saved in local storage, by agent and then conversation. */ From 10ddcc861ee20ab75c9537aacaac44ecde277a31 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 10 Oct 2026 21:06:14 -0700 Subject: [PATCH 05/39] fix(agents2): end agent lifetimes on teardown and inventory errors - A native agent list still being read when Agents2 stops is now stale, so it cannot reopen agent communities after teardown. - Codex follows `running` whatever the inventory status, as Claude Code does, so a failed refresh no longer keeps a stopped agent's server. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Bradley Axen --- src/bundled/codex/index.test.ts | 28 +++++++++++++++++++ src/bundled/codex/index.ts | 2 +- src/features/agents2/service.test.ts | 41 ++++++++++++++++++++++++++++ src/features/agents2/service.ts | 2 ++ 4 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/bundled/codex/index.test.ts b/src/bundled/codex/index.test.ts index 4672d3643..8ca97b59a 100644 --- a/src/bundled/codex/index.test.ts +++ b/src/bundled/codex/index.test.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { AgentType } from "../../features/agents2/service"; +import { CodexRuntime } from "./runtime"; import { apply } from "./index"; const desktop = vi.hoisted(() => ({ tauri: true })); @@ -47,3 +48,30 @@ it("registers only where the desktop app can run codex and base64", () => { desktop.tauri = false; expect(applied()).toBeUndefined(); }); + +it("stops agents that stop running even when the agent list could not be read", () => { + const sync = vi.spyOn(CodexRuntime.prototype, "sync"); + const agent = { pubkey: "agent", type: "buzz.codex/codex" }; + let state = { status: "ready", agents: [agent], running: [agent] }; + let notify = () => undefined; + apply({ + react: React, + pluginOwner: { id: "buzz.codex" }, + host: { spawn: vi.fn(), request: vi.fn() }, + agents2: { + register: () => undefined, + snapshot: () => state, + relay: () => undefined, + subscribe: (listener: () => undefined) => { + notify = listener; + return () => undefined; + }, + }, + effect: vi.fn(), + } as never); + expect(sync).toHaveBeenLastCalledWith([agent]); + // A failed refresh keeps the list; leaving the community then empties it. + state = { ...state, status: "error", agents: [], running: [] }; + notify(); + expect(sync).toHaveBeenLastCalledWith([]); +}); diff --git a/src/bundled/codex/index.ts b/src/bundled/codex/index.ts index fb4fa90aa..995a06de6 100644 --- a/src/bundled/codex/index.ts +++ b/src/bundled/codex/index.ts @@ -20,7 +20,7 @@ export const apply: PluginModule["apply"] = (ctx) => { // reconnect does not. const sync = () => { const snapshot = ctx.agents2.snapshot(); - if (snapshot.status !== "ready") return; + if (snapshot.status === "loading") return; runtime.sync(snapshot.running.filter((agent) => agent.type === typeKey)); }; const offAgents = ctx.agents2.subscribe(sync); diff --git a/src/features/agents2/service.test.ts b/src/features/agents2/service.test.ts index 593cb46c4..6a48bf26e 100644 --- a/src/features/agents2/service.test.ts +++ b/src/features/agents2/service.test.ts @@ -524,6 +524,47 @@ it("connects an agent's community at start without selecting it", async () => { await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1)); }); +it("opens no community for an agent list that arrives after it stopped", async () => { + const ctx = new Context(); + ctx.provide("pluginStatus", { + isActive: () => true, + subscribe: () => () => {}, + }); + const client = fakeCommunities( + fakeRelay(), + fakeRelay("https://other.example.test"), + ); + client.select(null); + const native = fakeNative(); + let list!: (identities: AgentIdentity[]) => void; + native.list.mockReturnValueOnce( + new Promise((resolve) => { + list = resolve; + }), + ); + const service = new Agents2Service( + ctx, + client.communities, + native, + memoryStorage(), + ); + await vi.waitFor(() => expect(list).toBeDefined()); + await ctx.fiber.dispose(); + list([ + { + pubkey: bot, + relay: "wss://other.example.test", + owner: viewer, + type: "example/echo", + name: "Echo", + deleted: false, + }, + ]); + await settle(); + expect(client.communities.open).not.toHaveBeenCalled(); + expect(service.snapshot().running).toEqual([]); +}); + it("keeps an agent running while its community reconnects, and stops it on disconnect or leave", async () => { const { service, connect, reconnecting, leave } = await setup(); await service.create({ type: "example/echo", name: "Echo" }); diff --git a/src/features/agents2/service.ts b/src/features/agents2/service.ts index d0a16c10d..9c9fe592c 100644 --- a/src/features/agents2/service.ts +++ b/src/features/agents2/service.ts @@ -331,6 +331,8 @@ export class Agents2Service extends Service implements Agents2 { }, TIMER_TICK_MS); return () => { clearInterval(tick); + // A list still being read is stale: it must not reopen communities. + this.listed++; for (const stop of stops) stop(); for (const origin of [...this.bound.keys()]) this.unbind(origin); for (const runner of this.runners.values()) this.retire(runner); From fafa5d63f940c37b4ad7adf3d8d7fbe098803a06 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 10 Oct 2026 21:28:08 -0700 Subject: [PATCH 06/39] perf(codex): one app-server for every agent Codex agents each kept their own `codex app-server` (~300-400 MB with its MCP children) until the agent stopped running. With agents now running in every joined community, that cost grew with every Codex agent used. Everything agent-specific already travels with the thread: cwd, developer instructions and dynamic tools go in thread/start, and tool calls carry their threadId. So the runtime now owns one server, opened on first use in ~/.buzz, and finds a tool call's agent by thread and turn across all agents. Entries keep their conversations and saved bindings but no longer own a process. A stopped agent can no longer take its process down with it, so it interrupts its own turns, then cleans the background shells its threads left (a dev server from a finished turn, say). Starting the same agent again waits for that, so it never resumes a thread mid-teardown. The server ends with the last Codex agent, on dispose, or on a crash (the next mention reopens it). live.mjs passed the relay as an object since the per-agent relay change; it now passes a function, expands ~ like the desktop host, and checks two agents on one real server. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Bradley Axen --- src/bundled/codex/README.md | 17 ++-- src/bundled/codex/index.ts | 4 +- src/bundled/codex/live.mjs | 55 +++++++++++- src/bundled/codex/runtime.test.ts | 129 ++++++++++++++++++++++++++++ src/bundled/codex/runtime.ts | 135 ++++++++++++++++++++---------- 5 files changed, 287 insertions(+), 53 deletions(-) diff --git a/src/bundled/codex/README.md b/src/bundled/codex/README.md index b13bedc13..dd92278f5 100644 --- a/src/bundled/codex/README.md +++ b/src/bundled/codex/README.md @@ -2,8 +2,10 @@ A bundled desktop plugin, on by default on macOS and Linux, using the native process host introduced in #731. Agents2 owns the identity, admission, configuration, and signed Buzz actions. This plugin -owns Codex work after delivery handover. It starts one `codex app-server` per agent -and uses independent Codex threads for Buzz conversations. +owns Codex work after delivery handover. It starts one `codex app-server` for all +agents and uses an independent Codex thread for each agent's Buzz conversation. +Each thread carries its agent's workspace, instructions and tools, so one crash +fails every agent's current turn; the next mention reopens the server. ## Try it @@ -47,8 +49,10 @@ current turn finishes. Expect the marker. Thread scope is the default. Channel scope shares a session across channel threads; DMs always share a conversation. Workspace changes start a fresh session. Bindings persist locally under the community and agent identity. Earlier bindings without dynamic tools are ignored; new conversations start fresh. Switching -communities does not end its server; removing the agent, leaving or disconnecting -from its community, or disabling the plugin does. +communities does not end an agent's work. Removing the agent, leaving or +disconnecting from its community interrupts its turns and ends shells they left +running; the server ends when no +Codex agent that has worked is still running, or when the plugin is disabled. If a saved Codex thread no longer exists, the next mention starts a fresh one. To inspect a saved session, see [View a Codex session](../../../docs/view-codex-session.md). @@ -77,8 +81,9 @@ workspace and a simulated relay. It sends no Buzz messages. It checks native she and file tools, the native coding prompt, conversation context, default steering, idle follow-ups, process shutdown, saved-thread reuse, changed and cleared instructions, missing-thread recovery, and disabled inherited MCP tools. It also -checks real dynamic Buzz callbacks, memory, workspace file uploads, and normal -final sends without aborted-turn markers. Set +checks real dynamic Buzz callbacks, memory, workspace file uploads, normal +final sends without aborted-turn markers, and two agents sharing one server with +their own workspace, instructions and replies. Set `BUZZ_CODEX_TEST_MODEL` to another available model if needed. It prints only sanitized outcomes; it does not save raw protocol or session logs. diff --git a/src/bundled/codex/index.ts b/src/bundled/codex/index.ts index 995a06de6..37195bef2 100644 --- a/src/bundled/codex/index.ts +++ b/src/bundled/codex/index.ts @@ -16,8 +16,8 @@ export const apply: PluginModule["apply"] = (ctx) => { }); const runtime = new CodexRuntime(start, ctx.agents2.relay); const typeKey = `${ctx.pluginOwner?.id ?? "buzz.codex"}/codex`; - // Agents that stop running stop their servers; switching communities or a - // reconnect does not. + // Agents that stop running stop their turns, and the last one stops the + // server; switching communities or a reconnect does not. const sync = () => { const snapshot = ctx.agents2.snapshot(); if (snapshot.status === "loading") return; diff --git a/src/bundled/codex/live.mjs b/src/bundled/codex/live.mjs index fe197614b..dceae90ae 100644 --- a/src/bundled/codex/live.mjs +++ b/src/bundled/codex/live.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { access, mkdtemp, readFile, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { build } from "rolldown"; import { JSDOM } from "jsdom"; @@ -20,6 +20,7 @@ const { CodexRuntime } = await import(join(workspace, "runtime.mjs")); const { AppServer } = await import(join(workspace, "rpc.mjs")); const children = new Set(); const notices = []; +let servers = 0; const sent = []; let acceptedSteers = 0; const nativeSpawn = async (_id, options = {}) => { @@ -46,6 +47,7 @@ const nativeSpawn = async (_id, options = {}) => { exited, }; } + servers++; const child = spawn( "codex", [ @@ -58,7 +60,8 @@ const nativeSpawn = async (_id, options = {}) => { `mcp_servers={"fixture.tools"={command="sh",args=["-c",${JSON.stringify(`touch '${join(workspace, "mcp-must-not-start")}'`)}]}}`, ], { - cwd: options.cwd, + // Like the desktop host, expand ~ in a process's working directory. + cwd: options.cwd?.replace(/^~(?=\/|$)/, homedir()), detached: true, env: Object.fromEntries( Object.entries(process.env).filter( @@ -169,7 +172,7 @@ const relay = { }, }), }; -const runtime = new CodexRuntime(nativeSpawn, relay, store); +const runtime = new CodexRuntime(nativeSpawn, () => relay, store); runtime.sync([{ pubkey }], scope); let id = 0; const delivery = (content) => { @@ -469,6 +472,52 @@ try { console.log( "PASS real dynamic callbacks, resumed tools, shared reads, memory, workspace file read/upload and final send without duplicate replies", ); + const other = "9".repeat(64); + const otherWorkspace = await mkdtemp(join(tmpdir(), "buzz-codex-other-")); + const otherPublished = []; + runtime.sync([{ pubkey }, { pubkey: other }]); + const before = servers; + const first = delivery( + "Reply exactly FIRST_AGENT_OK. No coding tools. Send the reply with buzz.send.", + ); + const second = delivery( + "Run pwd, then reply with its output. Send the reply with buzz.send.", + ); + await runtime.run(first); + await runtime.run({ + ...second, + agent: { + ...second.agent, + pubkey: other, + name: "Other", + publish: async (event) => { + otherPublished.push(event); + return { ...event, id: "8".repeat(64) }; + }, + }, + config: { + ...second.config, + workspace: otherWorkspace, + instructions: `${second.config.instructions} Every final answer MUST contain OTHER_AGENT.`, + }, + }); + await condition( + () => + published.length === 8 && + otherPublished.length === 1 && + runtime.sessions(pubkey)[0]?.status === "Idle" && + runtime.sessions(other)[0]?.status === "Idle", + "two agents on one server", + ); + // The previous step's server is still open and serves both agents. + assert.equal(servers, before); + assert.match(published.at(-1).content, /FIRST_AGENT_OK/); + assert.doesNotMatch(published.at(-1).content, /OTHER_AGENT/); + assert.match(otherPublished[0].content, /OTHER_AGENT/); + assert(otherPublished[0].content.includes(otherWorkspace.split("/").at(-1))); + console.log( + "PASS two agents share one server with their own workspace, instructions and replies", + ); } finally { await inspect?.close(); runtime.dispose(); diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts index 058db43cb..b2d6599e0 100644 --- a/src/bundled/codex/runtime.test.ts +++ b/src/bundled/codex/runtime.test.ts @@ -421,6 +421,135 @@ it("hands over promptly and runs conversations independently on one server", asy tags: expect.arrayContaining([["e", root, "", "reply"]]), }); }); +/** A second agent on the same runtime, with its own identity and publisher. */ +function second(f: ReturnType) { + const other = "9".repeat(64); + const base = f.delivery("unused").agent; + const agent = { + ...base, + pubkey: other, + name: "Other", + publish: vi.fn(async (event: unknown) => ({ + ...(event as object), + id: "8".repeat(64), + })), + } as unknown as Delivery["agent"]; + f.runtime.sync([{ pubkey } as Agent, { pubkey: other } as Agent]); + return { + agent, + delivery: (content: string) => ({ + ...f.delivery(content, "7".repeat(64)), + agent, + }), + }; +} +const contents = (publish: { mock: { calls: unknown[][] } }) => + publish.mock.calls.map(([event]) => (event as { content: string }).content); +const servers = (f: ReturnType) => + f.spawn.mock.calls.filter(([id]) => id === "app-server"); +it("serves every agent from one server and routes each tool call to its own agent", async () => { + const f = fixture(); + const b = second(f); + await f.runtime.run(f.delivery("@Codex first")); + await f.runtime.run(b.delivery("@Other second")); + await vi.waitFor(() => expect(f.starts()).toHaveLength(2)); + expect(servers(f)).toHaveLength(1); + expect(f.starts()[1]?.params).toMatchObject({ + additionalContext: { + "buzz.instructions": { value: expect.stringContaining("Other") }, + }, + }); + await f.complete(1, "FROM OTHER"); + await f.complete(0, "FROM CODEX"); + await vi.waitFor(() => expect(f.publish).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(b.agent.publish).toHaveBeenCalledTimes(1)); + expect(contents(f.publish)).toEqual(["FROM CODEX"]); + expect(contents(b.agent.publish as never)).toEqual(["FROM OTHER"]); + expect(f.runtime.sessions("9".repeat(64))[0]?.status).toBe("Idle"); +}); +it("stops a removed agent's turn without ending the server the others share", async () => { + const f = fixture(); + const b = second(f); + await f.runtime.run(f.delivery("work")); + await f.runtime.run(b.delivery("other work")); + await vi.waitFor(() => expect(f.starts()).toHaveLength(2)); + const removed = (f.starts()[1]?.params as { threadId: string } | undefined) + ?.threadId; + f.runtime.sync([{ pubkey } as Agent]); + await vi.waitFor(() => + expect( + f.sent.some( + (wire) => + wire.method === "thread/unsubscribe" && + (wire.params as { threadId: string }).threadId === removed, + ), + ).toBe(true), + ); + for (const method of ["turn/interrupt", "thread/backgroundTerminals/clean"]) + expect(f.sent.find((wire) => wire.method === method)?.params).toMatchObject( + { threadId: removed }, + ); + expect(f.process.end).not.toHaveBeenCalled(); + expect(f.runtime.sessions("9".repeat(64))).toHaveLength(0); + await f.complete(0, "STILL HERE"); + await vi.waitFor(() => expect(f.publish).toHaveBeenCalledTimes(1)); + expect(contents(f.publish)).toEqual(["STILL HERE"]); + expect(b.agent.publish).not.toHaveBeenCalled(); + f.runtime.sync([]); + await f.exits.promise; + expect(f.process.end).toHaveBeenCalled(); +}); +it("ends a stopped agent's leftover shells and lets it start again only after letting go", async () => { + const f = fixture(); + const b = second(f); + await f.runtime.run(f.delivery("work")); + await f.runtime.run(b.delivery("start a dev server")); + await vi.waitFor(() => expect(f.starts()).toHaveLength(2)); + const thread = (f.starts()[1]?.params as { threadId: string } | undefined) + ?.threadId; + await f.complete(1, "SERVER RUNNING"); + await vi.waitFor(() => expect(b.agent.publish).toHaveBeenCalledTimes(1)); + await f.runtime.run(b.delivery("busy again")); + await vi.waitFor(() => expect(f.starts()).toHaveLength(3)); + const index = (method: string) => + f.sent + .map( + (wire) => + wire.method === method && + (wire.params as { threadId: string }).threadId === thread, + ) + .lastIndexOf(true); + const before = index("thread/unsubscribe"); + f.runtime.sync([{ pubkey } as Agent]); + f.runtime.sync([{ pubkey } as Agent, { pubkey: "9".repeat(64) } as Agent]); + await f.runtime.run(b.delivery("back")); + await vi.waitFor(() => expect(f.starts()).toHaveLength(4)); + expect(index("thread/unsubscribe")).toBeGreaterThan(before); + expect(index("thread/backgroundTerminals/clean")).toBeGreaterThan( + index("thread/unsubscribe"), + ); + expect(index("thread/resume")).toBeGreaterThan( + index("thread/backgroundTerminals/clean"), + ); + expect(f.process.end).not.toHaveBeenCalled(); + await f.complete(3, "BACK"); + await f.complete(0, "STILL HERE"); + await vi.waitFor(() => expect(f.publish).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(b.agent.publish).toHaveBeenCalledTimes(2)); + expect(contents(b.agent.publish as never)).toEqual([ + "SERVER RUNNING", + "BACK", + ]); +}); +it("reopens the shared server after it exits", async () => { + const f = fixture(); + await f.runtime.run(f.delivery("work")); + await vi.waitFor(() => expect(f.starts()).toHaveLength(1)); + f.exits.resolve(3); + await vi.waitFor(() => expect(f.publish).toHaveBeenCalledTimes(1)); + await f.runtime.run(f.delivery("again")); + await vi.waitFor(() => expect(servers(f)).toHaveLength(2)); +}); it("serializes owner follow-ups and routes tool replies to the latest accepted request", async () => { const f = fixture(); await f.runtime.run(f.delivery("first")); diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index 27c29768f..7f0f9cef7 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -9,6 +9,7 @@ import { appClient } from "../../buzz-mcp/app-client"; import { TOOLS, callTool } from "../../buzz-mcp/tools"; import { config, + defaults, absoluteWorkspace, validWorkspace, type Config, @@ -42,6 +43,8 @@ type Request = { }; type Saved = { threadId: string; workspace: string }; type Active = { + /** The server this turn runs on; steering must reach the same process. */ + rpc: AppServer; threadId?: string; turnId?: string; ready: Promise; @@ -60,11 +63,17 @@ type Lane = { type Entry = { storageKey: string; abort: AbortController; - rpc: AppServer; - opening: Promise; + /** Settles once the same agent's previous run has let go of its threads. */ + after: Promise; lanes: Map; saved: Record; }; +type Server = { + rpc: AppServer; + opening: Promise; + /** Threads this process has loaded, and the agent each belongs to. */ + threads: Map; +}; export type SessionView = { key: string; status: string; detail: string }; const input = (text: string) => [{ type: "text", text, text_elements: [] }]; const message = (error: unknown) => @@ -76,9 +85,13 @@ function textOf(event: EventData, name: string) { } /** Agents2 owns admission/identity. This plugin owns work after hand-over, and - * uses one app-server per agent with independent Codex threads per conversation. */ + * uses one app-server for every agent. Each conversation is its own Codex + * thread carrying its agent's workspace, instructions and tools, and tool + * calls find their agent by thread and turn. */ export class CodexRuntime { private entries = new Map(); + private server: Server | undefined; + private stopping = new Map>(); private views = new Map(); private listeners = new Set<() => void>(); constructor( @@ -96,6 +109,8 @@ export class CodexRuntime { sessions = (pubkey: string): readonly SessionView[] => this.views.get(pubkey) ?? EMPTY; private status(pubkey: string, key: string, status: string, detail = "") { + // A stopped agent's draining turns don't bring its sessions back. + if (!this.entries.has(pubkey)) return; const others = this.sessions(pubkey).filter((view) => view.key !== key); this.views.set(pubkey, [ ...others.slice(-31), @@ -108,19 +123,68 @@ export class CodexRuntime { for (const [pubkey, entry] of this.entries) if (!present.has(pubkey)) { entry.abort.abort(); - void entry.rpc.close(); this.entries.delete(pubkey); this.views.delete(pubkey); + if (this.server) this.stop(this.server, pubkey, entry); } + if (!this.entries.size) this.close(); for (const listener of this.listeners) listener(); } + /** Other agents share the server, so a stopped agent's turns interrupt + * themselves. Then the shells its finished turns left are ended, which its + * own process used to do by exiting. Until then, the same agent starting + * again waits rather than resuming a thread that is still letting go. */ + private stop(server: Server, pubkey: string, entry: Entry) { + const stopped = (async () => { + await Promise.all( + [...entry.lanes.values()].map((lane) => + lane.tail.catch(() => undefined), + ), + ); + for (const [threadId, owner] of server.threads) + if (owner === pubkey) { + server.threads.delete(threadId); + await server.rpc + .request("thread/backgroundTerminals/clean", { threadId }) + .catch(() => undefined); + } + })(); + this.stopping.set(pubkey, stopped); + void stopped.finally(() => { + if (this.stopping.get(pubkey) === stopped) this.stopping.delete(pubkey); + }); + } dispose() { - for (const entry of this.entries.values()) { - entry.abort.abort(); - void entry.rpc.close(); - } + for (const entry of this.entries.values()) entry.abort.abort(); this.entries.clear(); this.views.clear(); + this.stopping.clear(); + this.close(); + } + private close() { + void this.server?.rpc.close(); + this.server = undefined; + } + private open() { + if (this.server) return this.server; + const rpc = new AppServer((call) => this.tool(call)); + // Threads set their own cwd; the process just needs a directory that exists. + const server: Server = { + rpc, + opening: rpc.open(this.spawn, defaults.workspace), + threads: new Map(), + }; + // An exit fails current work and permits the next ordinary mention to reopen. + rpc.onMessage((wire) => { + if (wire.method === "connection/closed" && this.server === server) + this.server = undefined; + }); + void server.opening.catch(() => { + if (this.server === server) this.server = undefined; + void rpc.close(); + }); + this.server = server; + return server; } private entry(request: Request) { const pubkey = request.agent.pubkey; @@ -143,28 +207,13 @@ export class CodexRuntime { } catch { /* Start fresh after malformed local data. */ } - const rpc = new AppServer((call) => this.tool(entry, call)); const entry: Entry = { storageKey: this.storageKey(request), abort: new AbortController(), - rpc, + after: this.stopping.get(pubkey) ?? Promise.resolve(), saved, lanes: new Map(), - opening: rpc.open(this.spawn, request.settings.workspace), }; - // An exit fails current work and permits the next ordinary mention to reopen. - rpc.onMessage((wire) => { - if ( - wire.method === "connection/closed" && - this.entries.get(pubkey) === entry - ) { - this.entries.delete(pubkey); - } - }); - void entry.opening.catch(() => { - if (this.entries.get(pubkey) === entry) this.entries.delete(pubkey); - void rpc.close(); - }); this.entries.set(pubkey, entry); return entry; } @@ -233,7 +282,7 @@ export class CodexRuntime { const entry = this.entry(request); let lane = entry.lanes.get(key); if (!lane) { - lane = { tail: Promise.resolve() }; + lane = { tail: entry.after }; entry.lanes.set(key, lane); } if (lane.active) { @@ -253,7 +302,7 @@ export class CodexRuntime { this.start(request, text, key, lane, entry); return; } - await entry.rpc.request("turn/steer", { + await active.rpc.request("turn/steer", { threadId: active.threadId, expectedTurnId: active.turnId, input: input( @@ -327,8 +376,9 @@ export class CodexRuntime { ], }); } - private async tool(entry: Entry, call: ToolCall): Promise { - const active = [...entry.lanes.values()] + private async tool(call: ToolCall): Promise { + const active = [...this.entries.values()] + .flatMap((entry) => [...entry.lanes.values()]) .map((lane) => lane.active) .find( (active) => @@ -447,12 +497,14 @@ export class CodexRuntime { let ready!: () => void; let notReady!: (error: unknown) => void; const turnAbort = new AbortController(); + const server = this.open(); const signal = AbortSignal.any([ entry.abort.signal, turnAbort.signal, AbortSignal.timeout(29 * 60_000), ]); const active: Active = { + rpc: server.rpc, ready: new Promise((resolve, reject) => { ready = resolve; notReady = reject; @@ -476,7 +528,7 @@ export class CodexRuntime { void completed.catch(() => undefined); const abort = () => fail(signal.reason); signal.addEventListener("abort", abort, { once: true }); - const off = entry.rpc.onMessage((wire) => { + const off = server.rpc.onMessage((wire) => { const p = wire.params as | { threadId?: string; @@ -517,7 +569,7 @@ export class CodexRuntime { } }); try { - const rpc = await entry.opening; + const rpc = await server.opening; signal.throwIfAborted(); // The process host expands ~/.buzz and creates it; Codex's RPC needs // an absolute path rather than shell-style home notation. @@ -610,6 +662,7 @@ export class CodexRuntime { }); } active.threadId = started.thread.id; + server.threads.set(active.threadId, agent.pubkey); signal.throwIfAborted(); await rpc.request("thread/name/set", { threadId: active.threadId, @@ -659,23 +712,21 @@ export class CodexRuntime { this.status(agent.pubkey, key, "Idle"); } } catch (error) { + const ended = active.finished; active.finished = true; notReady(error); await active.steering; - if ( - signal.aborted && - active.threadId && - active.turnId && - !entry.abort.signal.aborted - ) { + // Other agents share the server, so even a stopped agent interrupts + // its turn rather than ending the process. + if (signal.aborted && !ended && active.threadId && active.turnId) { try { - await entry.rpc.request("turn/interrupt", { + await server.rpc.request("turn/interrupt", { threadId: active.threadId, turnId: active.turnId, }); - await this.terminals(entry.rpc, active.threadId); + await this.terminals(server.rpc, active.threadId); } catch { - await entry.rpc.close(); + await server.rpc.close(); } } if (!entry.abort.signal.aborted) { @@ -691,10 +742,10 @@ export class CodexRuntime { off(); // Release the subscription before the next resume: loaded-thread rejoin // ignores instruction/config overrides while a client is subscribed. - if (active.threadId && !entry.abort.signal.aborted) - await entry.rpc + if (active.threadId) + await server.rpc .request("thread/unsubscribe", { threadId: active.threadId }) - .catch(() => entry.rpc.close()); + .catch(() => server.rpc.close()); if (lane.active === active) delete lane.active; } } From da93663fcaaf763a25606969e8acf8310290b7c2 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 10 Oct 2026 15:16:51 -0700 Subject: [PATCH 07/39] [session-debug] live agent session viewer `bin/node scripts/session-debug.mjs ''` serves a loopback page that follows a Codex or Claude Code transcript as it is written. The left rail is a vertical wall-time chart: inference, tool waits and their overlap, gaps nobody is accounted for, and idle time between runs, with each call's new tokens marked beside it. A grey band shows which part of the session is on screen. The conversation shows one harness/model/effort line, compact tool rows, and messages posted to Buzz as cards, so text the agent never sent stands out. Claude Code's zero-usage `` resume replies and queue bookkeeping no longer count as activity, which had been inflating inference time. The standalone exporters pick up the same tool rows and posted-message cards. Started by codex00; chart and conversation design by claude00. Co-Authored-By: Claude Opus 5.5 Squashed-from: baxen/session-debug Squashed-head: 84d70727ddf9db15c352f070634ab47f7adb682f Signed-off-by: Bradley Axen --- AGENTS.md | 11 + docs/session-debugging.md | 97 +++ docs/view-claude-session.md | 5 + docs/view-codex-session.md | 5 + scripts/export-claude-session.mjs | 14 +- scripts/export-codex-session.mjs | 51 +- scripts/session-debug-client.js | 608 +++++++++++++++++ scripts/session-debug.css | 778 ++++++++++++++++++++++ scripts/session-debug.html | 27 + scripts/session-debug.mjs | 277 ++++++++ scripts/session-model.mjs | 336 ++++++++++ scripts/session-timing.mjs | 261 ++++++++ scripts/session-viewer.mjs | 21 +- tests/integration/session-debug.test.mjs | 269 ++++++++ tests/integration/session-timing.test.mjs | 227 +++++++ vite.config.ts | 9 +- 16 files changed, 2979 insertions(+), 17 deletions(-) create mode 100644 docs/session-debugging.md create mode 100644 scripts/session-debug-client.js create mode 100644 scripts/session-debug.css create mode 100644 scripts/session-debug.html create mode 100644 scripts/session-debug.mjs create mode 100644 scripts/session-model.mjs create mode 100644 scripts/session-timing.mjs create mode 100644 tests/integration/session-debug.test.mjs create mode 100644 tests/integration/session-timing.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 4cf332f82..1fa97ad67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -272,3 +272,14 @@ communities load. Do not expose credentials in logs or responses. If the identit or community loading cannot be verified, report that limitation before presenting the build as ready to test. Follow the worktree bootstrap instructions above; do not hardcode identity values into tracked files. + +## Inspect a live agent session + +When asked to inspect or debug an Agent conversation, open its live session viewer +for the human: + +```sh +bin/node scripts/session-debug.mjs '' +``` + +Keep it running for live updates. See [details](docs/session-debugging.md) if needed. diff --git a/docs/session-debugging.md b/docs/session-debugging.md new file mode 100644 index 000000000..a5c01f83b --- /dev/null +++ b/docs/session-debugging.md @@ -0,0 +1,97 @@ +# Inspect a live agent session + +From a buzz-app checkout: + +```sh +bin/node scripts/session-debug.mjs '' +``` + +The command opens a private loopback URL in your browser and follows Codex or +Claude JSONL records once a second. Leave the command running. Ctrl+C stops it. +It does not resume an agent, execute transcript content, or send data to a relay. +For a known file use `--file /absolute/session.jsonl`. `--no-open` prints the URL +without opening it. A partial last JSONL line is retried on the next refresh; +malformed complete records produce a visible error, not silent omission. + +The left rail is one vertical wall-time chart for the whole session, with time +running downward. Active time is proportional: inference, tools, inference +overlapping a tool, and **unattributed** gaps (a run is active but no model or +tool activity is recorded). **Idle** time between runs, when the agent waits for +the next prompt, collapses to a labeled break. The legend totals each category; +overlap is counted once, never added to wall time. Tool time is call-to-result +elapsed time, not subprocess CPU time. Beside each inference, a thin mark shows +its new (uncached) tokens; hover it for the full context window and cache share. +Hover or focus a bar segment for its timing and tool names; click either to jump +the conversation to that record. + +Native transcripts do not expose exact provider request start times. Inference +bars are explicitly estimated from the preceding logged input/result through +observed model output, and can include queue, prefill and dispatch overhead. With +no usable output boundary, the chart shows an untimed inference and leaves that +time unattributed. An explicit running turn extends to now; legacy sessions with +no completion record end at their last recorded message. Claude Code's zero-usage +`` replies and queue bookkeeping are not counted as activity. The chart +uses UTC to match the transcript timestamps. + +Each inference shows new-input, cache-read, cache-write (when nonzero), and output +tokens. Token counts are the default cost view. Optional dollar estimates appear +only when rates are supplied; missing prices do not produce placeholder warnings. +For Codex, cached tokens are part of input and are subtracted to find new input. +Claude already reports new input separately. Neither provider's cache usage is +counted twice. + +The conversation stays on the right, under one line naming the harness, model +and reasoning effort (when the transcript records it). Messages the agent posted +to Buzz are highlighted; plain assistant text was never sent. Search filters the +conversation. The view follows new records while it is scrolled to the bottom +and leaves your position alone otherwise; expanded disclosures survive updates. +These are recorded transcript views, not exact reconstructed HTTP inference +requests. When multiple sessions match a thread, only one is shown; open a +specific one with `--file`. Thread discovery checks user delivery envelopes, +rather than matches in tool outputs that merely quoted a thread. Channel-scoped +sessions and DMs may require `--file`, and can contain multiple Buzz threads. + +## Recorded fields and cost + +Codex supplies `session_meta.base_instructions`, `dynamic_tools`, developer/user +messages, response items, lifecycle events, and token usage when supported by its +CLI version. The viewer prefers per-response `token_usage_record`, deduplicates +response IDs, and falls back to changes in cumulative token counts. It does not +add those two sources together. The chart uses timestamp intervals to account for +wall time; recorded `duration_ms` remains available in raw records. + +Claude's native transcript supplies messages and usage. Content blocks can repeat +the same message ID and usage; the viewer counts each response once. Legacy turn +boundaries are inferred from user messages; tool results do not start turns. +Without a completion event the viewer does not pretend that the last token's +arrival proves completion. Inferred boundaries are labeled. + +Dollar costs are unavailable unless reported or a rate file is supplied. They are +never represented as zero merely because the account uses a subscription. Claude +result totals are process-cumulative; the viewer derives differences within each +captured launch and labels the result. Exact billing can differ from API estimates. + +To estimate costs, create a JSON object keyed by exact model ID with USD per +million token rates, then pass `--rates /path/to/rates.json`: + +```json +{"your-model-id":{"input":2,"output":10,"cached":0.2,"written":2.5}} +``` + +These are illustrative rates, not current pricing. Supply all four rates. No +models or prices are guessed. The UI labels estimates. For Claude, input excludes +cache reads and writes; for Codex those are subtracted from inclusive input before +pricing. Models with tiered context, service tiers, or cache TTL pricing need rates +appropriate to the inspected usage; the simple estimator does not infer tiers. + +## Storage and older exports + +Discovery reads `$CODEX_HOME/sessions`, `$CODEX_HOME/archived_sessions` (default +`~/.codex`) and `$CLAUDE_CONFIG_DIR/projects` (default `~/.claude`). Compressed +`.jsonl.zst` rollouts must be decompressed to a local file first and opened with +`--file`. The server is bound to 127.0.0.1, uses an unguessable path, rejects foreign +Host/Origin requests, and serves only its three assets and selected transcript. + +For standalone snapshots, the existing +[Codex](view-codex-session.md) and [Claude](view-claude-session.md) exporters remain +available. A snapshot does not update. diff --git a/docs/view-claude-session.md b/docs/view-claude-session.md index a23949e92..c0ab51fc3 100644 --- a/docs/view-claude-session.md +++ b/docs/view-claude-session.md @@ -1,5 +1,10 @@ # View a Claude Code session started by Buzz +For the live viewer, run `bin/node scripts/session-debug.mjs ''` +from the repository root. See [session debugging](session-debugging.md) for wall +time, token use and live updates. The commands below remain useful for +standalone snapshots and manual transcript inspection. + This guide is for agents investigating a Buzz conversation. All commands run non-interactively and read the saved transcript without invoking Claude or tools. diff --git a/docs/view-codex-session.md b/docs/view-codex-session.md index c5b47ae74..00ed57a45 100644 --- a/docs/view-codex-session.md +++ b/docs/view-codex-session.md @@ -1,5 +1,10 @@ # View a Codex session started by Buzz +For the live viewer, run `bin/node scripts/session-debug.mjs ''` +from the repository root. See [session debugging](session-debugging.md) for wall +time, token use and live updates. The commands below remain useful for +standalone snapshots and manual transcript inspection. + This guide is for agents investigating a Buzz conversation. All commands run non-interactively and read the saved transcript without invoking Codex or tools. diff --git a/scripts/export-claude-session.mjs b/scripts/export-claude-session.mjs index b1d49a3ec..c7251b9e8 100644 --- a/scripts/export-claude-session.mjs +++ b/scripts/export-claude-session.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { pathToFileURL } from "node:url"; import { entry, exportSession } from "./session-viewer.mjs"; // Tool results are a string or a list of text/image content blocks. @@ -16,7 +17,7 @@ const target = (input) => .map((key) => input?.[key]) .find((value) => typeof value === "string") ?? ""; -exportSession((records) => { +export function buildClaude(records) { const messages = records.filter((record) => record?.message != null); const results = new Map( messages.flatMap((record) => @@ -60,6 +61,12 @@ exportSession((records) => { else if (block?.type === "thinking") { if (block.thinking?.trim()) entries.push(entry.thinking(timestamp, block.thinking.trim())); + } else if ( + block?.type === "tool_use" && + /(^|__)buzz__send$/.test(block.name) && + typeof block.input?.text === "string" + ) { + entries.push(entry.sent(timestamp, block.input.text)); } else if (block?.type === "tool_use") { const result = results.get(block.id); entries.push( @@ -88,4 +95,7 @@ exportSession((records) => { ], entries, }; -}); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) + exportSession(buildClaude); diff --git a/scripts/export-codex-session.mjs b/scripts/export-codex-session.mjs index 1761c48a0..4387c51af 100644 --- a/scripts/export-codex-session.mjs +++ b/scripts/export-codex-session.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { pathToFileURL } from "node:url"; import { entry, exportSession, text } from "./session-viewer.mjs"; const contentText = (content) => @@ -27,7 +28,15 @@ function buzzRequest(value) { } } -exportSession((records) => { +function jsonString(literal) { + try { + return JSON.parse(literal); + } catch { + return undefined; + } +} + +export function buildCodex(records) { const meta = records.find((record) => record?.type === "session_meta")?.payload ?? {}; const outputs = new Map( @@ -67,19 +76,36 @@ exportSession((records) => { if (summary) entries.push(entry.thinking(timestamp, summary)); } else if (item.call_id && !item.type.endsWith("_output")) { const input = item.input ?? item.arguments ?? item.action ?? ""; - // Code Mode runs nested tools from an `exec` script; name them. - const nested = new Set( - [...text(input).matchAll(/tools\.([A-Za-z0-9_]+)/g)].map((m) => m[1]), - ); const output = outputs.get(item.call_id); - entries.push( + const tool = (name) => entry.tool(timestamp, { - name: item.name || item.type, - detail: [...nested].join(", "), + name, input, result: output && outputText(output.output), - }), - ); + }); + // Code Mode runs nested tools from an `exec` script: name the calls in + // order, and show each Buzz send as the message it posted. + let group = []; + const flush = () => { + if (group.length) entries.push(tool([...new Set(group)].join(", "))); + group = []; + }; + const calls = [ + ...text(input).matchAll( + /tools\.([A-Za-z0-9_]+)\(\s*(?:\{\s*"?text"?\s*:\s*("(?:[^"\\]|\\.)*"))?/g, + ), + ]; + for (const [, name, literal] of calls) { + const body = name === "buzz__send" && literal && jsonString(literal); + if (body === undefined || body === false || body === "") + group.push(name); + else { + flush(); + entries.push(entry.sent(timestamp, body)); + } + } + flush(); + if (!calls.length) entries.push(tool(item.name || item.type)); } else if (!item.type.endsWith("_output")) { const { encrypted_content: _, ...rest } = item; entries.push(entry.other(timestamp, item.type, rest)); @@ -95,4 +121,7 @@ exportSession((records) => { ], entries, }; -}); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) + exportSession(buildCodex); diff --git a/scripts/session-debug-client.js b/scripts/session-debug-client.js new file mode 100644 index 000000000..3e8851a84 --- /dev/null +++ b/scripts/session-debug-client.js @@ -0,0 +1,608 @@ +const $ = (id) => document.getElementById(id); +const htmlEscape = (value) => + String(value ?? "").replace( + /[&<>"']/g, + (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ + c + ], + ); +const number = (n) => Number(n ?? 0).toLocaleString(); +const money = (n) => `$${n.toFixed(4)}`; +let state, + signature = "", + pending = false; +const duration = (ms) => { + const s = Math.max(0, ms) / 1000; + if (s < 1) return `${Math.round(s * 1000)}ms`; + if (s < 60) return `${s.toFixed(s < 10 ? 2 : 1)}s`; + return `${Math.floor(s / 60)}m ${Math.floor(s % 60)}s`; +}; +const compact = (n) => + n >= 1e6 + ? `${(n / 1e6).toFixed(n < 1e7 ? 2 : 1)}M` + : n >= 1000 + ? `${(n / 1000).toFixed(n < 10000 ? 1 : 0)}k` + : String(n); +// Dense duration for the summary line: 0.3s · 47s · 2m43 · 25m · 1h12. +const brief = (ms) => { + const s = Math.max(0, ms) / 1000; + if (s < 10) return `${s.toFixed(1)}s`; + if (s < 60) return `${Math.round(s)}s`; + if (s < 600) + return `${Math.floor(s / 60)}m${String(Math.floor(s % 60)).padStart(2, "0")}`; + if (s < 3600) return `${Math.round(s / 60)}m`; + return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}`; +}; +const clock = (ms) => new Date(ms).toISOString().slice(11, 19); +const kindNames = { + inference: "Inference", + tool: "Tools", + overlap: "Inference + tool", + unknown: "Unattributed", + idle: "Idle", +}; +const kindNotes = { + unknown: "Run active, but no model or tool activity recorded", + idle: "No run active; waiting for the next prompt", +}; +// One vertical time axis for the whole session. Active time is proportional; +// idle waits between runs collapse to a fixed-height break so a 25-minute pause +// can't flatten seconds of work. Each painted segment remembers which +// inference/tool items were active in it, for the hover readout. +const IDLE_HEIGHT = 30; +const PAD = 14; +const MIN_SCALE = 0.00225; +let chart = { segments: [], marks: [] }, + chartHtml = ""; +function runSegments(run) { + const items = run.items.filter( + (i) => (i.kind === "inference" || i.kind === "tool") && i.end > i.start, + ); + const edges = [ + ...new Set([run.start, run.end, ...items.flatMap((i) => [i.start, i.end])]), + ] + .filter((t) => t >= run.start && t <= run.end) + .sort((a, b) => a - b); + const out = []; + for (let k = 1; k < edges.length; k++) { + const a = edges[k - 1], + b = edges[k]; + const active = items.filter((i) => i.start < b && i.end > a); + const inference = active.some((i) => i.kind === "inference"); + const tool = active.some((i) => i.kind === "tool"); + const kind = + inference && tool + ? "overlap" + : inference + ? "inference" + : tool + ? "tool" + : "unknown"; + const key = active.map((i) => i.id).join("|"); + const last = out.at(-1); + if (last && last.key === key && last.end === a) last.end = b; + else out.push({ kind, key, start: a, end: b, items: active, run }); + } + return out; +} +// Min-height segments inflate the layout, so refit once with the overflow removed. +function layoutChart(timing, height) { + const first = layoutAt(timing, height, null); + if (first.height <= height || first.scale <= MIN_SCALE) return first; + return layoutAt( + timing, + height, + first.scale * + (1 - (first.height - height) / Math.max(1, first.height - first.fixed)), + ); +} +function layoutAt(timing, height, fitted) { + const perRun = timing.runs.map((run) => ({ run, segs: runSegments(run) })); + const activeMs = timing.runs.reduce((n, r) => n + (r.end - r.start), 0); + const breaks = timing.runs.filter( + (r, i) => i && r.start > timing.runs[i - 1].end, + ).length; + const fixed = PAD * 2 + breaks * IDLE_HEIGHT; + // px per ms: fit the active time to the rail, within readable bounds. + const scale = Math.max( + MIN_SCALE, + fitted ?? + Math.min(0.06, Math.max(240, height - fixed) / Math.max(1, activeMs)), + ); + const segments = [], + marks = [], + runs = []; + let y = PAD; + for (const [index, { run, segs }] of perRun.entries()) { + const previous = timing.runs[index - 1]; + if (previous && run.start > previous.end) { + segments.push({ + kind: "idle", + start: previous.end, + end: run.start, + items: [], + run, + y, + h: IDLE_HEIGHT, + }); + y += IDLE_HEIGHT; + } + const top = y; + for (const s of segs) { + s.y = y; + s.h = Math.max(3, (s.end - s.start) * scale); + segments.push(s); + y += s.h; + } + if (segs.length) { + segs[0].cap = "cap-top"; + segs.at(-1).cap = `${segs.length === 1 ? "cap-top " : ""}cap-bottom`; + } + runs.push({ run, y: top, h: y - top }); + for (const item of run.items) + if (item.kind === "inference") { + const at = + segs.find((s) => item.start >= s.start && item.start < s.end) ?? + segs.at(-1); + marks.push({ + item, + run, + y: at + ? at.y + + ((item.start - at.start) / Math.max(1, at.end - at.start)) * at.h + : top, + }); + } + } + return { segments, marks, runs, height: y + PAD, scale, fixed }; +} +const context = (t) => t.fresh + t.cached + t.written; +function renderSummary(timing) { + const tokens = { fresh: 0, cached: 0, written: 0, output: 0 }; + let inferences = 0, + peak = 0; + for (const run of timing.runs) + for (const i of run.items) + if (i.kind === "inference") { + inferences++; + peak = Math.max(peak, context(i.tokens)); + for (const k of Object.keys(tokens)) tokens[k] += i.tokens[k]; + } + const costs = state.turns.map((t) => t.cost ?? t.estimate); + const cost = costs.some((c) => c != null) + ? costs.reduce((a, c) => a + (c ?? 0), 0) + : null; + const input = context(tokens); + // Sub-second overlap stays in the tooltip; it would only crowd the line. + const parts = ["inference", "tool", "overlap", "unknown", "idle"] + .filter( + (k) => + timing.totals[k] >= (k === "overlap" ? 1000 : 1) || + k === "inference" || + k === "tool", + ) + .map( + (k) => + `${brief(timing.totals[k])}`, + ) + .join(""); + const tokenTitle = `${number(tokens.fresh)} new input · ${number(tokens.cached)} cache read · ${number(tokens.written)} cache write · ${number(tokens.output)} output · ${inferences} model calls · peak context ${number(peak)}`; + return `
${brief(timing.end - timing.start)}${parts}
${compact(input + tokens.output)} tokens · ${input ? Math.round((tokens.cached / input) * 100) : 0}% cached${cost == null ? "" : ` · ${money(cost)}`}
`; +} +function renderTiming(timing, force = false) { + if (!timing?.runs.length) { + $("summary").textContent = "Waiting for timing records…"; + $("timeline").replaceChildren(); + chartHtml = ""; + return; + } + const summary = renderSummary(timing); + if ($("summary").innerHTML !== summary) $("summary").innerHTML = summary; + const scroller = document.querySelector(".timeline-scroll"); + const scroll = scroller.scrollTop; + const atEnd = scroll + scroller.clientHeight >= scroller.scrollHeight - 40; + // Lay out 1.5× the visible height: taller segments are easier to hover, at + // the cost of some scrolling. + chart = layoutChart(timing, scroller.clientHeight * 1.5); + // Marks show each call's new (uncached) tokens; the full window is on hover. + const fresh = (t) => t.fresh + t.written; + const maxFresh = Math.max(1, ...chart.marks.map((m) => fresh(m.item.tokens))); + const last = chart.segments.at(-1); + const html = `
${chart.runs + .map( + (r) => + `${clock(r.run.start).slice(0, 5)}`, + ) + .join("")}${chart.segments + .map((s, i) => + s.kind === "idle" + ? `` + : ``, + ) + .join("")}${chart.marks + .map( + (m, i) => + ``, + ) + .join("")}
`; + if (force || chartHtml !== html) { + $("timeline").innerHTML = html; + chartHtml = html; + } + if (atEnd) scroller.scrollTop = scroller.scrollHeight; + else scroller.scrollTop = scroll; +} +function tipRow(color, value, label, detail) { + const row = document.createElement("div"); + row.className = "tip-row"; + const key = document.createElement("i"); + key.className = `key key-${color}`; + const strong = document.createElement("strong"); + strong.textContent = value; + const name = document.createElement("span"); + name.textContent = label; + row.append(key, strong, name); + if (detail) { + const small = document.createElement("small"); + small.textContent = detail; + row.append(small); + } + return row; +} +function inferenceRow(item) { + return tipRow( + "inference", + item.estimated ? duration(item.end - item.start) : "untimed", + "Inference", + ); +} +// Token mark readout: the whole context window as one bar (cached prefix, +// then this call's new tokens), with new/out counts beneath. +function contextReadout(item) { + const t = item.tokens; + const size = context(t); + const added = t.fresh + t.written; + const bar = document.createElement("div"); + bar.className = "ctx-bar"; + const cached = document.createElement("i"); + cached.className = "ctx-cached"; + cached.style.width = `${size ? (t.cached / size) * 100 : 0}%`; + const fresh = document.createElement("i"); + fresh.className = "ctx-new"; + fresh.style.width = `${size ? (added / size) * 100 : 0}%`; + bar.append(cached, fresh); + const stats = document.createElement("div"); + stats.className = "ctx-stats"; + const stat = (value, label, className) => { + const span = document.createElement("span"); + if (className) span.className = className; + const strong = document.createElement("strong"); + strong.textContent = value; + span.append(strong, ` ${label}`); + return span; + }; + stats.append( + stat(`+${compact(added)}`, "new"), + stat(compact(t.output), "out"), + ); + if (item.estimate != null) stats.append(stat(money(item.estimate), "")); + const total = document.createElement("span"); + total.className = "ctx-total"; + total.textContent = compact(size); + total.title = `${number(t.cached)} cached · ${number(t.fresh)} input · ${number(t.written)} cache write`; + stats.append(total); + return [bar, stats]; +} +function showTip(target, x, y) { + const tip = $("tip"); + const seg = chart.segments[target.dataset.seg]; + const mark = chart.marks[target.dataset.mark]; + if (!seg && !mark) return hideTip(); + if (mark) tip.replaceChildren(...contextReadout(mark.item)); + else { + const head = document.createElement("div"); + head.className = "tip-head"; + head.textContent = + seg.kind === "idle" + ? `${clock(seg.start)} – ${clock(seg.end)}` + : clock(seg.start); + const rows = seg.items.length + ? seg.items.map((item) => + item.kind === "inference" + ? inferenceRow(item) + : tipRow( + "tool", + item.open ? "running" : duration(item.end - item.start), + item.name ?? "tool", + ), + ) + : [ + tipRow( + seg.kind, + duration(seg.end - seg.start), + kindNames[seg.kind], + kindNotes[seg.kind], + ), + ]; + tip.replaceChildren(head, ...rows); + } + tip.classList.toggle("compact", !!mark); + tip.hidden = false; + const box = tip.getBoundingClientRect(); + tip.style.left = `${Math.min(innerWidth - box.width - 8, x + 16)}px`; + tip.style.top = `${Math.max(8, Math.min(innerHeight - box.height - 8, y - box.height / 2))}px`; +} +function hideTip() { + $("tip").hidden = true; +} +// Jump the conversation to the record nearest the hovered activity. +function jumpTo(target) { + const seg = chart.segments[target.dataset.seg]; + const item = + chart.marks[target.dataset.mark]?.item ?? + seg?.items.find((i) => i.kind === "tool") ?? + seg?.items[0]; + const at = item + ? item.kind === "inference" + ? item.end + : item.start + : seg?.start; + if (at == null || !state?.entries) return; + let best = null; + for (const e of state.entries) { + if (e.kind === "instructions") continue; + const node = document.getElementById(`row-${e.id}`); + const t = Date.parse(e.timestamp); + if (!node || !Number.isFinite(t)) continue; + if (!best || Math.abs(t - at) < Math.abs(best.t - at)) best = { node, t }; + } + if (!best) return; + best.node.scrollIntoView({ block: "center", behavior: "smooth" }); + best.node.classList.remove("flash"); + void best.node.offsetWidth; + best.node.classList.add("flash"); +} +// Hover targets: a context mark, or the bar column widened by 8px each side so +// 2px segments don't need precise aim. Elsewhere on the rail shows nothing. +let hot = null; +function targetAt(event) { + const mark = event.target.closest("[data-mark]"); + if (mark) return mark; + const bar = $("timeline").querySelector(".seg"); + if (!bar) return null; + const box = bar.getBoundingClientRect(); + const pad = 8; + if (event.clientX < box.left - pad || event.clientX > box.right + pad) + return event.target.closest(".idle-break"); + const y = event.clientY - $("timeline").getBoundingClientRect().top; + const index = chart.segments.findIndex((s) => y >= s.y && y < s.y + s.h); + return $("timeline").querySelector(`[data-seg="${index}"]`); +} +function setHot(node) { + if (hot === node) return; + hot?.classList.remove("hot"); + hot = node; + hot?.classList.add("hot"); +} +$("timeline").addEventListener("pointermove", (event) => { + const target = targetAt(event); + setHot(target); + if (target) showTip(target, event.clientX, event.clientY); + else hideTip(); +}); +$("timeline").addEventListener("pointerleave", () => { + setHot(null); + hideTip(); +}); +$("timeline").addEventListener("focusin", (event) => { + const target = event.target.closest("[data-seg],[data-mark]"); + if (!target) return; + const box = target.getBoundingClientRect(); + showTip(target, box.right, box.top + box.height / 2); +}); +$("timeline").addEventListener("focusout", hideTip); +$("timeline").addEventListener("click", (event) => { + const target = targetAt(event); + if (target) jumpTo(target); +}); +// Minimap viewport: the conversation's on-screen time range as a band on the +// rail, like an editor scrollbar map. An entry spans until the next one starts; +// each band edge interpolates within the entry it crosses, so it glides with +// the scroll instead of stepping entry by entry. +function timeToY(t) { + const segs = chart.segments; + if (!segs.length) return null; + if (t <= segs[0].start) return segs[0].y; + for (const s of segs) + if (t < s.end) + return t <= s.start + ? s.y + : s.y + ((t - s.start) / Math.max(1, s.end - s.start)) * s.h; + const last = segs.at(-1); + return last.y + last.h; +} +function visibleEntries(top, bottom) { + const rows = []; + for (const node of $("content").children) { + const t = Date.parse(node.dataset.time); + if (!Number.isFinite(t)) continue; + const box = node.getBoundingClientRect(); + rows.push({ t, top: box.top, bottom: box.bottom }); + if (box.top > bottom) break; + } + const first = rows.findLastIndex((row) => row.top <= top); + return rows.slice(Math.max(0, first)); +} +function timeAtLine(rows, line) { + const end = chart.segments.at(-1)?.end ?? rows.at(-1).t; + let i = rows.findLastIndex((row) => row.top <= line); + if (i < 0) i = 0; + const row = rows[i], + next = rows[i + 1]; + const to = next ? next.t : Math.max(row.t, end); + const span = (next ? next.top : row.bottom) - row.top; + const share = + span > 0 ? Math.min(1, Math.max(0, (line - row.top) / span)) : 0; + return row.t + share * Math.max(0, to - row.t); +} +function updateViewport(reveal) { + const band = document.querySelector(".chart .viewport"); + if (!band) return; + const top = document.querySelector(".toolbar").getBoundingClientRect().bottom; + const rows = visibleEntries(top, innerHeight); + band.hidden = !rows.length || !chart.segments.length; + if (band.hidden) return; + const y0 = timeToY(timeAtLine(rows, top)), + y1 = Math.max(y0, timeToY(timeAtLine(rows, innerHeight))); + band.style.top = `${y0 - 3}px`; + band.style.height = `${Math.max(6, y1 - y0 + 6)}px`; + if (!reveal) return; + // Follow continuously: keep the band inside the rail with a margin, moving + // the rail only as far as the band itself moved past that margin. + const scroller = document.querySelector(".timeline-scroll"); + const margin = Math.min(48, scroller.clientHeight / 6); + const view = scroller.clientHeight - 2 * margin; + if (y1 - y0 > view || y0 < scroller.scrollTop + margin) + scroller.scrollTop = y0 - margin; + else if (y1 > scroller.scrollTop + scroller.clientHeight - margin) + scroller.scrollTop = y1 - scroller.clientHeight + margin; +} +let viewportFrame = 0; +addEventListener( + "scroll", + () => { + if (viewportFrame) return; + viewportFrame = requestAnimationFrame(() => { + viewportFrame = 0; + updateViewport(true); + }); + }, + { passive: true }, +); +addEventListener( + "resize", + () => state?.timing && renderTiming(state.timing, true), +); + +function render() { + if (!state || state.waiting) { + $("content").textContent = state?.message ?? "Loading…"; + return; + } + const nearBottom = + window.scrollY + innerHeight >= document.documentElement.scrollHeight - 100; + const open = new Set( + [...document.querySelectorAll("details[open]")].map((d) => d.dataset.key), + ); + const focused = document.activeElement; + const focusedKey = focused?.closest("details")?.dataset.key; + const scroll = window.scrollY; + const query = $("search").value.toLowerCase(); + const { harness, model, effort } = state.header ?? {}; + const scope = `${htmlEscape(harness)}${model ? `${htmlEscape(model)}` : ""}${effort ? `${htmlEscape(effort)}` : ""}`; + if ($("scope").innerHTML !== scope) $("scope").innerHTML = scope; + $("scope").title = state.sessionId ?? ""; + renderTiming(state.timing); + const rows = state.entries + .filter( + (e) => + e.kind !== "instructions" && + `${e.label} ${e.preview} ${e.html}`.toLowerCase().includes(query), + ) + .map((e) => ({ + id: e.id, + time: e.timestamp, + html: `${e.kind === "tool" ? "" : `

${htmlEscape(e.label)}

`}${e.html}`, + })); + // Reconcile entries by stable transcript position: live polling must not close + // a disclosure, reset selection, or steal focus while the person reads. + const container = $("content"); + const existing = new Map([...container.children].map((n) => [n.id, n])); + let previous = null; + for (const row of rows) { + const id = `row-${row.id}`; + let node = existing.get(id); + if (!node) { + node = document.createElement("section"); + node.className = "entry"; + node.id = id; + } + if (row.time) node.dataset.time = row.time; + else delete node.dataset.time; + if (node.dataset.html !== row.html) { + node.innerHTML = row.html; + node.dataset.html = row.html; + node.querySelectorAll("details").forEach((d, i) => { + d.dataset.key ||= `${id}-${i}`; + d.open = open.has(d.dataset.key); + }); + } + if ( + previous + ? previous.nextElementSibling !== node + : container.firstElementChild !== node + ) + container.insertBefore( + node, + previous ? previous.nextElementSibling : container.firstElementChild, + ); + previous = node; + existing.delete(id); + } + for (const node of existing.values()) node.remove(); + if (!rows.length) { + const empty = document.createElement("p"); + empty.className = "empty"; + empty.textContent = "No matching records."; + container.append(empty); + } + if (focusedKey && !document.contains(focused)) + [...container.querySelectorAll("details")] + .find((d) => d.dataset.key === focusedKey) + ?.querySelector("summary") + ?.focus({ preventScroll: true }); + if (nearBottom) window.scrollTo(0, document.documentElement.scrollHeight); + else window.scrollTo(0, scroll); + updateViewport(false); +} +$("search").addEventListener("input", render); +$("theme").addEventListener("click", () => { + document.documentElement.dataset.theme = + (document.documentElement.dataset.theme || + (matchMedia("(prefers-color-scheme:dark)").matches + ? "dark" + : "light")) === "dark" + ? "light" + : "dark"; +}); +function setStatus(kind, text, detail) { + const status = $("status"); + status.dataset.state = kind; + status.title = detail; + if (status.textContent !== text) status.textContent = text; +} +async function poll() { + if (pending) return; + pending = true; + try { + const response = await fetch(new URL("state", location.href), { + cache: "no-store", + }); + const next = await response.json(); + if (!response.ok) throw new Error(next.error ?? "Could not read session"); + const nextSignature = JSON.stringify(next); + setStatus("live", "Live", `Checked ${new Date().toLocaleTimeString()}`); + if (signature !== nextSignature) { + state = next; + signature = nextSignature; + render(); + } + } catch (error) { + setStatus("error", "Reconnecting…", error.message); + } finally { + pending = false; + } +} +await poll(); +setInterval(poll, 1000); diff --git a/scripts/session-debug.css b/scripts/session-debug.css new file mode 100644 index 000000000..26d0743f0 --- /dev/null +++ b/scripts/session-debug.css @@ -0,0 +1,778 @@ +/* Standalone diagnostic renderer; no application host or React runtime. One + quiet surface: a tinted rail for the wall-time chart beside a borderless + conversation column. Chart hues are categorical slots 1–3 of the dataviz + reference palette (validated in light and dark); unattributed and idle stay + neutral so they never read as work. */ +:root { + color-scheme: light dark; + --base: #fafaf9; + --rail: #f4f4f3; + --panel: #fff; + --inset: #ebebea; + --text: #1c1c1c; + --muted: #62625f; + --faint: #9a9a96; + --line: #e4e4e2; + --accent: #457b9d; + --hover: #ededec; + --error: #e63946; + --shadow: 0 8px 28px rgb(20 20 18 / 0.12), 0 1px 3px rgb(20 20 18 / 0.08); + --k-inference: #457b9d; + --k-tool: #1d3557; + --k-unknown: #a1a19d; + --k-cached: #a8dadc; + --mono: ui-monospace, "SF Mono", Menlo, monospace; + font: + 100% / 1.5 system-ui, + -apple-system, + sans-serif; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --base: #111111; + --rail: #161616; + --panel: #1d1d1d; + --inset: #262626; + --text: #ececea; + --muted: #a3a3a0; + --faint: #6e6e6b; + --line: #2a2a2a; + --accent: #a8dadc; + --hover: #262626; + --error: #e63946; + --shadow: 0 8px 28px rgb(0 0 0 / 0.5), 0 0 0 1px rgb(255 255 255 / 0.06); + --k-inference: #a8dadc; + --k-tool: #457b9d; + --k-unknown: #6e6e6b; + --k-cached: #2f4f68; + } +} +:root[data-theme="dark"] { + color-scheme: dark; + --base: #111111; + --rail: #161616; + --panel: #1d1d1d; + --inset: #262626; + --text: #ececea; + --muted: #a3a3a0; + --faint: #6e6e6b; + --line: #2a2a2a; + --accent: #a8dadc; + --hover: #262626; + --error: #e63946; + --shadow: 0 8px 28px rgb(0 0 0 / 0.5), 0 0 0 1px rgb(255 255 255 / 0.06); + --k-inference: #a8dadc; + --k-tool: #457b9d; + --k-unknown: #6e6e6b; + --k-cached: #2f4f68; +} +:root[data-theme="light"] { + color-scheme: light; +} +* { + box-sizing: border-box; +} +body { + margin: 0; + background: var(--base); + color: var(--text); + font-size: 0.875rem; + -webkit-font-smoothing: antialiased; +} +input, +button { + font: inherit; + color: inherit; +} +button, +summary { + cursor: pointer; +} +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.workspace { + display: grid; + grid-template-columns: 16rem minmax(0, 1fr); + min-height: 100dvh; +} + +/* ── Rail ─────────────────────────────────────────────────────────────── */ +aside { + display: flex; + flex-direction: column; + min-width: 0; + height: 100dvh; + position: sticky; + top: 0; + background: var(--rail); + border-right: 1px solid var(--line); +} +.rail-head { + padding: 0.875rem 1rem 0.625rem; + font-size: 0.75rem; + color: var(--muted); + font-variant-numeric: tabular-nums; +} +.rail-title { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.625rem; +} +.rail-title strong { + color: var(--text); + font-size: 0.8125rem; + font-weight: 600; +} +#status { + display: inline-flex; + align-items: center; + gap: 0.375rem; + font-size: 0.6875rem; + color: var(--faint); +} +#status::before { + content: ""; + width: 0.375rem; + height: 0.375rem; + border-radius: 50%; + background: var(--faint); +} +#status[data-state="live"]::before { + background: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); +} +#status[data-state="error"] { + color: var(--error); +} +#status[data-state="error"]::before { + background: var(--error); +} +.rail-total { + display: flex; + align-items: baseline; + gap: 0.5rem; + white-space: nowrap; +} +.rail-total strong { + color: var(--text); + font-size: 1.0625rem; + font-weight: 600; + letter-spacing: -0.01em; + margin-right: 0.125rem; +} +.rail-total span { + display: inline-flex; + align-items: center; + gap: 0.25rem; + color: var(--text); + cursor: help; +} +.key { + display: inline-block; + flex-shrink: 0; + width: 0.4375rem; + height: 0.4375rem; + border-radius: 2px; +} +.key-inference { + background: var(--k-inference); +} +.key-tool { + background: var(--k-tool); +} +/* Overlap is both at once: stripes of the two hues, not a third color. */ +.key-overlap { + background: repeating-linear-gradient( + 135deg, + var(--k-inference) 0 2px, + var(--k-tool) 2px 4px + ); +} +.key-unknown { + background: repeating-linear-gradient( + 135deg, + var(--k-unknown) 0 1.5px, + transparent 1.5px 3.5px + ); +} +.key-idle { + background: none; + border: 1.5px dotted var(--faint); + border-radius: 50%; +} +.rail-tokens { + margin-top: 0.25rem; + cursor: help; +} +.rail-tokens strong { + color: var(--text); + font-weight: 600; +} +.timeline-scroll { + position: relative; + overflow: auto; + min-height: 0; + flex: 1; + scrollbar-width: none; + border-top: 1px solid var(--line); +} +.timeline-scroll:focus-visible { + outline-offset: -2px; +} + +/* The chart: time label · bar · context marks. */ +.chart { + position: relative; + --bar-left: 3.25rem; + --bar-width: 0.5rem; + --tok-left: 4.5rem; + --tok-room: calc(100% - var(--tok-left) - 1rem); +} +.run-label { + position: absolute; + left: 0; + width: calc(var(--bar-left) - 0.5rem); + text-align: right; + font-size: 0.6875rem; + line-height: 1; + color: var(--faint); + font-variant-numeric: tabular-nums; +} +.run-track { + position: absolute; + left: var(--bar-left); + width: var(--bar-width); + border-radius: 999px; + background: var(--inset); +} +.seg, +.idle-break { + position: absolute; + padding: 0; + border: 0; + margin: 0; + cursor: inherit; +} +.seg { + left: var(--bar-left); + width: var(--bar-width); + transition: + transform 0.12s ease, + filter 0.12s ease; +} +.seg.cap-top { + border-top-left-radius: 999px; + border-top-right-radius: 999px; +} +.seg.cap-bottom { + border-bottom-left-radius: 999px; + border-bottom-right-radius: 999px; +} +.seg.key-unknown { + background: repeating-linear-gradient( + 135deg, + color-mix(in srgb, var(--k-unknown) 75%, transparent) 0 1.5px, + transparent 1.5px 4px + ); +} +.seg.hot, +.seg:focus-visible { + transform: scaleX(1.75); + filter: saturate(1.15) brightness(1.08); + z-index: 1; +} +.seg:focus-visible { + outline-offset: 3px; +} +.seg.live { + animation: pulse 1.6s ease-in-out infinite; +} +@keyframes pulse { + 50% { + opacity: 0.4; + } +} +.idle-break { + left: 0; + right: 0; + background: none; + display: flex; + align-items: center; + color: var(--faint); + font-size: 0.6875rem; + font-variant-numeric: tabular-nums; +} +.idle-break::before { + content: ""; + position: absolute; + left: calc(var(--bar-left) + var(--bar-width) / 2 - 1px); + top: 6px; + bottom: 6px; + border-left: 2px dotted color-mix(in srgb, var(--faint) 70%, transparent); +} +.idle-break span { + margin-left: var(--tok-left); +} +.idle-break.hot, +.idle-break:hover { + color: var(--muted); +} +/* Context window readout: cached prefix + this call's new tokens. */ +.ctx-bar { + display: flex; + width: 12rem; + height: 0.375rem; + border-radius: 999px; + overflow: hidden; + background: var(--inset); + margin: 0.125rem 0 0.375rem; +} +.ctx-bar i { + display: block; + height: 100%; +} +.ctx-bar .ctx-cached { + background: var(--k-cached); +} +.ctx-bar .ctx-new { + background: var(--k-inference); + min-width: 2px; +} +.ctx-stats { + display: flex; + gap: 0.625rem; + color: var(--muted); + font-variant-numeric: tabular-nums; +} +.ctx-stats strong { + color: var(--text); + font-weight: 600; +} +.ctx-stats .ctx-total { + margin-left: auto; + color: var(--faint); +} +/* New (uncached) tokens per call; an 8px transparent hit box around a 2px line. */ +.tok { + position: absolute; + left: var(--tok-left); + width: calc(var(--tok-room) * var(--share)); + min-width: 3px; + height: 8px; + margin-top: -3px; + padding: 3px 0; + background: none; + border: 0; + cursor: pointer; +} +.tok i { + display: block; + height: 2px; + border-radius: 1px; + background: color-mix(in srgb, var(--k-inference) 70%, var(--rail)); +} +.tok.hot i { + background: var(--k-inference); + height: 3px; + margin-top: -0.5px; +} +@media (prefers-reduced-motion: reduce) { + .seg { + transition: none; + } + .seg.live { + animation: none; + } +} + +/* Viewport band: the conversation's visible range, minimap style. */ +.viewport { + position: absolute; + left: 0.375rem; + right: 0.375rem; + min-height: 6px; + border-radius: 0.375rem; + background: color-mix(in srgb, var(--text) 6%, transparent); + pointer-events: none; +} +#tip.compact { + min-width: 0; +} + +/* Tooltip: values lead, labels follow; keys are short strokes. */ +#tip { + position: fixed; + z-index: 10; + min-width: 11rem; + max-width: 20rem; + padding: 0.5rem 0.75rem 0.625rem; + border-radius: 0.625rem; + background: color-mix(in srgb, var(--panel) 92%, transparent); + backdrop-filter: blur(12px); + box-shadow: var(--shadow); + font-size: 0.75rem; + pointer-events: none; +} +.tip-head { + color: var(--faint); + font-size: 0.6875rem; + margin-bottom: 0.375rem; + font-variant-numeric: tabular-nums; +} +.tip-row { + display: grid; + grid-template-columns: 0.625rem auto 1fr; + column-gap: 0.5rem; + align-items: center; +} +.tip-row + .tip-row { + margin-top: 0.375rem; +} +.tip-row .key { + width: 0.625rem; + height: 2px; + border: 0; + border-radius: 1px; +} +.tip-row .key-unknown { + height: 0.5rem; +} +.tip-row .key-idle { + height: 0; + border-top: 2px dotted var(--faint); + background: none; +} +.tip-row strong { + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.tip-row span { + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tip-row small { + grid-column: 2 / -1; + color: var(--muted); + font-size: 0.6875rem; + font-variant-numeric: tabular-nums; +} + +/* ── Conversation ─────────────────────────────────────────────────────── */ +main { + min-width: 0; + width: 100%; + max-width: 60rem; + margin: 0 auto; + padding: 0 2rem 4rem; +} +.toolbar { + display: flex; + gap: 0.5rem; + align-items: center; + position: sticky; + top: 0; + z-index: 3; + margin: 0 -0.5rem 0.5rem; + padding: 0.75rem 0.5rem; + background: color-mix(in srgb, var(--base) 86%, transparent); + backdrop-filter: blur(10px); +} +#search, +.toolbar button { + height: 2rem; + border: 1px solid var(--line); + border-radius: 0.5rem; + background: var(--panel); + font-size: 0.8125rem; +} +/* Compact until used: focus or a query widens it toward the session line. */ +#search { + flex: 0 1 10rem; + min-width: 6rem; + padding: 0 0.75rem; + transition: flex-basis 0.18s ease-out; +} +#search:focus, +#search:not(:placeholder-shown) { + flex-basis: 26rem; +} +#search:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); +} +@media (prefers-reduced-motion: reduce) { + #search { + transition: none; + } +} +#search::placeholder { + color: var(--faint); +} +.toolbar button:hover { + background: var(--hover); +} +#theme { + flex: none; + width: 2rem; + padding: 0; + color: var(--muted); +} +/* Session identity: harness, then model and effort as quiet chips. */ +#scope { + display: flex; + flex: 1; + align-items: center; + gap: 0.375rem; + min-width: 0; + margin: 0; + overflow: hidden; + white-space: nowrap; + font-size: 0.75rem; + color: var(--muted); +} +#scope strong { + color: var(--text); + font-weight: 600; + margin-right: 0.25rem; +} +#scope .chip { + padding: 0.0625rem 0.5rem; + border-radius: 999px; + background: var(--inset); + font: 0.6875rem / 1.6 var(--mono); +} +#scope .effort { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} +details.tool .status { + color: var(--error); + font-family: system-ui, sans-serif; +} +.muted { + color: var(--muted); +} +.accent { + color: var(--muted); +} +.entry { + padding: 0.375rem 0; + scroll-margin-top: 5rem; + overflow-wrap: anywhere; + border-radius: 0.5rem; +} +.entry h2 { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0.875rem 0 0.25rem; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); +} +.entry h2::before { + content: ""; + width: 0.375rem; + height: 0.375rem; + border-radius: 50%; + background: var(--k-inference); +} +.entry:has(.user) h2::before { + background: var(--text); +} +.entry:has(.thinking) h2::before { + background: var(--faint); +} +.time { + font-size: 0.6875rem; + color: var(--faint); + font-variant-numeric: tabular-nums; + margin-bottom: 0.25rem; +} +pre { + font: 0.75rem / 1.55 var(--mono); + white-space: pre-wrap; + overflow-wrap: anywhere; + margin: 0.375rem 0; +} +/* Prose reads as prose; code and payloads stay monospace. */ +.assistant > pre, +.user > pre { + font: + 0.875rem / 1.6 system-ui, + -apple-system, + sans-serif; +} +.assistant.thinking > pre { + color: var(--muted); + font-style: italic; +} +.card.user { + padding: 0.75rem 1rem; + border-radius: 0.75rem; + background: var(--panel); + box-shadow: 0 0 0 1px var(--line); +} +.model { + font-size: 0.75rem; + color: var(--faint); +} +.model .time { + display: none; +} +.title { + font-weight: 600; + font-size: 0.8125rem; + margin: 0.25rem 0; +} +.output { + padding: 0.5rem 0.75rem; + background: var(--inset); + border-radius: 0.5rem; + margin: 0.5rem 0 0.25rem; +} +details { + margin: 0.25rem 0; + border-radius: 0.5rem; +} +summary { + overflow-wrap: anywhere; +} +details details { + background: var(--inset); + padding: 0.125rem 0.75rem; +} +details details > summary { + font: 0.75rem / 1.55 var(--mono); + white-space: pre-wrap; + color: var(--muted); + padding: 0.375rem 0; +} +/* Tool calls: one quiet row keyed with the chart's tool color. */ +.entry:has(> details.tool) { + padding: 0; +} +details.tool { + margin: 0; +} +details.tool > summary { + display: block; + padding: 0.3125rem 0.625rem; + margin: 0 -0.625rem; + border-radius: 0.5rem; + font: 0.75rem / 1.5 var(--mono); + color: var(--muted); + list-style: none; +} +details.tool > summary::-webkit-details-marker { + display: none; +} +details.tool > summary::before { + content: ""; + display: inline-block; + vertical-align: 0.0625rem; + margin-right: 0.5rem; + width: 0.375rem; + height: 0.375rem; + border-radius: 2px; + background: var(--k-tool); +} +details.tool.pending > summary::before { + background: none; + box-shadow: inset 0 0 0 1.5px var(--k-tool); +} +details.tool.error > summary { + color: var(--error); +} +details.tool.error > summary::before { + background: var(--error); +} +details.tool > summary:hover { + background: var(--hover); + color: var(--text); +} +details.tool[open] > summary { + color: var(--text); +} +details.tool > .details-body { + margin: 0.25rem 0 0.75rem; + padding: 0.25rem 0 0.25rem 0.875rem; + border-left: 1px solid var(--line); +} +details[open] > :is(.details-body, pre) { + animation: reveal 0.15s ease-out; +} +@keyframes reveal { + from { + opacity: 0; + transform: translateY(-0.2rem); + } + to { + opacity: 1; + transform: none; + } +} +@media (prefers-reduced-motion: reduce) { + details[open] > .details-body, + details[open] > pre { + animation: none; + } +} +.error { + color: var(--error); +} +.empty { + padding: 3rem 1rem; + text-align: center; + color: var(--muted); +} +#content { + min-height: 10rem; +} +.entry.flash { + animation: flash 1.4s ease-out; +} +@keyframes flash { + from { + background: color-mix(in srgb, var(--accent) 16%, transparent); + } +} +@media (max-width: 48rem) { + .workspace { + grid-template-columns: 1fr; + } + aside { + height: auto; + max-height: 55dvh; + position: static; + border-right: 0; + border-bottom: 1px solid var(--line); + } + main { + padding: 0 1rem 3rem; + } +} +/* Posted to Buzz: the agent's side of the conversation, tinted with its hue. */ +.card.sent { + padding: 0.75rem 1rem; + border-radius: 0.75rem; + background: color-mix(in srgb, var(--k-inference) 9%, var(--panel)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--k-inference) 22%, transparent); +} +.card.sent > pre { + font: + 0.875rem / 1.6 system-ui, + -apple-system, + sans-serif; +} +.entry:has(.sent) h2 { + color: var(--k-inference); +} diff --git a/scripts/session-debug.html b/scripts/session-debug.html new file mode 100644 index 000000000..b4358c95b --- /dev/null +++ b/scripts/session-debug.html @@ -0,0 +1,27 @@ + + + + + + Agent session + + + +
+ +
+

+
+
+
+ + + diff --git a/scripts/session-debug.mjs b/scripts/session-debug.mjs new file mode 100644 index 000000000..acd110ee4 --- /dev/null +++ b/scripts/session-debug.mjs @@ -0,0 +1,277 @@ +#!/usr/bin/env node +// One-line, read-only, loopback session inspector. No model or relay connection. +import { createServer } from "node:http"; +import { randomBytes } from "node:crypto"; +import { readFile, readdir, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { resolve, join, dirname } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { spawn } from "node:child_process"; +import { parseLines, sessionModel } from "./session-model.mjs"; + +export function threadId(value) { + if (/^[a-f0-9]{64}$/i.test(value)) return value.toLowerCase(); + try { + const url = new URL(value); + if (url.protocol !== "buzz:" || url.hostname !== "message") + throw new Error(); + const id = url.searchParams.get("thread") || url.searchParams.get("id"); + if (/^[a-f0-9]{64}$/i.test(id)) return id.toLowerCase(); + } catch {} + throw new Error( + "Use a Buzz thread ID, buzz://message link, or --file /absolute/session.jsonl", + ); +} +async function* files(root) { + let entries; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch (e) { + if (e.code === "ENOENT") return; + throw e; + } + for (const e of entries) { + if (e.isDirectory()) yield* files(join(root, e.name)); + else if (e.isFile() && e.name.endsWith(".jsonl")) yield join(root, e.name); + } +} +export function ownsThread(records, id) { + return records.some((r) => { + const message = + r.type === "response_item" && r.payload?.role === "user" + ? r.payload + : r.type === "user" + ? r.message + : null; + if (!message) return false; + const body = + typeof message.content === "string" + ? message.content + : (message.content ?? []) + .filter((b) => b.type === "text" || b.type === "input_text") + .map((b) => b.text) + .join("\n"); + // Inspect the delivery envelope, not quoted history or tool output. + for (const m of body.matchAll(/\s*([^<]+)\s*<\/context>/g)) { + try { + if (JSON.parse(m[1]).session_thread === id) return true; + } catch {} + } + return ( + new RegExp(`^Thread root: ${id}\\s*$`, "m").test(body) || + new RegExp(`^Event ID: ${id}\\s*$`, "m").test(body) + ); + }); +} +export async function createInspector({ + target, + file, + provider, + roots, + traceRoot, + rates = {}, +}) { + const home = homedir(); + roots ??= [ + join(process.env.CODEX_HOME || join(home, ".codex"), "sessions"), + join(process.env.CODEX_HOME || join(home, ".codex"), "archived_sessions"), + join(process.env.CLAUDE_CONFIG_DIR || join(home, ".claude"), "projects"), + ]; + traceRoot ??= join(home, ".buzz", "session-debug"); + const known = new Map(); + let candidates = file ? [resolve(file)] : []; + let scanned = 0; + const token = randomBytes(24).toString("hex"); + const base = `/${token}`; + const assets = dirname(fileURLToPath(import.meta.url)); + let busy; + async function inspect(selected, through, turn) { + if (!file && Date.now() - scanned > 3000) { + const found = []; + for (const root of roots) + for await (const path of files(root)) { + const info = await stat(path).catch(() => null); + if (!info) continue; + const stamp = `${info.size}:${info.mtimeMs}`; + let match = known.get(path); + if (match?.stamp !== stamp) { + const source = await readFile(path, "utf8"); + const matches = + source.includes(target) && + ownsThread(parseLines(source, true), target); + match = { stamp, matches }; + known.set(path, match); + } + if (match.matches) found.push(path); + } + candidates = found.sort(); + scanned = Date.now(); + } + if (!candidates.length) + return { + waiting: true, + message: "Waiting for a local session for this thread…", + candidates: [], + }; + const chosen = + selected && candidates.includes(selected) ? selected : candidates.at(-1); + const records = parseLines(await readFile(chosen, "utf8"), true); + const id = + records.find((r) => r.type === "session_meta")?.payload?.id ?? + records.find((r) => r.sessionId)?.sessionId; + let trace = []; + if (/^[\w-]+$/.test(id ?? "")) { + try { + trace = parseLines( + await readFile(join(traceRoot, `${id}.jsonl`), "utf8"), + true, + ); + } catch (e) { + if (e.code !== "ENOENT") throw e; + } + } + const model = sessionModel(records, { + file: chosen, + provider, + trace, + rates, + }); + const response = model.turns + .flatMap((t) => t.requests) + .find((r) => r.id === through); + const until = + response?.timestamp ?? model.turns.find((t) => t.id === turn)?.end; + if (until) { + const snapshot = sessionModel( + records.filter((r) => !r.timestamp || r.timestamp <= until), + { + file: chosen, + provider, + trace: trace.filter((r) => r.timestamp <= until), + rates, + }, + ); + model.entries = snapshot.entries; + model.raw = snapshot.raw; + model.snapshotAt = until; + } + return { ...model, candidates }; + } + const server = createServer(async (req, res) => { + const origin = `http://127.0.0.1:${server.address().port}`; + const url = new URL(req.url, origin); + res.setHeader("Cache-Control", "no-store"); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Referrer-Policy", "no-referrer"); + res.setHeader( + "Content-Security-Policy", + "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'", + ); + if ( + req.method !== "GET" || + req.headers.host !== `127.0.0.1:${server.address().port}` || + (req.headers.origin && req.headers.origin !== origin) || + req.headers["sec-fetch-site"] === "cross-site" + ) { + res.writeHead(403); + res.end(); + return; + } + try { + if (url.pathname === `${base}/state`) { + // Serialize scans, but never share a different selection's response. + const job = (busy ?? Promise.resolve()) + .catch(() => {}) + .then(() => + inspect( + url.searchParams.get("session"), + url.searchParams.get("through"), + url.searchParams.get("turn"), + ), + ); + busy = job; + const state = await job; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(state)); + } else { + const name = new Map([ + [`${base}/`, "session-debug.html"], + [`${base}/client.js`, "session-debug-client.js"], + [`${base}/style.css`, "session-debug.css"], + ]).get(url.pathname); + if (!name) { + res.writeHead(404); + res.end(); + return; + } + res.setHeader( + "Content-Type", + name.endsWith(".js") + ? "text/javascript" + : name.endsWith(".css") + ? "text/css" + : "text/html", + ); + res.end(await readFile(join(assets, name))); + } + } catch (error) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: error.message })); + } + }); + await new Promise((accept, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", accept); + }); + return { server, url: `http://127.0.0.1:${server.address().port}${base}/` }; +} +async function main() { + const args = process.argv.slice(2); + if (!args.length || args.includes("--help")) { + console.log( + 'Usage: bin/node scripts/session-debug.mjs [--no-open] [--rates rates.json]\n bin/node scripts/session-debug.mjs --file session.jsonl [--no-open]\nRates: {"exact-model-id":{"input":2,"output":10,"cached":0.2,"written":2.5}} in USD per million tokens.', + ); + return; + } + const take = (flag) => { + const i = args.indexOf(flag); + if (i < 0) return; + if (!args[i + 1] || args[i + 1].startsWith("--")) + throw new Error(`${flag} requires a value`); + return args.splice(i, 2)[1]; + }; + const file = take("--file"), + rateFile = take("--rates"); + const noOpen = args.includes("--no-open"); + if (noOpen) args.splice(args.indexOf("--no-open"), 1); + if (args.length !== (file ? 0 : 1)) + throw new Error("Unexpected arguments; use --help"); + const rates = rateFile + ? JSON.parse(await readFile(resolve(rateFile), "utf8")) + : {}; + const { server, url } = await createInspector({ + file, + target: file ? undefined : threadId(args[0]), + rates, + }); + console.log( + `Session viewer: ${url}\nLive local view. Ctrl+C stops the server.`, + ); + if (!noOpen) { + const command = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "explorer.exe" + : "xdg-open"; + const child = spawn(command, [url], { stdio: "ignore" }); + child.on("error", () => console.log("Open the URL above in a browser.")); + } + for (const signal of ["SIGINT", "SIGTERM"]) + process.on(signal, () => server.close(() => process.exit(0))); +} +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) + main().catch((e) => { + console.error(e.message); + process.exitCode = 1; + }); diff --git a/scripts/session-model.mjs b/scripts/session-model.mjs new file mode 100644 index 000000000..f403e504e --- /dev/null +++ b/scripts/session-model.mjs @@ -0,0 +1,336 @@ +// Provider transcripts remain the source of truth. No replay or model requests. +import { buildCodex } from "./export-codex-session.mjs"; +import { buildClaude } from "./export-claude-session.mjs"; +import { entry } from "./session-viewer.mjs"; +import { wallTiming } from "./session-timing.mjs"; + +export function parseLines(source, live = false) { + const lines = source.split("\n"); + return lines.flatMap((line, i) => { + if (!line.trim()) return []; + try { + return [JSON.parse(line)]; + } catch { + if (live && i === lines.length - 1) return []; + throw new Error(`Invalid JSON on line ${i + 1}`); + } + }); +} + +const ms = (stamp) => + Number.isFinite(Date.parse(stamp)) ? Date.parse(stamp) : null; +const amount = (value) => + typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0; +const usage = (u = {}) => ({ + input: amount(u.input_tokens), + output: amount(u.output_tokens), + cached: amount(u.cached_input_tokens ?? u.cache_read_input_tokens), + written: amount(u.cache_write_input_tokens ?? u.cache_creation_input_tokens), +}); +function total(requests) { + const out = { input: 0, output: 0, cached: 0, written: 0 }; + for (const r of requests) + for (const k of Object.keys(out)) out[k] += r.usage[k]; + return out; +} +// Never expose encrypted reasoning blobs as though they were readable context. +function publicRecord(value) { + if (Array.isArray(value)) return value.map(publicRecord); + if (value && typeof value === "object") + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => !["encrypted_content", "signature"].includes(key)) + .map(([key, v]) => [key, publicRecord(v)]), + ); + return value; +} + +export function sessionModel( + records, + { provider, file, trace = [], rates = {} } = {}, +) { + provider ??= records.some((r) => r.type === "session_meta") + ? "codex" + : "claude"; + const base = + provider === "codex" ? buildCodex(records) : buildClaude(records); + const meta = records.find((r) => r.type === "session_meta")?.payload ?? {}; + const turns = []; + let active; + let model = "", + effort = null; + const start = (id, timestamp, inferred = false) => { + if (active && !active.end) { + active.end = timestamp; + active.status = "boundary inferred"; + } + active = { + id, + start: timestamp, + end: null, + status: inferred ? "completion not recorded" : "running", + inferred, + model, + requests: [], + cost: null, + }; + turns.push(active); + return active; + }; + const requests = new Map(); + let previousTotals; + const hasUsageRecords = records.some((r) => r.type === "token_usage_record"); + const hasLifecycle = records.some((r) => r.payload?.type === "task_started"); + for (const [index, r] of records.entries()) { + const p = r.payload ?? {}; + if (r.type === "turn_context") { + model = p.model ?? model; + effort = + p.effort ?? p.collaboration_mode?.settings?.reasoning_effort ?? effort; + if (active) active.model = model; + } + if (provider === "codex") { + if (r.type === "event_msg" && p.type === "task_started") + start(p.turn_id ?? `turn-${index}`, r.timestamp); + if ( + !hasLifecycle && + r.type === "response_item" && + p.role === "user" && + /Request:/.test(JSON.stringify(p.content)) + ) + start(`turn-${index}`, r.timestamp, true); + if ( + r.type === "event_msg" && + ["task_complete", "turn_aborted"].includes(p.type) && + active + ) { + const t = turns.find((t) => t.id === p.turn_id) ?? active; + t.end = r.timestamp; + t.durationMs = p.duration_ms; + t.status = p.type === "turn_aborted" ? "interrupted" : "complete"; + } + if (r.type === "token_usage_record" && active) { + const t = turns.find((t) => t.id === p.turn_id) ?? active; + requests.set(p.response_id ?? `usage-${index}`, { + id: p.response_id ?? `usage-${index}`, + turnId: t.id, + timestamp: r.timestamp, + model: t.model, + usage: usage(p.usage), + raw: publicRecord(p), + }); + } + if ( + r.type === "event_msg" && + p.type === "token_count" && + p.info?.total_token_usage + ) { + const u = usage(p.info.total_token_usage); + if ( + !hasUsageRecords && + active && + (!previousTotals || + Object.keys(u).some((k) => u[k] !== previousTotals[k])) + ) { + const delta = Object.fromEntries( + Object.keys(u).map((k) => [ + k, + Math.max(0, u[k] - (previousTotals?.[k] ?? 0)), + ]), + ); + requests.set(`usage-${index}`, { + id: `usage-${index}`, + turnId: active.id, + timestamp: r.timestamp, + model, + usage: delta, + inferred: true, + }); + } + previousTotals = u; + } + } else { + const content = r.message?.content; + const blocks = Array.isArray(content) + ? content + : [{ type: "text", text: content }]; + const body = blocks + .filter((b) => b.type === "text") + .map((b) => b.text ?? "") + .join("\n"); + if ( + r.type === "user" && + !r.isMeta && + !/^<(task-notification|system-reminder|local-command|command-name)/.test( + body.trim(), + ) && + body && + !blocks.some((b) => b.type === "tool_result") + ) { + // Steering remains in the current burst of work, not a fabricated new completion. + if (!/new-message-arrived-while-you-were-working/.test(body) || !active) + start(r.uuid ?? `turn-${index}`, r.timestamp, true); + } + // Claude Code writes zero-usage "" replies (for example "No + // response requested.") when it resumes; no model request happened. + if ( + r.type === "assistant" && + active && + r.message?.model !== "" + ) { + model = r.message.model ?? model; + active.model = model; + if (r.message.usage) { + const id = r.message.id ?? r.requestId ?? `usage-${index}`; + // Claude writes multiple content blocks with the same message ID and usage. + requests.set(id, { + id, + turnId: active.id, + timestamp: r.timestamp, + model, + usage: usage(r.message.usage), + raw: publicRecord(r.message.usage), + }); + } + } + if (r.type === "system" && r.subtype === "turn_duration" && active) { + active.durationMs = r.durationMs; + active.end = r.timestamp; + active.status = "complete"; + } + } + } + const context = []; + if (provider === "codex") { + if (meta.base_instructions) + context.push( + entry.instructions( + meta.timestamp, + "Recorded base instructions", + typeof meta.base_instructions === "string" + ? meta.base_instructions + : (meta.base_instructions.text ?? + JSON.stringify(meta.base_instructions)), + ), + ); + if (meta.dynamic_tools) + context.push( + entry.instructions( + meta.timestamp, + "Recorded dynamic tool definitions", + JSON.stringify(meta.dynamic_tools, null, 2), + ), + ); + } + let previousCost = 0; + for (const r of trace) { + if (r.type === "context") previousCost = 0; + if (r.type === "context") + context.push( + entry.instructions( + r.timestamp, + "Recorded Buzz system prompt additions", + r.payload.systemPrompt, + ), + ); + if (r.type === "tools") + context.push( + entry.instructions( + r.timestamp, + "Recorded Buzz tool definitions", + JSON.stringify(r.payload, null, 2), + ), + ); + if (r.type === "result") { + const t = [...turns] + .reverse() + .find((t) => ms(t.start) <= ms(r.timestamp)); + if (t) { + t.end = r.timestamp; + t.durationMs = r.payload.duration_ms; + t.inferred = false; + const cumulative = r.payload.total_cost_usd; + if (typeof cumulative === "number" && cumulative >= previousCost) { + t.cost = (t.cost ?? 0) + cumulative - previousCost; + previousCost = cumulative; + } + t.costLabel = "reported total difference"; + t.status = r.payload.is_error ? "error" : "complete"; + } + } + } + for (const t of turns) { + t.requests = [...requests.values()].filter((r) => r.turnId === t.id); + t.usage = total(t.requests); + t.durationMs ??= + t.end && ms(t.start) !== null + ? Math.max(0, ms(t.end) - ms(t.start)) + : null; + const estimate = t.requests.map((r) => { + r.estimate = null; + const rate = rates[r.model]; + if (!rate) return null; + const u = r.usage; + const input = + provider === "codex" + ? Math.max(0, u.input - u.cached - u.written) + : u.input; + if ( + ![rate.input, rate.output, rate.cached, rate.written].every( + (x) => typeof x === "number" && Number.isFinite(x) && x >= 0, + ) + ) + return null; + r.estimate = + (input * rate.input + + u.output * rate.output + + u.cached * rate.cached + + u.written * rate.written) / + 1e6; + return r.estimate; + }); + t.estimate = + estimate.length && estimate.every((v) => v !== null) + ? estimate.reduce((a, b) => a + b, 0) + : null; + } + const identifiers = new Map(); + return { + ...base, + provider, + file, + sessionId: meta.id ?? records.find((r) => r.sessionId)?.sessionId, + header: { + harness: provider === "codex" ? "Codex" : "Claude Code", + model: + turns + .flatMap((t) => t.requests) + .reverse() + .find((r) => r.model && r.model !== "")?.model ?? model, + effort, + }, + // Model switches are summarized in the header instead. + entries: [ + ...context, + ...base.entries.filter((e) => e.kind !== "model"), + ].map((e) => { + const key = `${e.timestamp ?? "meta"}-${e.kind}-${e.label}`; + const occurrence = identifiers.get(key) ?? 0; + identifiers.set(key, occurrence + 1); + return { + ...e, + id: `${key}-${occurrence}`, + turnId: + [...turns].reverse().find((t) => ms(t.start) <= ms(e.timestamp)) + ?.id ?? null, + }; + }), + turns, + timing: wallTiming(records, turns, provider), + raw: records.map(publicRecord), + limitations: + provider === "codex" + ? "Recorded base instructions, dynamic tools, messages and usage. Native tool schemas and exact post-compaction inference requests may not be recorded. Encrypted reasoning is omitted." + : "Recorded messages and per-response usage. Buzz prompt additions and tool definitions are available for sessions launched by the updated plugin. Claude’s private base prompt, native tool schemas and exact inference requests are not exposed. Without completion records, duration is unknown or an inferred boundary.", + }; +} diff --git a/scripts/session-timing.mjs b/scripts/session-timing.mjs new file mode 100644 index 000000000..51be89b00 --- /dev/null +++ b/scripts/session-timing.mjs @@ -0,0 +1,261 @@ +// Wall-clock accounting for the diagnostic viewer. Model request starts are not +// in the native transcripts: inference windows are explicitly elapsed estimates, +// bounded by the preceding input/result and observed model output. Tool windows +// use call/result timestamps. Partition their union, never sum overlapping work. +const time = (value) => { + const n = Date.parse(value); + return Number.isFinite(n) ? n : null; +}; + +export function tokenBreakdown(usage, provider) { + const { input = 0, output = 0, cached = 0, written = 0 } = usage ?? {}; + const fresh = + provider === "codex" ? Math.max(0, input - cached - written) : input; + return { + fresh, + cached, + written, + output, + total: fresh + cached + written + output, + }; +} + +export function partitionTime(start, end, intervals) { + const clipped = intervals + .map((i) => ({ + ...i, + start: Math.max(start, i.start), + end: Math.min(end, i.end), + })) + .filter((i) => i.end > i.start); + const edges = [ + ...new Set([start, end, ...clipped.flatMap((i) => [i.start, i.end])]), + ].sort((a, b) => a - b); + const segments = []; + for (let i = 1; i < edges.length; i++) { + const a = edges[i - 1], + b = edges[i]; + if (b <= a) continue; + const kinds = new Set( + clipped.filter((s) => s.start < b && s.end > a).map((s) => s.kind), + ); + const kind = kinds.size > 1 ? "overlap" : ([...kinds][0] ?? "unknown"); + const last = segments.at(-1); + if (last?.kind === kind && last.end === a) last.end = b; + else segments.push({ kind, start: a, end: b }); + } + return segments; +} + +function toolLabel(p) { + if (p.name !== "exec") return p.name ?? p.type; + const nested = [ + ...new Set( + [...String(p.input ?? p.arguments ?? "").matchAll(/tools\.([\w]+)/g)].map( + (m) => m[1].replace(/^buzz__/, ""), + ), + ), + ]; + return nested.length + ? `${nested.slice(0, 2).join(" + ")}${nested.length > 2 ? ` +${nested.length - 2}` : ""}` + : "exec"; +} + +function isActivity(r, provider) { + if (provider === "codex") + return r.type === "response_item" || r.type === "event_msg"; + return ( + r.type === "user" || + (r.type === "assistant" && r.message?.model !== "") + ); +} + +export function wallTiming(records, turns, provider, now = Date.now()) { + const result = []; + const allRequests = new Map( + turns.flatMap((t) => t.requests.map((r) => [r.id, r])), + ); + const inference = new Map(); + const calls = new Map(); + let ready = null, + pending = null; + const codexUsage = records.some((r) => r.type === "token_usage_record"); + for (const [index, r] of records.entries()) { + const at = time(r.timestamp); + if (at === null) continue; + const p = r.payload ?? {}; + if (provider === "codex") { + if (r.type === "event_msg" && p.type === "task_started") { + ready = at; + pending = null; + } + if (r.type === "response_item") { + if (p.type?.endsWith("_output")) { + const call = calls.get(p.call_id); + if (call) call.end = Math.max(call.start, at); + ready = at; + } else if (p.role === "user") ready = at; + else if ( + p.type === "reasoning" || + p.role === "assistant" || + p.call_id + ) { + pending ??= { start: ready ?? at, end: at, known: ready !== null }; + pending.end = Math.max(pending.end, at); + if (p.call_id) + calls.set(p.call_id, { + id: p.call_id, + kind: "tool", + name: toolLabel(p), + start: at, + end: null, + }); + } + } + const id = + r.type === "token_usage_record" + ? (p.response_id ?? `usage-${index}`) + : !codexUsage && r.type === "event_msg" && p.type === "token_count" + ? `usage-${index}` + : null; + if (id && allRequests.has(id)) { + inference.set(id, { + id, + kind: "inference", + start: pending?.start ?? at, + end: pending?.end ?? at, + estimated: !!pending?.known, + }); + pending = null; + ready = Math.max(ready ?? at, at); + } + } else { + const blocks = Array.isArray(r.message?.content) ? r.message.content : []; + if (r.type === "user") { + ready = at; + for (const b of blocks) + if (b.type === "tool_result") { + const call = calls.get(b.tool_use_id); + if (call) call.end = Math.max(call.start, at); + } + } + if (r.type === "assistant" && r.message?.model !== "") { + const id = r.message.id ?? r.requestId ?? `usage-${index}`; + const span = inference.get(id) ?? { + id, + kind: "inference", + start: ready ?? at, + end: at, + estimated: ready !== null, + }; + span.end = Math.max(span.end, at); + inference.set(id, span); + ready = at; + for (const b of blocks) + if (b.type === "tool_use" && !calls.has(b.id)) + calls.set(b.id, { + id: b.id, + kind: "tool", + name: b.name, + start: at, + end: null, + }); + } + } + } + for (const [index, t] of turns.entries()) { + const start = time(t.start); + if (start === null) continue; + const next = time(turns[index + 1]?.start) ?? Infinity; + // Only conversation activity marks a run as busy. Bookkeeping written when + // the next prompt is queued (queue-operation, attachment, synthetic replies) + // would otherwise stretch an idle wait into the previous run. + const lastObserved = records.reduce((last, r) => { + const at = time(r.timestamp); + return at !== null && at >= start && at < next && isActivity(r, provider) + ? Math.max(last, at) + : last; + }, start); + // A missing completion is not proof the provider is still running. Only an + // explicit active lifecycle extends to now; older Claude logs stop at data. + const end = Math.max( + start, + t.status === "running" + ? Math.min(next, Math.max(lastObserved, now)) + : !t.inferred && time(t.end) !== null + ? Math.min(next, time(t.end)) + : lastObserved, + ); + const items = []; + for (const r of t.requests) { + const span = inference.get(r.id) ?? { + id: r.id, + kind: "inference", + start: time(r.timestamp) ?? start, + end: time(r.timestamp) ?? start, + estimated: false, + }; + items.push({ + ...span, + start: Math.min(end, Math.max(start, span.start)), + end: Math.min(end, Math.max(start, span.start, span.end)), + tokens: tokenBreakdown(r.usage, provider), + estimate: r.estimate, + model: r.model, + }); + } + for (const c of calls.values()) + if (c.start >= start && c.start < Math.min(next, end + 1)) { + items.push({ + ...c, + end: Math.min(end, c.end ?? end), + open: c.end === null, + }); + } + const segments = partitionTime(start, end, items); + for (const [i, s] of segments.entries()) + if (s.kind === "unknown") + items.push({ + ...s, + id: `gap-${i}`, + name: "Unattributed", + estimated: false, + }); + items.sort((a, b) => a.start - b.start || a.end - b.end); + result.push({ + id: t.id, + start, + end, + observedOnly: t.inferred, + ongoing: t.status === "running", + segments, + items, + recordedDurationMs: t.durationMs, + }); + } + const start = result[0]?.start ?? null; + const end = result.at(-1)?.end ?? null; + const idle = result + .slice(1) + .map((r, i) => ({ kind: "idle", start: result[i].end, end: r.start })) + .filter((s) => s.end > s.start); + const segments = + start === null + ? [] + : partitionTime(start, end, [ + ...result.flatMap((r) => r.segments), + ...idle, + ]); + // No active run in this period: the agent was idle, waiting for a prompt. It + // is neither a slow model nor a tool, and stays separate from unattributed + // gaps inside a run. + for (const s of segments) + if ( + s.kind === "unknown" && + !result.some((r) => r.start < s.end && r.end > s.start) + ) + s.kind = "idle"; + const totals = { inference: 0, tool: 0, overlap: 0, unknown: 0, idle: 0 }; + for (const s of segments) totals[s.kind] += s.end - s.start; + return { start, end, segments, totals, runs: result }; +} diff --git a/scripts/session-viewer.mjs b/scripts/session-viewer.mjs index 7e9286334..e5615c349 100644 --- a/scripts/session-viewer.mjs +++ b/scripts/session-viewer.mjs @@ -13,25 +13,27 @@ export const escapeHtml = (value) => ], ); export const text = (value) => - typeof value === "string" ? value : JSON.stringify(value, null, 2); + typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? ""); const time = (timestamp) => `
${escapeHtml(timestamp?.slice(11, 19))}
`; // Long text shows a preview; clicking toggles the full text, as in Pi's viewer. function expandable(value, lines = 10) { const all = text(value).split("\n"); if (all.length <= lines) return `
${escapeHtml(all.join("\n"))}
`; - return ``; + return `
${escapeHtml(all.slice(0, Math.max(1, lines)).join("\n"))} … (${all.length} lines)
${escapeHtml(all.join("\n"))}
`; } // Entry builders: kind drives filters, label/preview the sidebar, html the card. export const entry = { model: (timestamp, label) => ({ + timestamp, kind: "model", label: "model", preview: label, html: `
${time(timestamp)}Switched to model: ${escapeHtml(label)}
`, }), instructions: (timestamp, label, body) => ({ + timestamp, kind: "instructions", label, preview: body, @@ -39,18 +41,21 @@ export const entry = { }), // A Buzz turn shows its request, with the full turn context collapsed. user: (timestamp, body, request) => ({ + timestamp, kind: "user", label: "user", preview: request ?? body, html: `
${time(timestamp)}${request === undefined ? expandable(body, 20) : `
${escapeHtml(request)}
Buzz turn
${expandable(body, 0)}`}
`, }), assistant: (timestamp, body) => ({ + timestamp, kind: "assistant", label: "assistant", preview: body, html: `
${time(timestamp)}
${escapeHtml(body)}
`, }), thinking: (timestamp, body) => ({ + timestamp, kind: "assistant", label: "thinking", preview: body, @@ -58,12 +63,22 @@ export const entry = { }), // `result` is undefined while pending; `detail` names the call's target. tool: (timestamp, { name, detail = "", input, result, error = false }) => ({ + timestamp, kind: "tool", label: name, preview: detail || text(input), - html: `
${time(timestamp)}
${escapeHtml(name)} ${escapeHtml(detail)}
${expandable(input, 20)}${result === undefined ? "" : `
${expandable(result)}
`}
`, + html: `
${escapeHtml(name)}${detail ? ` ${escapeHtml(detail)}` : ""}${error ? ` failed` : ""}
${time(timestamp)}${expandable(input, 20)}${result === undefined ? "" : `
${expandable(result)}
`}
`, + }), + // A message the agent posted to Buzz through its send tool. + sent: (timestamp, body) => ({ + timestamp, + kind: "sent", + label: "posted to Buzz", + preview: body, + html: `
${time(timestamp)}
${escapeHtml(body)}
`, }), other: (timestamp, label, value) => ({ + timestamp, kind: "other", label, preview: text(value), diff --git a/tests/integration/session-debug.test.mjs b/tests/integration/session-debug.test.mjs new file mode 100644 index 000000000..45f01badd --- /dev/null +++ b/tests/integration/session-debug.test.mjs @@ -0,0 +1,269 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { mkdtemp, writeFile, appendFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createInspector, + ownsThread, + threadId, +} from "../../scripts/session-debug.mjs"; +import { parseLines, sessionModel } from "../../scripts/session-model.mjs"; +const stamp = (n) => new Date(1700000000000 + n * 1000).toISOString(); +const event = (n, type, p = {}) => ({ + type: "event_msg", + timestamp: stamp(n), + payload: { type, ...p }, +}); +const id = "a".repeat(64); +const user = (n, text) => ({ + type: "response_item", + timestamp: stamp(n), + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text }], + }, +}); +const codex = [ + { + type: "session_meta", + payload: { + id: "test", + base_instructions: { text: "base " }, + dynamic_tools: [{ name: "tool", inputSchema: { type: "object" } }], + }, + }, + event(0, "task_started", { turn_id: "t1" }), + { + type: "turn_context", + timestamp: stamp(0), + payload: { model: "test-model" }, + }, + user( + 1, + `\n${JSON.stringify({ session_thread: id })}\n\nRequest: "hello"`, + ), + { + type: "token_usage_record", + timestamp: stamp(2), + payload: { + turn_id: "t1", + response_id: "r1", + usage: { input_tokens: 100, cached_input_tokens: 50, output_tokens: 10 }, + }, + }, + event(2, "token_count", { + info: { + total_token_usage: { + input_tokens: 100, + cached_input_tokens: 50, + output_tokens: 10, + }, + }, + }), + event(3, "task_complete", { turn_id: "t1", duration_ms: 2900 }), +]; + +test("accepts thread links, not arbitrary URLs", () => { + assert.equal(threadId(`buzz://message?id=${id}`), id); + assert.throws(() => threadId(`https://example.com/?id=${id}`)); + assert.throws(() => threadId("bad")); +}); +test("matches user delivery context, not tool outputs or quoted history", () => { + assert.equal(ownsThread(codex, id), true); + assert.equal( + ownsThread( + [ + { + type: "response_item", + payload: { + type: "custom_tool_call_output", + output: JSON.stringify(codex), + }, + }, + ], + id, + ), + false, + ); + assert.equal( + ownsThread( + [user(0, `${JSON.stringify(codex)}`)], + id, + ), + false, + ); +}); +test("only tolerates a partial final line", () => { + assert.equal(parseLines('{"a":1}\n{"a":', true).length, 1); + assert.throws(() => parseLines('{bad}\n{"a":1}\n', true)); +}); +test("Codex exposes recorded context and counts usage only once", () => { + const result = sessionModel([...codex, codex[4]], { + rates: { "test-model": { input: 2, output: 10, cached: 1, written: 2 } }, + }); + assert.equal(result.turns.length, 1); + assert.equal(result.turns[0].durationMs, 2900); + assert.equal(result.turns[0].usage.input, 100); + assert.equal(result.turns[0].requests.length, 1); + assert.equal(result.turns[0].estimate, 0.00025); + assert.equal(result.turns[0].cost, null); + assert.match(result.entries[0].html, /<script>/); + assert.equal(result.entries[1].label, "Recorded dynamic tool definitions"); +}); +test("Claude deduplicates streamed blocks and ignores tool-result turns", () => { + const records = [ + { + type: "user", + uuid: "u", + timestamp: stamp(0), + message: { content: "hi" }, + }, + ...[1, 2].map((n) => ({ + type: "assistant", + timestamp: stamp(n), + message: { + id: "m", + model: "m1", + content: [{ type: "text", text: "ok" }], + usage: { + input_tokens: 5, + cache_read_input_tokens: 20, + output_tokens: 2, + }, + }, + })), + { + type: "user", + timestamp: stamp(3), + message: { + content: [{ type: "tool_result", tool_use_id: "x", content: "ok" }], + }, + }, + ]; + const result = sessionModel(records); + assert.equal(result.turns.length, 1); + assert.equal(result.turns[0].usage.input, 5); + assert.equal(result.turns[0].durationMs, null); + assert.equal(result.turns[0].requests.length, 1); +}); +test("captured costs are differenced within a launch and reset on restart", () => { + const records = [0, 10, 20].map((n) => ({ + type: "user", + uuid: String(n), + timestamp: stamp(n), + message: { content: "hi" }, + })); + const trace = [ + { type: "context", timestamp: stamp(0), payload: { systemPrompt: "base" } }, + { + type: "result", + timestamp: stamp(5), + payload: { total_cost_usd: 1, duration_ms: 5000 }, + }, + { + type: "result", + timestamp: stamp(15), + payload: { total_cost_usd: 3, duration_ms: 5000 }, + }, + { type: "context", timestamp: stamp(20), payload: { systemPrompt: "new" } }, + { + type: "result", + timestamp: stamp(25), + payload: { total_cost_usd: 0.5, duration_ms: 5000 }, + }, + ]; + assert.deepEqual( + sessionModel(records, { trace }).turns.map((t) => t.cost), + [1, 2, 0.5], + ); +}); +test("live server follows appended records and rejects cross-origin reads", async (t) => { + const dir = await mkdtemp(join(tmpdir(), "session-debug-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = join(dir, "session.jsonl"); + await writeFile(file, `${codex.map((r) => JSON.stringify(r)).join("\n")}\n`); + const { server, url } = await createInspector({ file, traceRoot: dir }); + t.after(() => new Promise((resolve) => server.close(resolve))); + let response = await fetch(`${url}state`); + assert.equal(response.status, 200); + assert.equal((await response.json()).turns.length, 1); + await appendFile( + file, + `${JSON.stringify(event(10, "task_started", { turn_id: "t2" }))}\n`, + ); + response = await fetch(`${url}state`); + assert.equal((await response.json()).turns.length, 2); + assert.equal( + ( + await fetch(`${url}state`, { + headers: { Origin: "https://evil.example" }, + }) + ).status, + 403, + ); + assert.equal((await fetch(`${url}../../other`)).status, 404); +}); + +test("historical response views never pair a tool with its later result", async (t) => { + const dir = await mkdtemp(join(tmpdir(), "session-boundary-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = join(dir, "session.jsonl"); + const records = [ + ...codex.slice(0, 4), + { + type: "response_item", + timestamp: stamp(1), + payload: { + type: "function_call", + call_id: "call", + name: "tool", + arguments: "{}", + }, + }, + codex[4], + { + type: "response_item", + timestamp: stamp(3), + payload: { + type: "function_call_output", + call_id: "call", + output: "FUTURE_RESULT", + }, + }, + codex[6], + ]; + await writeFile(file, records.map((r) => JSON.stringify(r)).join("\n")); + const { server, url } = await createInspector({ file, traceRoot: dir }); + t.after(() => new Promise((resolve) => server.close(resolve))); + const snapshot = await (await fetch(`${url}state?through=r1`)).json(); + assert.equal(snapshot.snapshotAt, stamp(2)); + assert.equal( + JSON.stringify(snapshot.entries).includes("FUTURE_RESULT"), + false, + ); + assert.equal(snapshot.turns.length, 1); + const full = await (await fetch(`${url}state`)).json(); + assert.equal(JSON.stringify(full.entries).includes("FUTURE_RESULT"), true); +}); + +test("Claude background notifications remain inside their originating turn", () => { + const records = [ + { type: "user", timestamp: stamp(0), message: { content: "hello" } }, + { + type: "user", + timestamp: stamp(1), + message: { content: "done" }, + }, + { + type: "user", + timestamp: stamp(2), + message: { + content: + "also this", + }, + }, + ]; + assert.equal(sessionModel(records).turns.length, 1); +}); diff --git a/tests/integration/session-timing.test.mjs b/tests/integration/session-timing.test.mjs new file mode 100644 index 000000000..71a15b767 --- /dev/null +++ b/tests/integration/session-timing.test.mjs @@ -0,0 +1,227 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + partitionTime, + tokenBreakdown, + wallTiming, +} from "../../scripts/session-timing.mjs"; +import { sessionModel } from "../../scripts/session-model.mjs"; +const codexModel = (records) => sessionModel(records, { provider: "codex" }); +const stamp = (s) => new Date(1700000000000 + s * 1000).toISOString(); +const event = (s, type, p = {}) => ({ + timestamp: stamp(s), + type: "event_msg", + payload: { type, ...p }, +}); +const item = (s, p) => ({ + timestamp: stamp(s), + type: "response_item", + payload: p, +}); +const usage = (s, id) => ({ + timestamp: stamp(s), + type: "token_usage_record", + payload: { + turn_id: "t", + response_id: id, + usage: { input_tokens: 100, cached_input_tokens: 80, output_tokens: 10 }, + }, +}); +const records = [ + event(0, "task_started", { turn_id: "t" }), + item(0.5, { type: "message", role: "user", content: [] }), + item(2, { + type: "function_call", + call_id: "c", + name: "read", + arguments: "{}", + }), + usage(2.1, "r1"), + item(5, { type: "function_call_output", call_id: "c", output: "ok" }), + item(6, { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "done" }], + }), + usage(6.1, "r2"), + event(7, "task_complete", { turn_id: "t", duration_ms: 7000 }), +]; + +test("wall time includes inferred inference, tool wait, and every uncovered gap", () => { + const model = codexModel(records); + assert.deepEqual(model.timing.totals, { + inference: 2500, + tool: 3000, + unknown: 1500, + idle: 0, + overlap: 0, + }); + assert.equal( + Object.values(model.timing.totals).reduce((a, b) => a + b, 0), + 7000, + ); + const inference = model.timing.runs[0].items.filter( + (i) => i.kind === "inference", + ); + assert.equal(inference.length, 2); + assert.equal(inference[0].estimated, true); + assert.deepEqual(inference[0].tokens, { + fresh: 20, + cached: 80, + written: 0, + output: 10, + total: 110, + }); +}); +test("parallel tools and overlapping inference never inflate the wall-time total", () => { + const parts = partitionTime(0, 100, [ + { kind: "tool", start: 10, end: 70 }, + { kind: "tool", start: 30, end: 80 }, + { kind: "inference", start: 60, end: 90 }, + ]); + assert.deepEqual(parts, [ + { kind: "unknown", start: 0, end: 10 }, + { kind: "tool", start: 10, end: 60 }, + { kind: "overlap", start: 60, end: 80 }, + { kind: "inference", start: 80, end: 90 }, + { kind: "unknown", start: 90, end: 100 }, + ]); + assert.equal( + parts.reduce((n, s) => n + s.end - s.start, 0), + 100, + ); +}); +test("idle waits between runs stay in the session denominator", () => { + const model = codexModel([ + ...records, + event(12, "task_started", { turn_id: "t2" }), + event(14, "task_complete", { turn_id: "t2" }), + ]); + assert.equal(model.timing.totals.idle, 5000); + assert.equal( + Object.values(model.timing.totals).reduce((a, b) => a + b, 0), + 14000, + ); +}); +test("missing model output timing stays unknown instead of inventing a duration", () => { + const model = codexModel([ + event(0, "task_started", { turn_id: "t" }), + usage(4, "r1"), + event(5, "task_complete", { turn_id: "t" }), + ]); + assert.equal(model.timing.totals.inference, 0); + assert.equal(model.timing.totals.unknown, 5000); + assert.equal( + model.timing.runs[0].items.find((i) => i.kind === "inference").estimated, + false, + ); +}); +test("Claude blocks count one inference with tools paired across user results", () => { + const model = sessionModel([ + { type: "user", timestamp: stamp(0), message: { content: "hi" } }, + { + type: "assistant", + timestamp: stamp(1), + message: { + id: "m", + usage: { + input_tokens: 2, + cache_read_input_tokens: 80, + cache_creation_input_tokens: 20, + output_tokens: 3, + }, + content: [{ type: "text", text: "hello" }], + }, + }, + { + type: "assistant", + timestamp: stamp(2), + message: { + id: "m", + usage: { + input_tokens: 2, + cache_read_input_tokens: 80, + cache_creation_input_tokens: 20, + output_tokens: 3, + }, + content: [{ type: "tool_use", id: "c", name: "read", input: {} }], + }, + }, + { + type: "user", + timestamp: stamp(5), + message: { + content: [{ type: "tool_result", tool_use_id: "c", content: "ok" }], + }, + }, + ]); + const run = model.timing.runs[0]; + assert.equal(run.items.filter((i) => i.kind === "inference").length, 1); + assert.equal(run.end - run.start, 5000); + assert.deepEqual(run.items[0].tokens, { + fresh: 2, + cached: 80, + written: 20, + output: 3, + total: 105, + }); +}); +test("only explicit running turns extend to now", () => { + const model = codexModel(records.slice(0, -1)); + const timing = wallTiming( + records.slice(0, -1), + model.turns, + "codex", + Date.parse(stamp(10)), + ); + assert.equal(timing.end - timing.start, 10000); + assert.equal( + tokenBreakdown({ input: 10, cached: 8, written: 1, output: 2 }, "codex") + .fresh, + 1, + ); +}); +test("Claude resume bookkeeping after a finished run counts as idle, not inference", () => { + const assistant = (s, id, content, model = "claude-opus") => ({ + type: "assistant", + timestamp: stamp(s), + message: { + id, + model, + usage: { input_tokens: 1, output_tokens: 1 }, + content, + }, + }); + const model = sessionModel([ + { type: "user", timestamp: stamp(0), message: { content: "hi" } }, + assistant(2, "m1", [ + { type: "tool_use", id: "c", name: "send", input: {} }, + ]), + { + type: "user", + timestamp: stamp(3), + message: { + content: [{ type: "tool_result", tool_use_id: "c", content: "sent" }], + }, + }, + { type: "attachment", timestamp: stamp(3) }, + { type: "queue-operation", timestamp: stamp(1500) }, + assistant( + 1500, + "synthetic-id", + [{ type: "text", text: "No response requested." }], + "", + ), + { type: "user", timestamp: stamp(1501), message: { content: "next" } }, + assistant(1504, "m2", [{ type: "text", text: "ok" }]), + ]); + const { totals, runs } = model.timing; + assert.equal(runs.length, 2); + assert.equal(runs[0].end - runs[0].start, 3000); + assert.equal(totals.idle, 1498000); + assert.equal(totals.inference, 5000); + assert.equal( + model.turns.flatMap((t) => t.requests).some((r) => r.id === "synthetic-id"), + false, + ); +}); diff --git a/vite.config.ts b/vite.config.ts index 157c717f4..a6c66154f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -77,7 +77,14 @@ export default defineConfig(async ({ command, mode }) => { clearScreen: false, // Runtime preparation checks out the Buzz and Goose web UIs under target/. optimizeDeps: { - entries: ["**/*.html", "!**/target/**", "!**/coverage/**"], + // The session inspector is served by its own Node server, which maps + // client.js to session-debug-client.js; it is not a Vite entry. + entries: [ + "**/*.html", + "!**/target/**", + "!**/coverage/**", + "!**/scripts/session-debug.html", + ], }, server: { // Derived from this checkout's path, exactly as `just desktop` does, so From a7a305e5ebac20ef21d4fc59c023ceb6933592d8 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 10 Oct 2026 15:17:18 -0700 Subject: [PATCH 08/39] [session-debug-context] show recorded prompt, tools and injected context The session page hid every instructions entry, so even Codex's recorded base instructions and dynamic tools never appeared. Model-visible context now shows as quiet collapsed rows where it was recorded, without a proxy: - Claude: the system prompt and tool schemas from Claude Code's `prompt_snapshot` records, one disclosure per tool. Each snapshot pair counts as one, and repeats only show when the prompt or tools change. Every injected reminder (environment, skills, CLAUDE.md, hook context) is shown with its rendered text and role. - Codex: base instructions, dynamic tools per tool, run settings from `turn_context` whenever they change, and developer/AGENTS.md context named by its tag. A one-line reminder also shows inline, such as the token count. Request framing, Claude's deferred tool schemas and Codex's native tool schemas are not recorded, and the page says so. Co-Authored-By: Claude Opus 5.5 Squashed-from: baxen/session-debug Squashed-head: d7e8a3c248cdbbb693fdba2ef4c9262e412f21d8 Signed-off-by: Bradley Axen --- docs/session-debugging.md | 17 +- scripts/export-claude-session.mjs | 78 ++++++- scripts/export-codex-session.mjs | 37 ++- scripts/session-debug-client.js | 173 +++++++++++++- scripts/session-debug.css | 278 +++++++++++++++++++++++ scripts/session-debug.html | 13 +- scripts/session-model.mjs | 95 ++++++-- scripts/session-viewer.mjs | 29 ++- tests/integration/session-debug.test.mjs | 136 ++++++++++- 9 files changed, 812 insertions(+), 44 deletions(-) diff --git a/docs/session-debugging.md b/docs/session-debugging.md index a5c01f83b..88b337812 100644 --- a/docs/session-debugging.md +++ b/docs/session-debugging.md @@ -53,14 +53,27 @@ sessions and DMs may require `--file`, and can contain multiple Buzz threads. ## Recorded fields and cost +**Prompt** opens what the model received before its first response. Instructions +are tagged with the role they were sent as (System, Developer, User, or Settings +for run configuration) and render as Markdown: the system prompt first, then +the rest in recorded order. Tools show one row per tool, opening to its +description and JSON schema. Context injected later, such as Claude Code's +reminders, stays in the conversation as faint rows. + Codex supplies `session_meta.base_instructions`, `dynamic_tools`, developer/user messages, response items, lifecycle events, and token usage when supported by its -CLI version. The viewer prefers per-response `token_usage_record`, deduplicates +CLI version. Run settings (approval, sandbox, collaboration mode, effort) come from +`turn_context` and show again when they change. Codex does not record its native +tool schemas, such as shell and apply_patch. The viewer prefers per-response `token_usage_record`, deduplicates response IDs, and falls back to changes in cumulative token counts. It does not add those two sources together. The chart uses timestamp intervals to account for wall time; recorded `duration_ms` remains available in raw records. -Claude's native transcript supplies messages and usage. Content blocks can repeat +Claude's native transcript supplies messages and usage. Claude Code also writes a +`prompt_snapshot` with the system prompt and tool schemas, shown once per change, +and the rendered text and role of each reminder it injects (environment, skills, +CLAUDE.md, hook context). Request framing, such as billing and SDK system blocks, +beta headers and deferred tool schemas, is not recorded. Content blocks can repeat the same message ID and usage; the viewer counts each response once. Legacy turn boundaries are inferred from user messages; tool results do not start turns. Without a completion event the viewer does not pretend that the last token's diff --git a/scripts/export-claude-session.mjs b/scripts/export-claude-session.mjs index c7251b9e8..ac2cab8bf 100644 --- a/scripts/export-claude-session.mjs +++ b/scripts/export-claude-session.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { pathToFileURL } from "node:url"; -import { entry, exportSession } from "./session-viewer.mjs"; +import { entry, exportSession, text } from "./session-viewer.mjs"; // Tool results are a string or a list of text/image content blocks. const resultText = (content) => @@ -30,7 +30,79 @@ export function buildClaude(records) { ); const entries = []; let model; - for (const { timestamp, type, isMeta, message } of messages) { + // Claude Code writes `prompt_snapshot`s in pairs: the first, before a + // response, lacks the CLI prefix and tools that the second fills in. Show the + // pair as one, at the first, and again only when the prompt or tools change. + let system; + let tools; + const deferred = records.some( + (record) => record?.attachment?.type === "deferred_tools_delta", + ); + for (const record of records) { + const { timestamp, type, isMeta, message, attachment } = record; + if (type === "attachment" && attachment?.type === "prompt_snapshot") { + const key = JSON.stringify(attachment.systemPrompt); + const blocks = [ + attachment.cliPrefix, + ...(attachment.systemPrompt ?? []), + ].filter((block) => typeof block === "string" && block.trim()); + const prompt = blocks.join("\n\n"); + const row = (at) => + entry.instructions(at, "System prompt", prompt, { + scope: "system", + blocks, + }); + if (prompt && key !== system?.key) { + system = { key, prefixed: !!attachment.cliPrefix, at: entries.length }; + entries.push(row(timestamp)); + } else if (prompt && attachment.cliPrefix && !system.prefixed) { + system.prefixed = true; + entries[system.at] = row(entries[system.at].timestamp); + } + // `schema` is the wire definition; name and description show apart. + const defs = (attachment.tools ?? []).map((tool) => { + const { + name: _, + description: __, + ...schema + } = tool.schema ?? tool.input_schema ?? {}; + return { name: tool.name, description: tool.description, schema }; + }); + if (defs.length && JSON.stringify(defs) !== tools) { + tools = JSON.stringify(defs); + const row = (at) => + entry.tools(at, `Tools · ${defs.length}`, defs, { + note: deferred + ? "Deferred tools load on demand; only their names are recorded, in the deferred tools reminder." + : null, + }); + if (system && !system.tools) + entries.splice(system.at + 1, 0, row(entries[system.at].timestamp)); + else entries.push(row(timestamp)); + } + if (system && defs.length) system.tools = true; + continue; + } + // Other attachments that reached the model carry their rendered text. + if (type === "attachment") { + const body = (record.rendered ?? []) + .map((part) => text(part?.content)) + .join("\n\n") + .trim(); + if (body) + entries.push( + entry.instructions( + timestamp, + [record.renderedRole, attachment?.type?.replaceAll("_", " ")] + .filter(Boolean) + .join(" · ") || "context", + body, + { scope: record.renderedRole }, + ), + ); + continue; + } + if (message == null) continue; if (message.model && message.model !== model) { model = message.model; entries.push(entry.model(timestamp, model)); @@ -47,7 +119,7 @@ export function buildClaude(records) { if (body) entries.push( isMeta - ? entry.instructions(timestamp, "meta", body) + ? entry.instructions(timestamp, "meta", body, { scope: "user" }) : entry.user(timestamp, body, buzzRequest(body)), ); for (const block of blocks) diff --git a/scripts/export-codex-session.mjs b/scripts/export-codex-session.mjs index 4387c51af..a1ae74a3d 100644 --- a/scripts/export-codex-session.mjs +++ b/scripts/export-codex-session.mjs @@ -18,6 +18,14 @@ const isContext = (value) => /^(# AGENTS\.md instructions||)/.test( value, ); +// Name injected context by its role and leading tag, as `developer · skills instructions`. +const contextLabel = (role, body) => + `${role} · ${ + /^# AGENTS\.md/.test(body) + ? "AGENTS.md" + : (body.match(/^<([A-Za-z][\w.-]*)>/)?.[1].replace(/[_.]/g, " ") ?? + "instructions") + }`; // Buzz turns end with `Request: ""`; show that instead of context. function buzzRequest(value) { const match = value.match(/^Request: (".*")$/m); @@ -46,7 +54,28 @@ export function buildCodex(records) { ); const entries = []; let model; + let settings; for (const { timestamp, type, payload: item } of records) { + // Approval, sandbox, collaboration mode and effort, shown when they change. + if (type === "turn_context" && item) { + const { turn_id: _, root_turn_id: __, ...rest } = item; + const next = JSON.stringify( + { + ...rest, + model_provider: meta.model_provider, + context_window: meta.context_window, + }, + null, + 2, + ); + if (next !== settings) + entries.push( + entry.instructions(timestamp, "Run settings", next, { + scope: "settings", + }), + ); + settings = next; + } if (type === "turn_context" && item?.model && item.model !== model) { model = item.model; entries.push( @@ -62,11 +91,9 @@ export function buildCodex(records) { if (!body) continue; if (item.role === "developer" || isContext(body)) entries.push( - entry.instructions( - timestamp, - item.role === "developer" ? "instructions" : "context", - body, - ), + entry.instructions(timestamp, contextLabel(item.role, body), body, { + scope: item.role, + }), ); else if (item.role === "user") entries.push(entry.user(timestamp, body, buzzRequest(body))); diff --git a/scripts/session-debug-client.js b/scripts/session-debug-client.js index 3e8851a84..e3090bcf7 100644 --- a/scripts/session-debug-client.js +++ b/scripts/session-debug-client.js @@ -504,16 +504,15 @@ function render() { if ($("scope").innerHTML !== scope) $("scope").innerHTML = scope; $("scope").title = state.sessionId ?? ""; renderTiming(state.timing); + renderSetup(state.setup); const rows = state.entries - .filter( - (e) => - e.kind !== "instructions" && - `${e.label} ${e.preview} ${e.html}`.toLowerCase().includes(query), + .filter((e) => + `${e.label} ${e.preview} ${e.html}`.toLowerCase().includes(query), ) .map((e) => ({ id: e.id, time: e.timestamp, - html: `${e.kind === "tool" ? "" : `

${htmlEscape(e.label)}

`}${e.html}`, + html: `${e.kind === "tool" || e.kind === "instructions" ? "" : `

${htmlEscape(e.label)}

`}${e.html}`, })); // Reconcile entries by stable transcript position: live polling must not close // a disclosure, reset selection, or steal focus while the person reads. @@ -566,6 +565,170 @@ function render() { else window.scrollTo(0, scroll); updateViewport(false); } +// Prompt setup: what the model was given before its first response, apart +// from the conversation. Transcript text is untrusted: everything is escaped +// before the small Markdown subset below adds its own tags. +const SCOPES = { + system: "System", + developer: "Developer", + user: "User", + settings: "Settings", +}; +const BOUNDARY = "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"; +const inline = (text) => + htmlEscape(text) + .replace(/`([^`\n]+)`/g, "$1") + .replace(/\*\*([^*\n]+)\*\*/g, "$1"); +// Headings, lists, fenced code, tag-only lines, inline code and bold. No links +// or raw HTML. +function markdown(source) { + const out = []; + let para = [], + list = [], + code = null; + const flush = () => { + if (para.length) out.push(`

${para.map(inline).join("
")}

`); + if (list.length) + out.push( + `
    ${list.map(([depth, text]) => `
  • ${inline(text)}
  • `).join("")}
`, + ); + para = []; + list = []; + }; + for (const line of String(source ?? "").split("\n")) { + if (code) { + if (/^\s*```/.test(line)) { + out.push( + `
${htmlEscape(code.join("\n"))}
`, + ); + code = null; + } else code.push(line); + continue; + } + const heading = line.match(/^(#{1,6})\s+(.+)$/); + const item = line.match(/^(\s*)(?:[-*]|\d+[.)])\s+(.+)$/); + if (/^\s*```/.test(line)) { + flush(); + code = []; + } else if (heading) { + flush(); + const level = Math.min(6, heading[1].length + 2); + out.push(`${inline(heading[2])}`); + } else if (/^\s*<\/?[A-Za-z][\w.-]*>\s*$/.test(line)) { + flush(); + out.push(`
${htmlEscape(line.trim())}
`); + } else if (item) { + if (para.length) flush(); + list.push([Math.min(3, Math.floor(item[1].length / 2)), item[2]]); + } else if (!line.trim()) flush(); + else if (list.length && /^\s/.test(line)) + list.at(-1)[1] += ` ${line.trim()}`; + else { + if (list.length) flush(); + para.push(line); + } + } + if (code) + out.push( + `
${htmlEscape(code.join("\n"))}
`, + ); + flush(); + return out.join(""); +} +// Pretty JSON with keys, strings, numbers and literals told apart. +function json(value, indent = "") { + const next = `${indent} `; + if (Array.isArray(value)) + return value.length + ? `[\n${value.map((v) => next + json(v, next)).join(",\n")}\n${indent}]` + : "[]"; + if (value && typeof value === "object") { + const fields = Object.entries(value); + return fields.length + ? `{\n${fields.map(([k, v]) => `${next}${htmlEscape(JSON.stringify(k))}: ${json(v, next)}`).join(",\n")}\n${indent}}` + : "{}"; + } + const kind = + typeof value === "string" + ? "j-str" + : typeof value === "number" + ? "j-num" + : "j-lit"; + return `${htmlEscape(JSON.stringify(value))}`; +} +const parsed = (text) => { + try { + return JSON.parse(text); + } catch { + return undefined; + } +}; +function setupBody(item) { + if (item.blocks) + return item.blocks + .map((block) => + block === BOUNDARY + ? `
${htmlEscape(block)}
` + : `
${markdown(block)}
`, + ) + .join(""); + const value = item.scope === "settings" && parsed(item.body); + return value ? `
${json(value)}
` : markdown(item.body); +} +let setupKey = ""; +function renderSetup(setup) { + const key = JSON.stringify(setup ?? null); + if (key === setupKey) return; + setupKey = key; + const { instructions = [], tools = [] } = setup ?? {}; + const count = tools.reduce((n, group) => n + group.tools.length, 0); + $("prompt-toggle").hidden = !instructions.length && !count; + $("tab-tools").textContent = `Tools · ${count}`; + const items = instructions + .map((item) => { + const scope = SCOPES[item.scope] ? item.scope : "other"; + const label = item.label.replace(/^(system|developer|user) · /, ""); + const at = Date.parse(item.timestamp); + return `
${SCOPES[scope] ?? "Context"}${htmlEscape(label)}${Number.isFinite(at) ? `${clock(at)}` : ""}
${setupBody(item)}
`; + }) + .join(""); + $("prompt-instructions").innerHTML = items + ? `

What the model received before its first response. Later context stays in the conversation.

${items}` + : `

No instructions recorded.

`; + $("prompt-tools").innerHTML = + tools + .map( + (group) => + `${group.note ? `

${htmlEscape(group.note)}

` : ""}${group.tools + .map( + (tool) => + `
${htmlEscape(tool.name)}${htmlEscape(String(tool.description ?? "").split("\n")[0])}
${markdown(tool.description)}
${tool.schema ? `
${json(tool.schema)}
` : ""}
`, + ) + .join("")}`, + ) + .join("") || `

No tool definitions recorded.

`; +} +function showPrompt(open) { + $("prompt").hidden = !open; + $("prompt-toggle").setAttribute("aria-expanded", String(open)); + if (open) $("prompt-close").focus(); + else $("prompt-toggle").focus(); +} +function showTab(name) { + for (const tab of ["instructions", "tools"]) { + $(`tab-${tab}`).setAttribute("aria-selected", String(tab === name)); + $(`prompt-${tab}`).hidden = tab !== name; + } +} +$("prompt-toggle").addEventListener("click", () => + showPrompt($("prompt").hidden), +); +$("prompt-close").addEventListener("click", () => showPrompt(false)); +$("tab-instructions").addEventListener("click", () => showTab("instructions")); +$("tab-tools").addEventListener("click", () => showTab("tools")); +addEventListener("keydown", (event) => { + if (event.key === "Escape" && !$("prompt").hidden) showPrompt(false); +}); $("search").addEventListener("input", render); $("theme").addEventListener("click", () => { document.documentElement.dataset.theme = diff --git a/scripts/session-debug.css b/scripts/session-debug.css index 26d0743f0..bc247d709 100644 --- a/scripts/session-debug.css +++ b/scripts/session-debug.css @@ -685,6 +685,21 @@ details.tool > summary::before { border-radius: 2px; background: var(--k-tool); } +/* Context injected mid-conversation: present, but well below the conversation. + `:where` keeps hover and open brightening it like any tool row. */ +details.tool:where(.context) > summary { + padding-block: 0.0625rem; + font-size: 0.625rem; + color: var(--faint); +} +details.tool:where(.context) > summary::before { + width: 0.25rem; + height: 0.25rem; + background: var(--line); +} +details.tool:where(.context) > summary .accent { + color: inherit; +} details.tool.pending > summary::before { background: none; box-shadow: inset 0 0 0 1.5px var(--k-tool); @@ -776,3 +791,266 @@ details[open] > :is(.details-body, pre) { .entry:has(.sent) h2 { color: var(--k-inference); } + +/* Prompt setup: a drawer over the conversation's right side. */ +#prompt-toggle { + flex: none; + padding: 0 0.75rem; + color: var(--muted); +} +#prompt-toggle[aria-expanded="true"] { + color: var(--text); + border-color: var(--accent); +} +#prompt { + position: fixed; + inset: 0 0 0 auto; + z-index: 10; + width: min(44rem, 92vw); + overflow-y: auto; + overscroll-behavior: contain; + background: var(--panel); + border-left: 1px solid var(--line); + box-shadow: var(--shadow); + padding: 0 1.5rem 3rem; +} +.prompt-head { + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 1; + margin: 0 -1.5rem; + padding: 0.75rem 1.5rem; + background: color-mix(in srgb, var(--panel) 90%, transparent); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--line); +} +.tabs { + display: flex; + gap: 0.25rem; +} +.prompt-head button { + height: 2rem; + padding: 0 0.75rem; + border: 1px solid transparent; + border-radius: 0.5rem; + background: none; + font-size: 0.8125rem; + color: var(--muted); +} +.prompt-head button:hover { + background: var(--hover); + color: var(--text); +} +.tabs [aria-selected="true"] { + background: var(--inset); + color: var(--text); + font-weight: 600; +} +#prompt-close { + width: 2rem; + padding: 0; + font-size: 1.125rem; +} +.prompt-head button:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); +} +.prompt-note { + margin: 0.75rem 0; + font-size: 0.75rem; + color: var(--faint); +} +/* One section per recorded item, keyed by the role it was sent as. */ +details.setup-item { + margin: 0 0 0.75rem; + border: 1px solid var(--line); + border-radius: 0.625rem; + background: var(--base); + padding: 0; +} +#prompt details.setup-item > summary { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 0.875rem; + list-style: none; + cursor: pointer; +} +#prompt details.setup-item > summary::-webkit-details-marker { + display: none; +} +.setup-label { + flex: 1; + font-weight: 600; +} +.setup-time { + font-size: 0.6875rem; + color: var(--faint); + font-variant-numeric: tabular-nums; +} +.scope { + flex: none; + padding: 0.0625rem 0.4375rem; + border-radius: 0.3125rem; + font-size: 0.625rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--scope); + background: color-mix(in srgb, var(--scope) 12%, transparent); + --scope: var(--muted); +} +.scope-system { + --scope: var(--k-tool); +} +.scope-developer { + --scope: var(--accent); +} +.scope-user { + --scope: var(--text); +} +.scope-settings, +.scope-other { + --scope: var(--faint); +} +:root[data-theme="dark"] .scope-system { + --scope: var(--k-inference); +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .scope-system { + --scope: var(--k-inference); + } +} +/* Recorded Markdown, read as a document. */ +.md { + padding: 0.25rem 0.875rem 0.5rem; + font-size: 0.8125rem; + line-height: 1.6; +} +details.setup-item > .md { + border-top: 1px solid var(--line); +} +.md-block + .md-block { + border-top: 1px dashed var(--line); + margin-top: 0.5rem; + padding-top: 0.5rem; +} +.md-boundary { + margin: 0.75rem 0; + padding: 0.25rem 0; + border-block: 1px solid var(--line); + font: 0.625rem / 1.5 var(--mono); + color: var(--faint); + text-align: center; +} +.md :is(h3, h4, h5, h6) { + margin: 1rem 0 0.25rem; + font-size: 0.8125rem; + font-weight: 650; +} +.md h3 { + font-size: 0.9375rem; +} +.md p { + margin: 0.375rem 0; +} +.md ul { + margin: 0.25rem 0; + padding-left: 1.125rem; +} +.md li.depth-1 { + margin-left: 1rem; +} +.md li.depth-2, +.md li.depth-3 { + margin-left: 2rem; +} +.md code { + font: 0.75rem var(--mono); + padding: 0 0.25rem; + border-radius: 0.25rem; + background: var(--inset); +} +.md-code { + padding: 0.5rem 0.75rem; + border-radius: 0.5rem; + background: var(--inset); +} +.md-code code { + padding: 0; + background: none; +} +.md-tag { + margin: 0.5rem 0 0.25rem; + font: 0.6875rem var(--mono); + color: var(--faint); +} +/* Tool definitions: name and summary line, opening to description and schema. */ +details.tool-def { + margin: 0; + border-bottom: 1px solid var(--line); + border-radius: 0; + padding: 0; + background: none; +} +#prompt details.tool-def > summary { + display: flex; + gap: 0.75rem; + align-items: baseline; + padding: 0.5rem 0.25rem; + cursor: pointer; + list-style: none; +} +#prompt details.tool-def > summary::-webkit-details-marker { + display: none; +} +#prompt details.tool-def > summary:hover { + background: var(--hover); +} +.tool-name { + flex: none; + font: 600 0.75rem var(--mono); +} +.tool-desc { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.75rem; + color: var(--muted); +} +details.tool-def[open] > .md { + padding-inline: 0.25rem; +} +.json { + margin: 0 0 0.75rem; + padding: 0.625rem 0.75rem; + border-radius: 0.5rem; + background: var(--inset); + color: var(--faint); +} +.md .json { + margin: 0.25rem 0; +} +.j-key { + color: var(--accent); +} +.j-str { + color: var(--text); +} +.j-num, +.j-lit { + color: var(--k-tool); +} +:root[data-theme="dark"] :is(.j-num, .j-lit) { + color: var(--k-inference); +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) :is(.j-num, .j-lit) { + color: var(--k-inference); + } +} diff --git a/scripts/session-debug.html b/scripts/session-debug.html index b4358c95b..4a1bf54bb 100644 --- a/scripts/session-debug.html +++ b/scripts/session-debug.html @@ -18,9 +18,20 @@
-

+

+ diff --git a/scripts/session-model.mjs b/scripts/session-model.mjs index f403e504e..a2358b6d4 100644 --- a/scripts/session-model.mjs +++ b/scripts/session-model.mjs @@ -206,19 +206,37 @@ export function sessionModel( context.push( entry.instructions( meta.timestamp, - "Recorded base instructions", + "Base instructions", typeof meta.base_instructions === "string" ? meta.base_instructions : (meta.base_instructions.text ?? JSON.stringify(meta.base_instructions)), + // Codex 0.156 sends these as the first developer message, with no + // top-level `instructions`. + { scope: "developer" }, ), ); - if (meta.dynamic_tools) + // Namespaced tools are flattened to `namespace.name`. + const tools = (meta.dynamic_tools ?? []) + .flatMap((t) => + t.type === "namespace" + ? (t.tools ?? []).map((x) => ({ ...x, name: `${t.name}.${x.name}` })) + : [t], + ) + .map((t) => ({ + name: t.name, + description: t.description, + schema: t.inputSchema ?? t.parameters, + })); + if (tools.length) context.push( - entry.instructions( + entry.tools( meta.timestamp, - "Recorded dynamic tool definitions", - JSON.stringify(meta.dynamic_tools, null, 2), + `Dynamic tools · ${tools.length} (native tools not recorded)`, + tools, + { + note: "Codex does not record its native tools, such as shell and apply_patch.", + }, ), ); } @@ -295,6 +313,30 @@ export function sessionModel( : null; } const identifiers = new Map(); + // Model switches are summarized in the header instead. + const all = [ + ...context, + ...base.entries.filter((e) => e.kind !== "model"), + ].map((e) => { + const key = `${e.timestamp ?? "meta"}-${e.kind}-${e.label}`; + const occurrence = identifiers.get(key) ?? 0; + identifiers.set(key, occurrence + 1); + return { + ...e, + id: `${key}-${occurrence}`, + turnId: + [...turns].reverse().find((t) => ms(t.start) <= ms(e.timestamp))?.id ?? + null, + }; + }); + // Context sent before the first response is the prompt setup, shown apart + // from the conversation; later context stays inline. + const first = all.findIndex((e) => + ["assistant", "tool", "sent"].includes(e.kind), + ); + const setup = all + .slice(0, first < 0 ? all.length : first) + .filter((e) => e.kind === "instructions"); return { ...base, provider, @@ -309,28 +351,35 @@ export function sessionModel( .find((r) => r.model && r.model !== "")?.model ?? model, effort, }, - // Model switches are summarized in the header instead. - entries: [ - ...context, - ...base.entries.filter((e) => e.kind !== "model"), - ].map((e) => { - const key = `${e.timestamp ?? "meta"}-${e.kind}-${e.label}`; - const occurrence = identifiers.get(key) ?? 0; - identifiers.set(key, occurrence + 1); - return { - ...e, - id: `${key}-${occurrence}`, - turnId: - [...turns].reverse().find((t) => ms(t.start) <= ms(e.timestamp)) - ?.id ?? null, - }; - }), + entries: all.filter((e) => !setup.includes(e)), + setup: { + // The system prompt leads, as the request's `system` field does. + instructions: setup + .filter((e) => !e.tools) + .sort((a, b) => Number(!!b.blocks) - Number(!!a.blocks)) + .map(({ id, timestamp, label, scope, preview, blocks }) => ({ + id, + timestamp, + label, + scope: scope ?? null, + body: preview, + blocks: blocks ?? null, + })), + tools: setup + .filter((e) => e.tools) + .map(({ id, label, note, tools }) => ({ + id, + label, + note: note ?? null, + tools, + })), + }, turns, timing: wallTiming(records, turns, provider), raw: records.map(publicRecord), limitations: provider === "codex" - ? "Recorded base instructions, dynamic tools, messages and usage. Native tool schemas and exact post-compaction inference requests may not be recorded. Encrypted reasoning is omitted." - : "Recorded messages and per-response usage. Buzz prompt additions and tool definitions are available for sessions launched by the updated plugin. Claude’s private base prompt, native tool schemas and exact inference requests are not exposed. Without completion records, duration is unknown or an inferred boundary.", + ? "Recorded base instructions, dynamic tools, run settings, messages and usage. Native tool schemas and exact post-compaction inference requests are not recorded. Encrypted reasoning is omitted." + : "Recorded system prompt and tool snapshots (when Claude Code writes them), injected context as rendered, messages and per-response usage. Request framing, such as billing and SDK system blocks, beta headers and deferred tool schemas, is not recorded. Without completion records, duration is unknown or an inferred boundary.", }; } diff --git a/scripts/session-viewer.mjs b/scripts/session-viewer.mjs index e5615c349..787e34d42 100644 --- a/scripts/session-viewer.mjs +++ b/scripts/session-viewer.mjs @@ -32,12 +32,35 @@ export const entry = { preview: label, html: `
${time(timestamp)}Switched to model: ${escapeHtml(label)}
`, }), - instructions: (timestamp, label, body) => ({ + // Model-visible context outside the conversation: one quiet, collapsed row. + // A one-line body, once unwrapped from ``, also shows inline. + // `extra.scope` is the role it was sent as: system, developer or user. + instructions: (timestamp, label, body, extra = {}) => { + const brief = body.replace(/<\/?system-reminder>/g, "").trim(); + return { + timestamp, + kind: "instructions", + label, + preview: body, + html: `
${escapeHtml(label)}${/^.{1,120}$/.test(brief) ? ` ${escapeHtml(brief)}` : ""}
${time(timestamp)}
${escapeHtml(body)}
`, + ...extra, + }; + }, + // Tool definitions as recorded, `[{ name, description, schema }]`, one + // disclosure per tool. + tools: (timestamp, label, tools, extra = {}) => ({ timestamp, kind: "instructions", label, - preview: body, - html: `
${time(timestamp)}
${escapeHtml(label)}
${expandable(body, 2)}
`, + tools, + ...extra, + preview: tools.map((tool) => tool.name).join(", "), + html: `
${escapeHtml(label)}
${time(timestamp)}${tools + .map( + ({ name, description, schema }) => + `
${escapeHtml(name)}
${escapeHtml([description, schema && text(schema)].filter(Boolean).join("\n\n"))}
`, + ) + .join("")}
`, }), // A Buzz turn shows its request, with the full turn context collapsed. user: (timestamp, body, request) => ({ diff --git a/tests/integration/session-debug.test.mjs b/tests/integration/session-debug.test.mjs index 45f01badd..7cf3a0c47 100644 --- a/tests/integration/session-debug.test.mjs +++ b/tests/integration/session-debug.test.mjs @@ -109,8 +109,140 @@ test("Codex exposes recorded context and counts usage only once", () => { assert.equal(result.turns[0].requests.length, 1); assert.equal(result.turns[0].estimate, 0.00025); assert.equal(result.turns[0].cost, null); - assert.match(result.entries[0].html, /<script>/); - assert.equal(result.entries[1].label, "Recorded dynamic tool definitions"); + const { instructions, tools } = result.setup; + assert.deepEqual( + instructions.map((e) => [e.label, e.scope]), + [ + ["Base instructions", "developer"], + ["Run settings", "settings"], + ], + ); + assert.equal(instructions[0].body, "base "); + assert.equal(tools[0].label, "Dynamic tools · 1 (native tools not recorded)"); + assert.match(tools[0].note, /native tools/); + assert.deepEqual(tools[0].tools, [ + { name: "tool", description: undefined, schema: { type: "object" } }, + ]); + assert.ok(result.entries.every((e) => e.kind !== "instructions")); +}); +test("Codex keeps prompt setup apart and shows later setting changes inline", () => { + const context = (n, p) => ({ + type: "turn_context", + timestamp: stamp(n), + payload: { turn_id: `t${n}`, model: "m", ...p }, + }); + const message = (n, role, text) => ({ + type: "response_item", + timestamp: stamp(n), + payload: { + type: "message", + role, + content: [ + { type: role === "assistant" ? "output_text" : "input_text", text }, + ], + }, + }); + const result = sessionModel([ + { type: "session_meta", payload: { id: "s", model_provider: "openai" } }, + context(0, { approval_policy: "never" }), + message(1, "developer", "lead"), + message(1, "user", "# AGENTS.md instructions for /x\n\nbe kind"), + message(2, "assistant", "ok"), + context(3, { approval_policy: "never" }), + context(4, { approval_policy: "on-request" }), + ]); + assert.deepEqual( + result.setup.instructions.map((e) => [e.label, e.scope]), + [ + ["Run settings", "settings"], + ["developer · multi agent role", "developer"], + ["user · AGENTS.md", "user"], + ], + ); + const settings = result.setup.instructions[0].body; + assert.match(settings, /"model_provider": "openai"/); + assert.doesNotMatch(settings, /turn_id/); + const inline = result.entries.filter((e) => e.kind === "instructions"); + assert.deepEqual( + inline.map((e) => [e.label, e.scope, e.timestamp]), + [["Run settings", "settings", stamp(4)]], + ); +}); +test("Claude puts the recorded system prompt and tools in the prompt setup", () => { + const attachment = (n, a, extra = {}) => ({ + type: "attachment", + timestamp: stamp(n), + attachment: a, + ...extra, + }); + const reminder = (n, type, text) => + attachment( + n, + { type }, + { rendered: [{ content: text }], renderedRole: "system" }, + ); + const systemPrompt = ["base ", "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"]; + const wire = { name: "Bash", description: "Run", input_schema: {} }; + const tools = [{ name: "Bash", description: "Run", schema: wire }]; + const result = sessionModel([ + { + type: "user", + uuid: "u", + timestamp: stamp(0), + message: { content: "hi" }, + }, + reminder(0, "date", "Today is 2026-10-10."), + attachment(0, { type: "credential_org", organizationUuid: "x" }), + attachment(0, { type: "prompt_snapshot", systemPrompt }), + { + type: "assistant", + timestamp: stamp(1), + message: { + id: "m", + model: "m1", + content: [{ type: "text", text: "ok" }], + }, + }, + attachment(1, { + type: "prompt_snapshot", + systemPrompt, + cliPrefix: "You are Claude Code.", + tools, + }), + reminder(2, "total_tokens_reminder", "9 left"), + attachment(2, { type: "prompt_snapshot", systemPrompt, tools }), + ]); + const { instructions, tools: groups } = result.setup; + assert.deepEqual( + instructions.map((e) => [e.label, e.scope]), + [ + ["System prompt", "system"], + ["system · date", "system"], + ], + ); + assert.equal(instructions[0].timestamp, stamp(0)); + assert.deepEqual(instructions[0].blocks, [ + "You are Claude Code.", + ...systemPrompt, + ]); + assert.deepEqual( + groups.map((g) => [g.label, g.tools]), + [ + [ + "Tools · 1", + [{ name: "Bash", description: "Run", schema: { input_schema: {} } }], + ], + ], + ); + // Later reminders stay inline, after the reply they followed. + const order = result.entries.map((e) => e.label); + assert.deepEqual(order, [ + "user", + "assistant", + "system · total tokens reminder", + ]); + assert.equal(result.entries[2].scope, "system"); + assert.match(result.entries[2].html, /<b>9 left<\/b>/); }); test("Claude deduplicates streamed blocks and ignores tool-result turns", () => { const records = [ From 430392dd1e5dcc23adaf5343e0cac8bb02a96ab4 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sun, 11 Oct 2026 16:50:21 -0700 Subject: [PATCH 09/39] fix(session-debug): let Vite's dependency scan resolve the inspector page Browser fixtures scan every HTML file under the repo root, and scripts/session-debug.html imported "client.js", which only the inspector's own server maps to a real file. The scan failed, Vite skipped pre-bundling and re-optimized mid-test, and the reloads broke browser journeys across both engines. The page now names its files as they are on disk and the server serves them under those names, so the vite.config.ts exclusion (which fixture servers never read) goes away. Co-authored-by: claude00 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Bradley Axen --- scripts/session-debug.html | 4 ++-- scripts/session-debug.mjs | 4 ++-- vite.config.ts | 9 +-------- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/scripts/session-debug.html b/scripts/session-debug.html index 4a1bf54bb..77208f0dc 100644 --- a/scripts/session-debug.html +++ b/scripts/session-debug.html @@ -4,7 +4,7 @@ Agent session - +
@@ -33,6 +33,6 @@
- + diff --git a/scripts/session-debug.mjs b/scripts/session-debug.mjs index acd110ee4..cb3b9cc7e 100644 --- a/scripts/session-debug.mjs +++ b/scripts/session-debug.mjs @@ -196,8 +196,8 @@ export async function createInspector({ } else { const name = new Map([ [`${base}/`, "session-debug.html"], - [`${base}/client.js`, "session-debug-client.js"], - [`${base}/style.css`, "session-debug.css"], + [`${base}/session-debug-client.js`, "session-debug-client.js"], + [`${base}/session-debug.css`, "session-debug.css"], ]).get(url.pathname); if (!name) { res.writeHead(404); diff --git a/vite.config.ts b/vite.config.ts index a6c66154f..157c717f4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -77,14 +77,7 @@ export default defineConfig(async ({ command, mode }) => { clearScreen: false, // Runtime preparation checks out the Buzz and Goose web UIs under target/. optimizeDeps: { - // The session inspector is served by its own Node server, which maps - // client.js to session-debug-client.js; it is not a Vite entry. - entries: [ - "**/*.html", - "!**/target/**", - "!**/coverage/**", - "!**/scripts/session-debug.html", - ], + entries: ["**/*.html", "!**/target/**", "!**/coverage/**"], }, server: { // Derived from this checkout's path, exactly as `just desktop` does, so From c41320fdbc81629057b540e58f9b8956696f7708 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 10 Oct 2026 17:10:10 -0700 Subject: [PATCH 10/39] [model] pick a mentioned agent's model and effort for its next run Mentioning your own Claude Code or Codex agent shows a gauge in the composer. Its popover is an effort slider with the model name in the slider's tab; the name opens a compact menu of the agent's models. The pick rides on that message as `model` and `effort` tags for that agent, and the host honors them only from the owner and only for listed models and levels. - Codex passes the pick to `turn/start` for that turn only. - Claude Code lists models from `initialize`, sends `set_model` and `apply_flag_settings` ahead of the picked turn, and restores its own on the next unpicked one. - The slider opens at the effort an unpicked run uses (Claude Code's session effort, Codex's configured effort or the model's default); moving back to it is no pick. - A configured Codex model its CLI does not list keeps the picker, with the listed models still pickable. Co-Authored-By: Claude Opus 5.5 Squashed-from: baxen/model Squashed-head: 053cee0dba93bb946f544c0c87804ceee90a49ef Signed-off-by: Bradley Axen Co-authored-by: Codex --- src/app/App.tsx | 300 +++++++++--------- src/bundled/claude-code/claude-testing.ts | 74 ++++- src/bundled/claude-code/claude.test.ts | 68 ++++ src/bundled/claude-code/claude.ts | 155 ++++++++- src/bundled/claude-code/index.ts | 1 + src/bundled/claude-code/runtime.test.ts | 100 +++++- src/bundled/claude-code/runtime.ts | 85 ++++- src/bundled/claude-code/sessions.test.ts | 46 +++ src/bundled/claude-code/sessions.ts | 93 +++++- src/bundled/codex/index.ts | 1 + src/bundled/codex/runtime.test.ts | 133 ++++++++ src/bundled/codex/runtime.ts | 119 +++++-- src/features/agents2/react.test.tsx | 117 ++++++- src/features/agents2/react.ts | 90 +++++- src/features/agents2/service.test.ts | 45 +++ src/features/agents2/service.ts | 66 +++- .../messages/ComposerModels.module.css | 196 ++++++++++++ src/features/messages/ComposerModels.tsx | 282 ++++++++++++++++ .../messages/MessageComposer.test.tsx | 245 +++++++++++++- src/features/messages/MessageComposer.tsx | 34 +- src/features/relay/mentions.test.ts | 52 +++ src/features/relay/messages.ts | 24 ++ src/shared/design-system/DESIGN.md | 5 +- src/shared/design-system/icons/index.ts | 3 + .../design-system/styles/components.css | 14 + src/shared/design-system/ui/Button.tsx | 2 +- src/shared/design-system/ui/registry.ts | 2 +- .../design-system/ui/ButtonSpecimens.tsx | 11 + 28 files changed, 2160 insertions(+), 203 deletions(-) create mode 100644 src/features/messages/ComposerModels.module.css create mode 100644 src/features/messages/ComposerModels.tsx diff --git a/src/app/App.tsx b/src/app/App.tsx index 3fe917608..63cab60d1 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -3,6 +3,7 @@ import { IdentitySetup } from "../features/identity/IdentitySetup"; import { MeSidebar } from "../bundled/me/MeSidebar"; import { ChannelSidebar } from "../features/channel-navigation/ChannelSidebar"; import { ChannelNavigationProvider } from "../features/channel-navigation/ChannelNavigationState"; +import { Agents2Context } from "../features/agents2/react"; import { ToastProvider } from "../shared/design-system/ui/Toast"; import { Button } from "../shared/design-system/ui/Button"; import { AgentWakeNotice } from "../features/agents/AgentWakeNotice"; @@ -148,16 +149,147 @@ function ConnectedApp({ services }: { services: AppServices }) { portalContainer={document.getElementById("buzz-toast-root") ?? undefined} > - - settings ? ( - + + settings ? ( + { + const client = services.communities.snapshot(); + void services.navigation.open({ + version: 1, + kind: "settings", + section, + ...(client.viewer && client.selected + ? { + scope: { + viewer: client.viewer, + communityOrigin: communityDestination( + client.selected, + ).url, + }, + } + : { scope: null }), + }); + }} + /> + ) : route.selected === "buzz.me/me" ? ( + + ) : ( + page.key === "buzz.me/me", + )} + sessionsEnabled={route.pages.some( + (page) => page.pluginId === "buzz.sessions", + )} + > + {pageNavigation} + + ) + } + navigationControls={ + + } + onCommunitySelect={(id) => { + const recovering = + services.navigation.snapshot().ingress && + services.navigation.snapshot().retryable; + services.communities.select(id); + if (!recovering) select("buzz.channels/channels"); + }} + // A scoped Settings target selects its community on the way. + onOpenTarget={(target) => void services.navigation.open(target)} + communities={services.communities} + invite={invite?.viewer === client.viewer ? invite : undefined} + onInviteClose={services.invites.clear} + settingsCards={services.settingsCards} + accountActions={services.accountActions} + onProfile={ + ownProfile && launcher.canOpen(ownProfile) + ? (trigger) => launcher.open(ownProfile, trigger) + : undefined + } + searchServices={services} + launchers={ + + } + companion={pageOwnsCompanion ? undefined : companion} + pages={startup === "ready" ? route.pages : []} + selected={route.selected} + navigationAttempt={route.state.attempt.id} + onSelect={select} + tone={presentation.tone} + workspace={ + startup === "ready" && route.page?.layout === "workspace" + } + > + + + + + {startup === "recovery" && !settings ? ( + + ) : (!route.state.ingress && route.failure) || + route.state.status === "failed" ? ( +
+

This destination couldn’t open

+

+ {(route.state.reason ?? route.failure) === "denied" + ? "This target needs its original account and an already joined community." + : route.state.ingress && !route.state.retryable + ? "This link is invalid or unsupported." + : "The destination is unavailable or isn’t supported yet. Your target has been kept for retry."} +

+ {(!route.state.ingress || route.state.retryable) && ( + + )} + +
+ ) : route.waiting ? ( +

Opening destination…

+ ) : settings ? ( + { const client = services.communities.snapshot(); void services.navigation.open({ @@ -177,146 +309,20 @@ function ConnectedApp({ services }: { services: AppServices }) { }); }} /> - ) : route.selected === "buzz.me/me" ? ( - Opening destination…

+ ) : route.page ? ( + - ) : ( - page.key === "buzz.me/me", - )} - sessionsEnabled={route.pages.some( - (page) => page.pluginId === "buzz.sessions", - )} - > - {pageNavigation} - - ) - } - navigationControls={ - - } - onCommunitySelect={(id) => { - const recovering = - services.navigation.snapshot().ingress && - services.navigation.snapshot().retryable; - services.communities.select(id); - if (!recovering) select("buzz.channels/channels"); - }} - // A scoped Settings target selects its community on the way. - onOpenTarget={(target) => void services.navigation.open(target)} - communities={services.communities} - invite={invite?.viewer === client.viewer ? invite : undefined} - onInviteClose={services.invites.clear} - settingsCards={services.settingsCards} - accountActions={services.accountActions} - onProfile={ - ownProfile && launcher.canOpen(ownProfile) - ? (trigger) => launcher.open(ownProfile, trigger) - : undefined - } - searchServices={services} - launchers={ - - } - companion={pageOwnsCompanion ? undefined : companion} - pages={startup === "ready" ? route.pages : []} - selected={route.selected} - navigationAttempt={route.state.attempt.id} - onSelect={select} - tone={presentation.tone} - workspace={startup === "ready" && route.page?.layout === "workspace"} - > - - - - - {startup === "recovery" && !settings ? ( - - ) : (!route.state.ingress && route.failure) || - route.state.status === "failed" ? ( -
-

This destination couldn’t open

-

- {(route.state.reason ?? route.failure) === "denied" - ? "This target needs its original account and an already joined community." - : route.state.ingress && !route.state.retryable - ? "This link is invalid or unsupported." - : "The destination is unavailable or isn’t supported yet. Your target has been kept for retry."} -

- {(!route.state.ingress || route.state.retryable) && ( - - )} - -
- ) : route.waiting ? ( -

Opening destination…

- ) : settings ? ( - { - const client = services.communities.snapshot(); - void services.navigation.open({ - version: 1, - kind: "settings", - section, - ...(client.viewer && client.selected - ? { - scope: { - viewer: client.viewer, - communityOrigin: communityDestination(client.selected) - .url, - }, - } - : { scope: null }), - }); - }} - /> - ) : startup === "loading" ? ( -

Opening destination…

- ) : route.page ? ( - - ) : null} -
+ ) : null} +
+
); diff --git a/src/bundled/claude-code/claude-testing.ts b/src/bundled/claude-code/claude-testing.ts index af8085c0e..20ea3fa02 100644 --- a/src/bundled/claude-code/claude-testing.ts +++ b/src/bundled/claude-code/claude-testing.ts @@ -11,6 +11,14 @@ export type FakeClaude = { received: Record[]; /** User messages it has taken, in order. */ prompts: string[]; + /** The session's effort, which `apply_flag_settings` changes. */ + effort: string; + /** The effort each user message was taken at. */ + efforts: string[]; + /** The session's model, which `set_model` changes. */ + model: string; + /** The model each user message was taken on. */ + models: string[]; /** When set, a turn stays open until `finish()`. */ hold: boolean; killed: boolean; @@ -19,6 +27,26 @@ export type FakeClaude = { exit(code: number | null, stderr?: string): void; }; +/** The models the fake lists, as Claude Code's `initialize` answer does. */ +export const MODELS = [ + { value: "default", resolvedModel: "claude-opus-5-5" }, + { + value: "opus", + resolvedModel: "claude-opus-5-5", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"], + }, + { + value: "haiku", + resolvedModel: "claude-haiku-5-5", + supportsEffort: true, + supportedEffortLevels: ["low", "high"], + }, + { value: "legacy", resolvedModel: "claude-legacy-1", supportsEffort: false }, +]; +const resolved = (model: string) => + MODELS.find((item) => item.value === model)?.resolvedModel ?? model; + /** A spawn whose `claude` processes answer like Claude Code. Sessions named in * `known` (or created by a process) can be resumed; others fail to load. */ export function fakeSpawn(known = new Set()) { @@ -43,6 +71,10 @@ export function fakeSpawn(known = new Set()) { options, received: [], prompts: [], + effort: "high", + efforts: [], + model: flag("--model") ?? "default", + models: [], hold: false, killed: false, emit(message) { @@ -78,9 +110,40 @@ export function fakeSpawn(known = new Set()) { const take = (message: Record) => { fake.received.push(message); if (message.type === "control_request") { + const request = message.request as Record; + const level = (request.settings as Record | undefined) + ?.effortLevel; + if ( + request.subtype === "apply_flag_settings" && + typeof level === "string" + ) + fake.effort = level; + if (request.subtype === "set_model") + fake.model = flag("--model") ?? "default"; + if ( + request.subtype === "set_model" && + typeof request.model === "string" + ) + fake.model = request.model; fake.emit({ type: "control_response", - response: { subtype: "success", request_id: message.request_id }, + response: { + subtype: "success", + request_id: message.request_id, + ...(request.subtype === "get_settings" + ? { + response: { + applied: { + effort: fake.effort, + model: resolved(fake.model), + }, + }, + } + : {}), + ...(request.subtype === "initialize" + ? { response: { models: MODELS } } + : {}), + }, }); return; } @@ -91,10 +154,17 @@ export function fakeSpawn(known = new Set()) { } const content = (message.message as { content: string }).content; fake.prompts.push(content); + fake.efforts.push(fake.effort); + fake.models.push(fake.model); if (!started) { started = true; known.add(session); - fake.emit({ type: "system", subtype: "init", session_id: session }); + fake.emit({ + type: "system", + subtype: "init", + session_id: session, + model: resolved(fake.model), + }); } open++; fake.emit({ diff --git a/src/bundled/claude-code/claude.test.ts b/src/bundled/claude-code/claude.test.ts index 62c73586f..d807135a5 100644 --- a/src/bundled/claude-code/claude.test.ts +++ b/src/bundled/claude-code/claude.test.ts @@ -82,6 +82,74 @@ it("answers both messages of a turn that took a second one mid-way", async () => await expect(second).resolves.toEqual({ ok: true }); }); +it("runs a turn at the effort asked for, and the next without one at the session's own", async () => { + const { spawn, processes } = fakeSpawn(); + const claude = await ClaudeProcess.start(spawn, { + ...launch, + sessionId: "s1", + }); + const fake = processes[0]; + if (!fake) throw new Error("no process"); + await claude.send("plain"); + expect(fake.received.map((message) => message.type)).toEqual([ + "control_request", + "user", + ]); + await claude.send("hard", { effort: "xhigh" }); + fake.hold = true; + const quick = claude.send("quick", { effort: "low" }); + // Folded into the running turn, a message without an effort keeps it. + const steer = claude.send("also"); + await flush(); + fake.finish(); + await Promise.all([quick, steer]); + fake.hold = false; + await claude.send("after"); + expect(fake.prompts).toEqual(["plain", "hard", "quick", "also", "after"]); + expect(fake.efforts).toEqual(["high", "xhigh", "low", "low", "high"]); + // The session's own level is read once, before the first change. + expect( + fake.received.filter( + (message) => + (message.request as { subtype?: string } | undefined)?.subtype === + "get_settings", + ), + ).toHaveLength(1); +}); + +it("runs a turn on the model asked for, and the next without one on the session's own", async () => { + const { spawn, processes } = fakeSpawn(); + const claude = await ClaudeProcess.start(spawn, { + ...launch, + model: "opus", + sessionId: "s1", + }); + const fake = processes[0]; + if (!fake) throw new Error("no process"); + await expect(claude.models()).resolves.toEqual( + expect.arrayContaining([expect.objectContaining({ value: "haiku" })]), + ); + await claude.send("plain"); + fake.hold = true; + const quick = claude.send("quick", { model: "haiku" }); + // Folded into the running turn, a message without a model keeps it. + const steer = claude.send("also"); + await flush(); + fake.finish(); + await Promise.all([quick, steer]); + fake.hold = false; + await claude.send("after"); + expect(fake.models).toEqual(["opus", "haiku", "haiku", "opus"]); + // The session reports its own model, not the picked one. + await expect(claude.readModel()).resolves.toBe("claude-opus-5-5"); + expect( + fake.received + .map((message) => message.request as { subtype?: string; model?: string }) + .filter((request) => request?.subtype === "set_model") + .map((request) => request.model), + ).toEqual(["haiku", "opus"]); +}); + it("settles on each result when Claude Code does not report idle", async () => { const { spawn, processes } = fakeSpawn(); const claude = await ClaudeProcess.start( diff --git a/src/bundled/claude-code/claude.ts b/src/bundled/claude-code/claude.ts index c163acc52..4e2330532 100644 --- a/src/bundled/claude-code/claude.ts +++ b/src/bundled/claude-code/claude.ts @@ -39,6 +39,18 @@ const FINAL_TOOL = `mcp__${TOOL_SERVER}__send`; type Message = Record & { type?: string; subtype?: string }; +/** A model Claude Code lists as one a session can switch to. */ +export type ClaudeModel = Readonly<{ + /** What `set_model` takes: an alias such as `opus`, or a model id. */ + value: string; + resolvedModel?: string; + displayName?: string; + supportsEffort?: boolean; + supportedEffortLevels?: readonly string[]; +}>; +/** What the next turn is asked to run with; absent is the session's own. */ +export type TurnPick = Readonly<{ model?: string; effort?: string }>; + /** How a burst of work ended: every message sent so far has been answered, * or the process is gone. */ export type Settled = Readonly<{ @@ -76,6 +88,8 @@ export class ClaudeProcess { private pending = 0; /** It has begun a turn, so its session loaded. */ private began = false; + /** The model the session reported when it began. */ + private reported: string | undefined; private waiters: ((settled: Settled) => void)[] = []; private buffer = ""; private stderr = ""; @@ -84,16 +98,32 @@ export class ClaudeProcess { /** It reports idle; an older `claude` that does not is settled per result. */ private reportsState = false; private requests = 0; + /** Control requests awaiting Claude Code's answer, by request id. */ + private calls = new Map void>(); + /** The models Claude Code lists, from its answer to `initialize`. */ + private listed: Promise = Promise.resolve([]); + /** The model the next turn runs on; undefined is the session's own. */ + private pickedModel: string | undefined; + /** The effort the next turn runs at; undefined is the session's own. */ + private effort: string | undefined; + /** The session's own effort, read before the first change to restore it. */ + private ownEffort: string | undefined; + private readingEffort: Promise | undefined; readonly exited: Promise; private constructor( private readonly process: HostProcess, resume: string | undefined, private tools: ToolServer | undefined, + /** The session's own model, as launched; undefined is Claude Code's default. */ + private readonly ownModel: string | undefined, ) { this.sessionId = resume; this.exited = process.exited.then((code) => { this.alive = false; + for (const resolve of this.calls.values()) + resolve({ subtype: "error", error: "Claude Code exited" }); + this.calls.clear(); const error = this.lastError ?? (this.stderr.trim().split("\n").at(-1) || @@ -155,6 +185,7 @@ export class ClaudeProcess { process, launch.resume ?? launch.sessionId, launch.tools, + launch.model, ); for (const data of earlyErrors) claude.readError(data); for (const data of early) claude.read(data); @@ -165,7 +196,7 @@ export class ClaudeProcess { * works for. Loads settings and tools now rather than on the first message. */ async initialize(launch: Pick) { if (launch.tools) this.tools = launch.tools; - await this.request({ + const { written, answer } = this.request({ subtype: "initialize", appendSystemPrompt: launch.systemPrompt, ...(launch.tools @@ -179,6 +210,15 @@ export class ClaudeProcess { } : {}), }); + await written; + this.listed = answer.then((response) => { + const models = (response.response as Message | undefined)?.models; + return Array.isArray(models) + ? models.filter( + (model): model is ClaudeModel => typeof model?.value === "string", + ) + : []; + }); } get running() { @@ -190,15 +230,43 @@ export class ClaudeProcess { get started() { return this.began; } + get model() { + return this.reported; + } + /** The models this session can switch to, without sending a turn. */ + models() { + return this.listed; + } + /** The effort a turn without a pick runs at, when Claude Code reports it. */ + async readEffort() { + await this.readOwnEffort(); + return this.ownEffort; + } + /** Settings resolve the default model without sending a paid user turn. */ + async readModel() { + // Settings report a picked model, which is not the session's own. + if (this.reported || this.pickedModel !== undefined) return this.reported; + const answer = await this.call({ subtype: "get_settings" }); + const applied = (answer.response as Message | undefined)?.applied as + | Message + | undefined; + if (typeof applied?.model === "string") this.reported = applied.model; + return this.reported; + } /** Sends one user message. Resolves when it, and anything sent after it, has - * been answered and the session is idle again. */ - send(text: string): Promise { + * been answered and the session is idle again. A new turn runs with the + * picked model and effort, or the session's own without them; a message + * folded into a running turn changes only what it asks for. */ + send(text: string, { model, effort }: TurnPick = {}): Promise { if (!this.alive) return Promise.resolve({ ok: false, error: "Claude Code is not running", }); + // The model first: effort applies to whichever model runs. + this.setModel(model !== undefined || !this.busy ? model : this.pickedModel); + this.setEffort(effort !== undefined || !this.busy ? effort : this.effort); this.pending++; const settled = new Promise((resolve) => this.waiters.push(resolve), @@ -224,12 +292,74 @@ export class ClaudeProcess { private write(message: Message) { return this.process.write(`${JSON.stringify(message)}\n`); } - private async request(request: Message) { - await this.write({ + /** Writes a control request: `written` settles with the write, `answer` + * with Claude Code's response. */ + private request(request: Message) { + const id = `buzz-${++this.requests}`; + const answer = new Promise((resolve) => + this.calls.set(id, resolve), + ); + const written = this.write({ type: "control_request", - request_id: `buzz-${++this.requests}`, + request_id: id, request, }); + return { written, answer }; + } + /** Sends a control request and resolves with Claude Code's response. */ + private call(request: Message): Promise { + const { written, answer } = this.request(request); + return written.then(() => answer); + } + /** Switches model without restarting, as the SDK's `setModel` does. Back to + * the session's own is the model it was launched with, or the default. A + * new model misses the prompt cache, which the owner accepts by picking. */ + private setModel(model: string | undefined) { + if (model === this.pickedModel) return; + this.pickedModel = model; + const next = model ?? this.ownModel; + void this.call({ + subtype: "set_model", + ...(next ? { model: next } : {}), + }).then((answer) => { + if (answer.subtype !== "success") + console.warn("Claude Code did not change its model", answer.error); + }); + } + /** Changes effort without restarting, which keeps the prompt cache: Claude + * Code sends effort per message. Clearing the flag setting would not restore + * the session's own level, so that is read before the first change and set + * back explicitly. Written ahead of the message without waiting, as Claude + * Code takes its input in order, so a turn never waits on it. */ + private setEffort(effort: string | undefined) { + if (effort === this.effort) return; + void this.readOwnEffort(); + const level = effort ?? this.ownEffort; + if (!level) { + console.warn("Claude Code's own effort is unknown; keeping the last one"); + return; + } + this.effort = effort; + void this.call({ + subtype: "apply_flag_settings", + settings: { effortLevel: level }, + }).then((answer) => { + if (answer.subtype !== "success") + console.warn("Claude Code did not change its effort", answer.error); + }); + } + /** Read once, ahead of the first change, so it is the session's own. */ + private readOwnEffort() { + this.readingEffort ??= this.call({ subtype: "get_settings" }).then( + (answer) => { + const applied = (answer.response as Message | undefined)?.applied as + | Message + | undefined; + if (typeof applied?.effort === "string") + this.ownEffort = applied.effort; + }, + ); + return this.readingEffort; } private settle(settled: Settled) { this.pending = 0; @@ -260,12 +390,23 @@ export class ClaudeProcess { private handle(message: Message) { if (typeof message.session_id === "string" && message.session_id) this.sessionId = message.session_id; - if (message.type === "system" && message.subtype === "init") + if (message.type === "system" && message.subtype === "init") { this.began = true; + // A picked model is not the session's own. + if (typeof message.model === "string" && this.pickedModel === undefined) + this.reported = message.model; + } if (message.type === "control_request") { void this.answer(message); return; } + if (message.type === "control_response") { + const response = (message.response ?? {}) as Message; + const id = String(response.request_id); + this.calls.get(id)?.(response); + this.calls.delete(id); + return; + } if ( message.type === "system" && message.subtype === "session_state_changed" diff --git a/src/bundled/claude-code/index.ts b/src/bundled/claude-code/index.ts index a31de4171..87a16c73e 100644 --- a/src/bundled/claude-code/index.ts +++ b/src/bundled/claude-code/index.ts @@ -51,6 +51,7 @@ 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, }); diff --git a/src/bundled/claude-code/runtime.test.ts b/src/bundled/claude-code/runtime.test.ts index 00e8eac8b..ab58a6eda 100644 --- a/src/bundled/claude-code/runtime.test.ts +++ b/src/bundled/claude-code/runtime.test.ts @@ -4,7 +4,7 @@ import type { Host } from "../../features/host/service"; import type { EventData, RelayEvent } from "../../features/relay/events"; import type { RelayData } from "../../features/relay/service"; import { fakeSpawn, flush } from "./claude-testing"; -import { ClaudeRuntime, DEFAULT_CONFIG } from "./runtime"; +import { ClaudeRuntime, DEFAULT_CONFIG, modelName } from "./runtime"; afterEach(() => { vi.restoreAllMocks(); @@ -118,6 +118,104 @@ it("hands a mention to its thread's session", async () => { expect(published).toEqual([]); }); +const EFFORTS = [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra high" }, +]; +it("knows the agent's own model once one of its sessions has begun", async () => { + const mention = message("2", "@Claude can you look?", { + tags: [ + ["h", channel], + ["p", self], + ], + }); + const { runtime, deliver } = setup([mention]); + // Before then nothing says which model it runs, nor what else it could. + expect(await runtime.models(self)).toEqual({ + current: "", + models: [{ id: "", label: "Claude Code", efforts: EFFORTS }], + }); + await deliver({ type: "mention", event: mention }); + await flush(10); + expect((await runtime.models(self)).current).toBe("opus"); +}); +it("discovers models and effort without a turn or retaining a per-agent spare", async () => { + const { runtime, claudes } = setup(); + runtime.sync([{ pubkey: self, config: DEFAULT_CONFIG } as Agent]); + try { + expect(await runtime.models(self)).toEqual({ + current: "opus", + models: [ + { id: "opus", label: "Opus 5.5", efforts: EFFORTS, effort: "high" }, + { + id: "haiku", + label: "Haiku 5.5", + efforts: [EFFORTS[0], EFFORTS[2]], + effort: "high", + }, + { id: "legacy", label: "Legacy 1", efforts: [] }, + ], + }); + expect(claudes().filter((process) => !process.killed)).toHaveLength(1); + expect(claudes().every((process) => process.prompts.length === 0)).toBe( + true, + ); + } finally { + runtime.dispose(); + } +}); +it("still offers a configured model Claude Code does not list", async () => { + const { runtime } = setup(); + const config = { ...DEFAULT_CONFIG, model: "claude-sonnet-9-9" }; + runtime.sync([{ pubkey: self, config } as Agent]); + try { + const { current, models } = await runtime.models(self); + expect(current).toBe("claude-sonnet-9-9"); + expect(models.map((model) => model.label)).toEqual([ + "Sonnet 9.9", + "Opus 5.5", + "Haiku 5.5", + "Legacy 1", + ]); + } finally { + runtime.dispose(); + } +}); +it("runs a mention on the model and effort its delivery picked", async () => { + const mention = message("2", "@Claude think hard", { + tags: [ + ["h", channel], + ["p", self], + ], + }); + const { runtime, claudes, agent } = setup([mention]); + runtime.sync([{ pubkey: self, config: DEFAULT_CONFIG } as Agent]); + expect((await runtime.models(self)).current).toBe("opus"); + await runtime.run({ + trigger: { type: "mention", event: mention }, + channelId: channel, + agent, + config: DEFAULT_CONFIG, + model: "haiku", + effort: "low", + signal: new AbortController().signal, + } as Delivery); + await flush(10); + const claude = claudes().find((process) => process.prompts.length); + expect(claude?.models).toEqual(["haiku"]); + expect(claude?.efforts).toEqual(["low"]); + expect((await runtime.models(self)).current).toBe("opus"); + runtime.dispose(); +}); +it("reads model ids and aliases as names", () => { + expect(modelName("claude-opus-5-5")).toBe("Opus 5.5"); + expect(modelName("claude-sonnet-4-5-20250929")).toBe("Sonnet 4.5"); + expect(modelName("claude-opus-4[1m]")).toBe("Opus 4"); + expect(modelName("opus")).toBe("Opus"); + expect(modelName("my-proxy/model")).toBe("my-proxy/model"); +}); it("answers Claude's tool calls as the agent, in the thread it is working in", async () => { const root = message("1", "Build is red"); const mention = message("2", "@Claude can you look?", { diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts index c0f2b271e..dd5a91757 100644 --- a/src/bundled/claude-code/runtime.ts +++ b/src/bundled/claude-code/runtime.ts @@ -10,6 +10,8 @@ import type { Agent, AgentHandle, Delivery, + Effort, + Models, } from "../../features/agents2/service"; import type { Host } from "../../features/host/service"; import type { EventData } from "../../features/relay/events"; @@ -27,6 +29,15 @@ import { } from "./prompt"; import { AgentSessions, localSessions, Spare } from "./sessions"; +/** What a session can switch to between turns. Claude Code runs `max` as + * `xhigh` in a stream-json session, so it is not offered. */ +const EFFORTS = [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra high" }, +]; + export type Config = Readonly<{ /** A Claude model or alias; empty uses Claude Code's own default. */ model: string; @@ -128,6 +139,59 @@ export class ClaudeRuntime { this.notify(); } + /** The models the agent's next turn can run on, with their effort levels. + * Without a pick it uses its own: the configured one, or else the one its + * sessions report. `default` duplicates a listed model, so it is not offered. */ + async models(pubkey: string): Promise { + const entry = this.agents.get(pubkey); + const configured = entry?.config.model.trim(); + const { + models: listed, + model: reported, + effort, + } = (await entry?.sessions.metadata()) ?? { models: [] }; + // A turn without a pick keeps the session's effort on any model. + const resting = (efforts: readonly Effort[]) => + effort && efforts.some((level) => level.id === effort) ? { effort } : {}; + const own = configured || reported || ""; + const offered = listed.filter((model) => model.value !== "default"); + const models = offered.map((model) => { + const efforts = + model.supportsEffort === false + ? [] + : EFFORTS.filter( + (level) => + !model.supportedEffortLevels || + model.supportedEffortLevels.includes(level.id), + ); + return { + id: model.value, + label: modelName(model.resolvedModel ?? model.value), + efforts, + ...resting(efforts), + }; + }); + const current = + offered.find( + (model) => own === model.value || own === model.resolvedModel, + )?.value ?? own; + // An own model Claude Code does not list is still the one it runs. + return models.some((model) => model.id === current) + ? { current, models } + : { + current, + models: [ + { + id: current, + label: current ? modelName(current) : "Claude Code", + efforts: EFFORTS, + ...resting(EFFORTS), + }, + ...models, + ], + }; + } + /** Conversations each agent has running, for its status view. */ sessions(pubkey: string) { let view = this.views.get(pubkey); @@ -154,7 +218,14 @@ export class ClaudeRuntime { this.agents.clear(); } - async run({ trigger, agent, config: raw, channelId }: Delivery) { + async run({ + trigger, + agent, + config: raw, + channelId, + model, + effort, + }: Delivery) { const settings = config(raw); if ( trigger.type !== "timer" && @@ -211,6 +282,7 @@ export class ClaudeRuntime { const done = entry.sessions.deliver(key, text, event.created_at, { steer: steerPrompt(text), fresh: async () => (await prompt(true)).text, + pick: { ...(model ? { model } : {}), ...(effort ? { effort } : {}) }, }); this.notify(); this.watch(done, async (error) => { @@ -451,3 +523,14 @@ export class ClaudeRuntime { } } } + +/** `claude-opus-5-5` reads as `Opus 5.5`, and an alias such as `opus` as + * `Opus`. Anything else is shown as it is. */ +export function modelName(model: string) { + const id = + /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?:\[.*\])?$/.exec(model); + const family = id?.[1] ?? (/^[a-z]+$/.test(model) ? model : undefined); + if (!family) return model; + const name = family[0]?.toUpperCase() + family.slice(1); + return id ? `${name} ${id[2]}${id[3] ? `.${id[3]}` : ""}` : name; +} diff --git a/src/bundled/claude-code/sessions.test.ts b/src/bundled/claude-code/sessions.test.ts index 478c70500..191d77dda 100644 --- a/src/bundled/claude-code/sessions.test.ts +++ b/src/bundled/claude-code/sessions.test.ts @@ -307,6 +307,21 @@ it("starts over when a saved session no longer loads", async () => { expect(store.get("c/root")).toMatchObject({ id: "new-1", seen: 9 }); }); +it("runs a turn with the model and effort it was delivered with, starting over included", async () => { + const store = memoryStore(); + store.set("c/root", { id: "lost", seen: 4, at: 1 }); + const { pool, claudes } = sessions({ store }); + await pool.deliver("c/root", "hello", 9, { + fresh: async () => "hello again", + pick: { model: "haiku", effort: "low" }, + }); + await pool.deliver("c/root", "next", 10); + const fresh = claudes().find((process) => process.prompts.length); + expect(fresh?.prompts).toEqual(["hello again", "next"]); + expect(fresh?.models).toEqual(["haiku", "opus"]); + expect(fresh?.efforts).toEqual(["low", "high"]); +}); + it("runs any number of conversations at once", async () => { const { pool, live } = sessions({ hold: true }); for (let index = 0; index < 12; index++) @@ -404,3 +419,34 @@ it("keeps saved sessions per agent in storage", () => { data.set("buzz.claude-code.sessions.v1", "not json"); expect(one.get("c/root")).toBeUndefined(); }); + +it("shares a temporary metadata lookup, releases its process, and refreshes after settings change", async () => { + const { pool, claudes, live, setModel } = sessions(); + const [first, second] = await Promise.all([pool.metadata(), pool.metadata()]); + expect(first).toEqual(second); + expect(first.model).toBe("claude-opus-5-5"); + expect(claudes()).toHaveLength(1); + expect(live()).toHaveLength(0); + await pool.metadata(); + expect(claudes()).toHaveLength(1); + setModel("haiku"); + pool.reconfigure(); + expect((await pool.metadata()).model).toBe("claude-haiku-5-5"); + expect(live()).toHaveLength(0); + pool.dispose(); +}); + +it("does not start metadata discovery after its agent is disposed during launch", async () => { + let release!: () => void; + const launched = new Promise((resolve) => { + release = resolve; + }); + const { pool, claudes } = sessions({ launched }); + const result = pool.metadata(); + await flush(); + pool.dispose(); + const stopped = expect(result).rejects.toThrow("stopped"); + release(); + await stopped; + expect(claudes()).toHaveLength(0); +}); diff --git a/src/bundled/claude-code/sessions.ts b/src/bundled/claude-code/sessions.ts index 0b80ab674..b5a23ba37 100644 --- a/src/bundled/claude-code/sessions.ts +++ b/src/bundled/claude-code/sessions.ts @@ -6,9 +6,11 @@ import { ClaudeProcess, type ClaudeLaunch, + type ClaudeModel, type Settled, type Spawn, type ToolServer, + type TurnPick, } from "./claude"; /** A conversation's process stops after this long without a message. */ @@ -54,6 +56,17 @@ type Live = { export class AgentSessions { private readonly live = new Map(); private readonly opening = new Map>(); + private discovery: + | { + fingerprint: string; + result: Promise<{ + models: readonly ClaudeModel[]; + model: string | undefined; + effort: string | undefined; + }>; + process?: ClaudeProcess | undefined; + } + | undefined; private disposed = false; private readonly now: () => number; private readonly newId: () => string; @@ -75,9 +88,68 @@ export class AgentSessions { })); } + /** Read defaults without assigning the shared spare to this agent. */ + async metadata() { + const fingerprint = this.options.fingerprint(); + const read = async (process: ClaudeProcess) => { + const [models, model, effort] = await Promise.all([ + process.models(), + process.readModel(), + process.readEffort(), + ]); + return { models, model, effort }; + }; + for (const live of this.live.values()) + if (live.fingerprint === fingerprint && live.process.running) { + const metadata = await read(live.process); + const discovered = + this.discovery?.fingerprint === fingerprint + ? await this.discovery.result + : undefined; + return { ...metadata, model: metadata.model ?? discovered?.model }; + } + if (this.disposed) + return { models: [], model: undefined, effort: undefined }; + if (this.discovery?.fingerprint === fingerprint) + return this.discovery.result; + this.clearDiscovery(); + const discovery: NonNullable = { + fingerprint, + result: Promise.resolve().then(async () => { + const launch = await this.options.launch(); + if (this.disposed || this.discovery !== discovery) + throw new Error("Claude Code was stopped"); + const process = await ClaudeProcess.start(this.options.spawn, { + cwd: launch.cwd, + ...(launch.model ? { model: launch.model } : {}), + systemPrompt: "", + }); + discovery.process = process; + try { + if (this.disposed || this.discovery !== discovery) + throw new Error("Claude Code was stopped"); + return await read(process); + } finally { + await process.kill(); + discovery.process = undefined; + } + }), + }; + this.discovery = discovery; + void discovery.result.catch(() => { + if (this.discovery === discovery) this.discovery = undefined; + }); + return discovery.result; + } + private clearDiscovery() { + void this.discovery?.process?.kill(); + this.discovery = undefined; + } + /** Sends `text` into the conversation `key`, and resolves when the session * has answered it. `steer` replaces `text` when the session is mid-turn; - * `fresh` builds it again for a new session when the saved one is lost. */ + * `fresh` builds it again for a new session when the saved one is lost; + * `pick` is the model and effort the turn was asked to run with. */ async deliver( key: string, text: string, @@ -85,7 +157,12 @@ export class AgentSessions { { steer = text, fresh, - }: Readonly<{ steer?: string; fresh?: () => Promise }> = {}, + pick = {}, + }: Readonly<{ + steer?: string; + fresh?: () => Promise; + pick?: TurnPick; + }> = {}, ): Promise { if (this.disposed) return { ok: false, error: "Claude Code was stopped" }; // A message that arrives while its conversation's process starts uses it. @@ -103,7 +180,7 @@ export class AgentSessions { } if (live) { this.touch(key, live, seen); - return this.answer(key, live, live.process.busy ? steer : text); + return this.answer(key, live, live.process.busy ? steer : text, pick); } const saved = this.options.store.get(key); const starting = this.start(saved?.id).catch((error) => { @@ -119,13 +196,13 @@ export class AgentSessions { void live.process.kill(); return { ok: false, error: "Claude Code was stopped" }; } - const delivered = await this.answer(key, live, text); + const delivered = await this.answer(key, live, text, pick); // A saved session that no longer loads starts over rather than failing // every message in the conversation, shown the conversation so far. if (!delivered.ok && saved && !live.process.started) { this.options.store.delete(key); this.drop(key, live); - return this.deliver(key, fresh ? await fresh() : text, seen); + return this.deliver(key, fresh ? await fresh() : text, seen, { pick }); } return delivered; } @@ -133,6 +210,7 @@ export class AgentSessions { /** Stops idle processes started with old settings; busy ones stop when they * next fall idle. */ reconfigure() { + this.clearDiscovery(); const fingerprint = this.options.fingerprint(); for (const [key, live] of this.live) if (live.fingerprint !== fingerprint && !live.process.busy) @@ -141,6 +219,7 @@ export class AgentSessions { dispose() { this.disposed = true; + this.clearDiscovery(); for (const [key, live] of this.live) this.drop(key, live); } @@ -181,8 +260,8 @@ export class AgentSessions { if (this.live.get(key) === live) this.drop(key, live); }); } - private async answer(key: string, live: Live, text: string) { - const settled = await live.process.send(text); + private async answer(key: string, live: Live, text: string, pick: TurnPick) { + const settled = await live.process.send(text, pick); if (this.live.get(key) === live) { this.touch(key, live); if (live.fingerprint !== this.options.fingerprint() && !live.process.busy) diff --git a/src/bundled/codex/index.ts b/src/bundled/codex/index.ts index 37195bef2..1c836296f 100644 --- a/src/bundled/codex/index.ts +++ b/src/bundled/codex/index.ts @@ -41,6 +41,7 @@ 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, }); diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts index b2d6599e0..cbc1c7d38 100644 --- a/src/bundled/codex/runtime.test.ts +++ b/src/bundled/codex/runtime.test.ts @@ -76,8 +76,21 @@ function fixture( data: [ { model: "test-model", + displayName: "Test Model", isDefault: true, defaultReasoningEffort: "medium", + supportedReasoningEfforts: ["low", "medium", "high"].map( + (reasoningEffort) => ({ reasoningEffort, description: "" }), + ), + }, + { + model: "deep-model", + displayName: "", + isDefault: false, + defaultReasoningEffort: "high", + supportedReasoningEfforts: ["high", "xhigh"].map( + (reasoningEffort) => ({ reasoningEffort, description: "" }), + ), }, ], nextCursor: null, @@ -550,6 +563,126 @@ it("reopens the shared server after it exits", async () => { await f.runtime.run(f.delivery("again")); await vi.waitFor(() => expect(servers(f)).toHaveLength(2)); }); +it("discovers models and efforts before any turn, sharing one read-only lookup", async () => { + const f = fixture(); + const agent = { pubkey, config: { workspace: "/tmp/codex-test" } } as Agent; + const one = f.runtime.preview(agent); + const two = f.runtime.preview(agent); + f.runtime.sync([agent]); + expect(one).toBe(two); + await expect(one).resolves.toEqual({ + current: "test-model", + models: [ + { + id: "test-model", + label: "Test Model", + efforts: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + effort: "medium", + }, + { + id: "deep-model", + label: "deep-model", + efforts: [ + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra high" }, + ], + effort: "high", + }, + ], + }); + expect(f.spawn).toHaveBeenCalledTimes(1); + expect(f.spawn.mock.calls[0]?.[1]?.cwd).toBe("~/.buzz"); + expect(f.starts()).toEqual([]); + expect(f.sent.some((wire) => wire.method === "thread/start")).toBe(false); + expect(f.process.end).not.toHaveBeenCalled(); + expect(f.runtime.preview(agent)).toBe(one); + f.runtime.sync([]); + f.spawn.mockRejectedValueOnce(new Error("offline")); + await expect(f.runtime.preview(agent)).rejects.toThrow("offline"); +}); +it("shares model discovery with active turns and another agent", async () => { + const f = fixture(); + await f.runtime.run(f.delivery("work")); + await vi.waitFor(() => expect(f.starts()).toHaveLength(1)); + const b = second(f); + await Promise.all([ + f.runtime.preview(f.delivery("unused").agent as unknown as Agent), + f.runtime.preview(b.agent as unknown as Agent), + ]); + expect(servers(f)).toHaveLength(1); + expect(f.process.end).not.toHaveBeenCalled(); + f.complete(); + await vi.waitFor(() => expect(f.publish).toHaveBeenCalledTimes(1)); +}); +it("still offers the listed models when the configured one is not listed", async () => { + const f = fixture(); + const agent = { + pubkey, + config: { workspace: "/tmp/codex-test", model: "newer-model" }, + } as Agent; + const { current, models } = await f.runtime.preview(agent); + expect(current).toBe("newer-model"); + expect(models.map((model) => [model.id, model.efforts.length])).toEqual([ + ["newer-model", 0], + ["test-model", 3], + ["deep-model", 2], + ]); +}); +it("previews each model at the configured effort when it has it", async () => { + const f = fixture(); + const agent = { + pubkey, + config: { workspace: "/tmp/codex-test", effort: "low" }, + } as Agent; + const { models } = await f.runtime.preview(agent); + expect(models.map((model) => model.effort)).toEqual(["low", "high"]); +}); +it("starts a turn at the effort its owner picked when the model supports it", async () => { + const f = fixture(); + await f.runtime.run({ ...f.delivery("@Codex hard"), effort: "high" }); + await vi.waitFor(() => expect(f.starts()).toHaveLength(1)); + await f.runtime.run({ + ...f.delivery("@Codex other", "e".repeat(64)), + effort: "max", + }); + await vi.waitFor(() => expect(f.starts()).toHaveLength(2)); + expect( + f.starts().map((wire) => (wire.params as { effort?: string }).effort), + ).toEqual(["high", "low"]); +}); +it("starts a turn on the model its owner picked, at an effort that model supports", async () => { + const f = fixture(); + const run = async ( + thread: string, + pick: { model?: string; effort?: string }, + ) => { + const before = f.starts().length; + await f.runtime.run({ ...f.delivery("@Codex", thread), ...pick }); + await vi.waitFor(() => expect(f.starts()).toHaveLength(before + 1)); + }; + await run("a".repeat(64), { model: "deep-model", effort: "xhigh" }); + // The configured `low` is not one of its levels, so it runs at its default. + await run("b".repeat(64), { model: "deep-model" }); + // A model Codex no longer lists falls back to the agent's own. + await run("c".repeat(64), { model: "gone-model" }); + expect( + f.starts().map((wire) => { + const { model, effort } = wire.params as { + model: string; + effort: string; + }; + return { model, effort }; + }), + ).toEqual([ + { model: "deep-model", effort: "xhigh" }, + { model: "deep-model", effort: "high" }, + { model: "test-model", effort: "low" }, + ]); +}); it("serializes owner follow-ups and routes tool replies to the latest accepted request", async () => { const f = fixture(); await f.runtime.run(f.delivery("first")); diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index 7f0f9cef7..47559abbf 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -2,6 +2,7 @@ import type { Agent, AgentHandle, Delivery, + Models, } from "../../features/agents2/service"; import type { RelayData } from "../../features/relay/service"; import type { EventData } from "../../features/relay/events"; @@ -11,6 +12,7 @@ import { config, defaults, absoluteWorkspace, + effortName, validWorkspace, type Config, } from "./config"; @@ -40,6 +42,9 @@ type Request = { /** Latest request's thread, independent of session scope; absent in DMs. */ toolRoot?: string; interest: string; + /** The model and effort the owner picked for this turn. */ + model?: string; + effort?: string; }; type Saved = { threadId: string; workspace: string }; type Active = { @@ -94,6 +99,8 @@ export class CodexRuntime { private stopping = new Map>(); private views = new Map(); private listeners = new Set<() => void>(); + /** Read-only model discovery, kept per agent until its settings change. */ + private previews = new Map }>(); constructor( private readonly spawn: Spawn, /** The owner's connection to each agent's own community. */ @@ -108,6 +115,76 @@ export class CodexRuntime { }; sessions = (pubkey: string): readonly SessionView[] => this.views.get(pubkey) ?? EMPTY; + preview(agent: Agent) { + const settings = config(agent.config); + const key = JSON.stringify([ + settings.workspace, + settings.model, + settings.effort, + ]); + const existing = this.previews.get(agent.pubkey); + if (existing?.key === key) return existing.ready; + const server = this.open(); + const rpc = server.rpc; + const ready = (async () => { + const workspace = await this.workspace(settings.workspace); + await server.opening; + const normalized = await rpc.request<{ + config: { model?: string | null }; + }>("config/read", { cwd: workspace }); + const models = await listModels(rpc); + const model = + settings.model || + normalized.config.model || + models.find((row) => row.isDefault)?.model; + const offered = models.map((row) => { + const efforts = row.supportedReasoningEfforts.map( + ({ reasoningEffort: id }) => ({ id, label: effortName(id) }), + ); + // As a run picks it: the configured effort if the model has it. + const effort = efforts.some((level) => level.id === settings.effort) + ? settings.effort + : row.defaultReasoningEffort; + return { + id: row.model, + label: row.displayName || row.model, + efforts, + ...(effort ? { effort } : {}), + }; + }); + // A configured model this Codex does not list still leaves the + // listed ones to pick, so the picker stays. + return !model || models.some((row) => row.model === model) + ? { current: model ?? "", models: offered } + : { + current: model, + models: [{ id: model, label: model, efforts: [] }, ...offered], + }; + })(); + const entry = { key, ready }; + this.previews.set(agent.pubkey, entry); + void ready.catch(() => { + if (this.previews.get(agent.pubkey) === entry) + this.previews.delete(agent.pubkey); + }); + return ready; + } + + private async workspace(path: string) { + if (absoluteWorkspace(path)) return path; + let directory = ""; + const process = await this.spawn("workspace", { + cwd: path, + onStdout: (data) => { + directory += data; + }, + }); + const code = await process.exited; + const workspace = directory.trim(); + if (code !== 0 || !absoluteWorkspace(workspace)) + throw new Error("Could not resolve the default Codex workspace."); + return workspace; + } private status(pubkey: string, key: string, status: string, detail = "") { // A stopped agent's draining turns don't bring its sessions back. if (!this.entries.has(pubkey)) return; @@ -120,6 +197,10 @@ export class CodexRuntime { } sync(agents: readonly Agent[]) { const present = new Set(agents.map((agent) => agent.pubkey)); + for (const pubkey of this.previews.keys()) + if (!present.has(pubkey)) { + this.previews.delete(pubkey); + } for (const [pubkey, entry] of this.entries) if (!present.has(pubkey)) { entry.abort.abort(); @@ -127,7 +208,7 @@ export class CodexRuntime { this.views.delete(pubkey); if (this.server) this.stop(this.server, pubkey, entry); } - if (!this.entries.size) this.close(); + if (!present.size) this.close(); for (const listener of this.listeners) listener(); } /** Other agents share the server, so a stopped agent's turns interrupt @@ -155,6 +236,7 @@ export class CodexRuntime { }); } dispose() { + this.previews.clear(); for (const entry of this.entries.values()) entry.abort.abort(); this.entries.clear(); this.views.clear(); @@ -262,6 +344,8 @@ export class CodexRuntime { agent, settings, ...(toolRoot ? { toolRoot } : {}), + ...(delivery.model ? { model: delivery.model } : {}), + ...(delivery.effort ? { effort: delivery.effort } : {}), conversation: { channelId, name: channel?.name ?? channelId, @@ -573,21 +657,8 @@ export class CodexRuntime { signal.throwIfAborted(); // The process host expands ~/.buzz and creates it; Codex's RPC needs // an absolute path rather than shell-style home notation. - let workspace = settings.workspace; - if (!absoluteWorkspace(workspace)) { - let directory = ""; - const process = await this.spawn("workspace", { - cwd: workspace, - onStdout: (data) => { - directory += data; - }, - }); - const code = await process.exited; - workspace = directory.trim(); - if (code !== 0 || !absoluteWorkspace(workspace)) - throw new Error("Could not resolve the default Codex workspace."); - signal.throwIfAborted(); - } + const workspace = await this.workspace(settings.workspace); + signal.throwIfAborted(); active.workspace = workspace; const existing = entry.saved[key]; const normalized = await rpc.request<{ @@ -597,16 +668,26 @@ export class CodexRuntime { }; }>("config/read", { cwd: workspace }); const models = await listModels(rpc); - const model = + const own = settings.model || normalized.config.model || models.find((row) => row.isDefault)?.model; - const selected = models.find((row) => row.model === model); + // Model and effort are per turn, so a pick changes nothing after it. + const picked = models.find((row) => row.model === request.model); + const selected = picked ?? models.find((row) => row.model === own); if (!selected) throw new Error( "The selected Codex model is unavailable. Choose an available model in Settings.", ); - const effort = settings.effort || selected.defaultReasoningEffort; + const model = selected.model; + const supported = selected.supportedReasoningEfforts.map( + (level) => level.reasoningEffort, + ); + // A picked model may not support the configured effort. + const effort = + [request.effort, settings.effort].find( + (level) => level && supported.includes(level), + ) ?? selected.defaultReasoningEffort; const mcp = Object.fromEntries( Object.entries(normalized.config.mcp_servers ?? {}).map( ([name, server]) => [ diff --git a/src/features/agents2/react.test.tsx b/src/features/agents2/react.test.tsx index fc14c1a28..7dbdc721a 100644 --- a/src/features/agents2/react.test.tsx +++ b/src/features/agents2/react.test.tsx @@ -1,9 +1,18 @@ // @vitest-environment jsdom -import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { act, waitFor, renderHook } from "@testing-library/react"; import { expect, it } from "vitest"; -import { useAgent2 } from "./react"; +import { useAgent2, useMentionModels, Agents2Context } from "./react"; import type { Agent, Agents2 } from "./service"; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + it("still finds a saved agent whose type plugin is not registered", () => { const agent = { pubkey: "a".repeat(64), type: "gone/echo" } as Agent; const agents2 = { @@ -16,3 +25,107 @@ it("still finds a saved agent whose type plugin is not registered", () => { const other = renderHook(() => useAgent2(agents2, "b".repeat(64))); expect(other.result.current).toBeUndefined(); }); + +const high = { id: "high", label: "High" }; +const models = (current: string) => ({ + current, + models: [{ id: current, label: current, efforts: [high] }], +}); + +it("loads models and their effort levels asynchronously before an agent runs", async () => { + const pending = deferred>(); + const agent = { pubkey: "agent", type: "codex", name: "Codex" } as Agent; + const snapshot = { status: "ready", agents: [agent] }; + const types = [{ key: "codex", models: () => pending.promise }]; + const service = { + subscribe: () => () => {}, + snapshot: () => snapshot, + types: () => types, + } as unknown as Agents2; + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const { result } = renderHook(() => useMentionModels(["agent"]), { + wrapper, + }); + expect(result.current).toEqual([ + { pubkey: "agent", name: "Codex", current: "", models: [], loading: true }, + ]); + await act(async () => { + pending.resolve(models("GPT test")); + }); + expect(result.current).toEqual([ + { pubkey: "agent", name: "Codex", ...models("GPT test") }, + ]); +}); + +it("offers nothing for a single model without effort levels", async () => { + const agents = ["fixed", "tunable"].map( + (pubkey) => ({ pubkey, type: pubkey, name: pubkey }) as Agent, + ); + const snapshot = { status: "ready", agents }; + const types = [ + { + key: "fixed", + models: async () => ({ + current: "only", + models: [{ id: "only", label: "Only", efforts: [] }], + }), + }, + { key: "tunable", models: async () => models("Tunable") }, + ]; + const service = { + subscribe: () => () => {}, + snapshot: () => snapshot, + types: () => types, + } as unknown as Agents2; + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const { result } = renderHook(() => useMentionModels(["fixed", "tunable"]), { + wrapper, + }); + // Both lookups settle together, so the tunable one is the barrier. + await waitFor(() => + expect(result.current.map((choice) => choice.pubkey)).toEqual(["tunable"]), + ); +}); + +it("discards pending metadata after the mentioned agent changes", async () => { + const pending = deferred>(); + const agents = ["old", "new"].map( + (pubkey) => ({ pubkey, type: "type", name: pubkey }) as Agent, + ); + const snapshot = { status: "ready", agents }; + const types = [ + { + key: "type", + models: async (agent: Agent) => + agent.pubkey === "old" ? pending.promise : models("New model"), + }, + ]; + const service = { + subscribe: () => () => {}, + snapshot: () => snapshot, + types: () => types, + } as unknown as Agents2; + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const { result, rerender } = renderHook( + ({ pubkey }) => useMentionModels([pubkey]), + { initialProps: { pubkey: "old" }, wrapper }, + ); + rerender({ pubkey: "new" }); + await waitFor(() => expect(result.current[0]?.current).toBe("New model")); + await act(async () => { + pending.resolve(models("Old model")); + }); + expect(result.current.map((choice) => choice.pubkey)).toEqual(["new"]); +}); diff --git a/src/features/agents2/react.ts b/src/features/agents2/react.ts index 511c86a3f..ed031ad74 100644 --- a/src/features/agents2/react.ts +++ b/src/features/agents2/react.ts @@ -1,7 +1,14 @@ -import { useEffect, useMemo, useSyncExternalStore } from "react"; +import { + createContext, + useContext, + useEffect, + useMemo, + useState, + useSyncExternalStore, +} from "react"; import type { ChannelQueries } from "../relay/contracts"; import type { ChannelChoice } from "./attention"; -import type { Agents2 } from "./service"; +import type { Agents2, Models } from "./service"; const noSubscribe = () => () => {}; const none = () => undefined; @@ -40,6 +47,85 @@ export function useAgent2(agents2: Agents2 | undefined, pubkey: string) { return agent ? { agent, type } : undefined; } +/** The app's Agents2, for surfaces such as the composer that plugins do not own. */ +export const Agents2Context = createContext(undefined); + +export type MentionModelChoice = Models & + Readonly<{ pubkey: string; name: string; loading?: boolean }>; +/** The mentioned agents, of the viewer's own, whose type offers a choice of + * model or effort. */ +export function useMentionModels( + pubkeys: readonly string[], +): readonly MentionModelChoice[] { + const agents2 = useContext(Agents2Context); + const subscribe = agents2?.subscribe ?? noSubscribe; + const snapshot = useSyncExternalStore( + subscribe, + agents2 ? agents2.snapshot : none, + none, + ); + const types = useSyncExternalStore( + subscribe, + agents2 ? agents2.types : none, + none, + ); + const key = [...new Set(pubkeys)].join(":"); + const [resolved, setResolved] = useState<{ + snapshot: typeof snapshot; + types: typeof types; + key: string; + choices: readonly MentionModelChoice[]; + }>(); + useEffect(() => { + let current = true; + void Promise.all( + key.split(":").map(async (pubkey): Promise => { + const agent = snapshot?.agents.find((item) => item.pubkey === pubkey); + const type = agent && types?.find((item) => item.key === agent.type); + if (!agent || !type?.models) return []; + try { + const { current, models } = await type.models(agent); + return models.length > 1 || models.some((m) => m.efforts.length) + ? [{ pubkey, name: agent.name, current, models }] + : []; + } catch (error) { + console.warn( + `Agent type ${type.key} could not list its models`, + error, + ); + return []; + } + }), + ).then((choices) => { + if (current) + setResolved({ snapshot, types, key, choices: choices.flat() }); + }); + return () => { + current = false; + }; + }, [snapshot, types, key]); + // Never offer a previous agent/config's choices while the new lookup settles. + return resolved?.snapshot === snapshot && + resolved?.types === types && + resolved?.key === key + ? resolved.choices + : key.split(":").flatMap((pubkey) => { + const agent = snapshot?.agents.find((item) => item.pubkey === pubkey); + const type = agent && types?.find((item) => item.key === agent.type); + return agent && type?.models + ? [ + { + pubkey, + name: agent.name, + current: "", + models: [], + loading: true, + }, + ] + : []; + }); +} + const noList = () => undefined; /** Channels a watch can be scoped to, by name, from the viewer's joined roster. */ export function useChannelChoices( diff --git a/src/features/agents2/service.test.ts b/src/features/agents2/service.test.ts index 6a48bf26e..93f2111f3 100644 --- a/src/features/agents2/service.test.ts +++ b/src/features/agents2/service.test.ts @@ -246,6 +246,20 @@ async function setup({ }, }, }), + models: async () => ({ + current: "fast", + models: [ + { + id: "fast", + label: "Fast", + efforts: [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + }, + { id: "deep", label: "Deep", efforts: [{ id: "max", label: "Max" }] }, + ], + }), run, }; plugin.agents2.register(type); @@ -344,6 +358,37 @@ it("treats a reply to something the agent published as addressed", async () => { expect(run.mock.calls[1]?.[0].trigger.type).toBe("mention"); }); +it("passes the model and effort its owner asked for, and no other", async () => { + const { service, run, emit } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + const asked = (id: string, pubkey: string, ...tags: string[][]) => + event(id, { pubkey, tags: [["h", channel], ["p", bot], ...tags] }); + emit({ + events: [ + asked("owner", viewer, ["effort", bot, "high"]), + asked("stranger", other, ["effort", bot, "high"], ["model", bot, "deep"]), + asked("unlisted", viewer, ["effort", bot, "max"]), + asked("elsewhere", viewer, ["effort", sibling, "high"]), + asked("model", viewer, ["model", bot, "deep"], ["effort", bot, "max"]), + asked("mismatch", viewer, ["model", bot, "deep"], ["effort", bot, "low"]), + asked("unknown", viewer, ["model", bot, "gone"], ["effort", bot, "low"]), + ], + }); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(7)); + expect( + run.mock.calls.map(([{ model, effort }]) => ({ model, effort })), + ).toEqual([ + { model: undefined, effort: "high" }, + { model: undefined, effort: undefined }, + { model: undefined, effort: undefined }, + { model: undefined, effort: undefined }, + // An effort counts only for the model the run uses. + { model: "deep", effort: "max" }, + { model: "deep", effort: undefined }, + { model: undefined, effort: "low" }, + ]); +}); + it("keeps the in-flight run and the queue across edits; each job reads the agent as it starts", async () => { const { service, run, emit, native } = await setup(); await service.create({ type: "example/echo", name: "Echo" }); diff --git a/src/features/agents2/service.ts b/src/features/agents2/service.ts index 9c9fe592c..7c5fc46bc 100644 --- a/src/features/agents2/service.ts +++ b/src/features/agents2/service.ts @@ -14,6 +14,7 @@ import { removeAgentFromChannels } from "../agents/relay-removal"; import { communityDestination, relayOrigin } from "../communities/destination"; import type { Communities } from "../communities/service"; import type { RelayEvent } from "../relay/events"; +import { EFFORT_TAG, MODEL_TAG } from "../relay/messages"; import type { LiveBatch } from "../relay/incoming"; import { relayPartition } from "../relay/partition"; import type { RelayData } from "../relay/service"; @@ -129,10 +130,27 @@ export type Delivery = Readonly<{ agent: AgentHandle; /** The agent's config when this run started. */ config: Config; + /** The model the owner asked for in the triggering message: one of the + * type's `models`. Absent means the agent's own model. */ + model?: string; + /** The effort level the owner asked for in the triggering message: one of + * that model's `efforts`. Absent means the agent's own default. */ + effort?: string; /** Aborts on the run's deadline, or when the agent is removed or its type is * replaced. Advisory: the handle keeps working, so finishing up is harmless. */ signal: AbortSignal; }>; +export type Effort = Readonly<{ id: string; label: string }>; +/** A model with its effort levels, lowest first. */ +export type Model = Readonly<{ + id: string; + label: string; + efforts: readonly Effort[]; + /** The effort a run on it uses without a pick, when known. */ + effort?: string; +}>; +/** The models an owner can pick from, and the id of the one used without a pick. */ +export type Models = Readonly<{ current: string; models: readonly Model[] }>; export type AgentType = { id: string; title: string; @@ -148,6 +166,10 @@ export type AgentType = { Peek?: ComponentType>; /** The type's settings tabs, in order. The host adds Attention after them. */ tabs?: readonly AgentTab[]; + /** Models and effort levels the owner can pick for the next run while + * mentioning the agent. Discovery may be async but must not start a turn. + * The picks reach that run as `Delivery.model` and `Delivery.effort`. */ + models?(agent: Agent): Models | Promise; run?(delivery: Delivery): void | Promise; /** Per-run deadline in milliseconds, up to 30 minutes; defaults to 60 seconds. */ timeoutMs?: number; @@ -216,6 +238,39 @@ const bounded = (set: Set, id: string) => { set.add(id); if (set.size > SEEN_LIMIT) set.delete(set.values().next().value as string); }; +/** The model and effort the triggering message asks of this agent. Only the + * owner picks them, so nobody else in a channel can run the agent at their + * level, and only ones the type lists: effort from the model the run uses. */ +async function pickFor( + trigger: Trigger, + agent: Agent, + type: RegisteredAgentType, +): Promise<{ model?: string; effort?: string }> { + if (trigger.type === "timer" || trigger.event.pubkey !== agent.owner) + return {}; + const asked = (name: string) => + trigger.event.tags.find( + (tag) => tag[0] === name && tag[1] === agent.pubkey, + )?.[2]; + const model = asked(MODEL_TAG); + const effort = asked(EFFORT_TAG); + if (!model && !effort) return {}; + try { + const offered = await type.models?.(agent); + const picked = offered?.models.find((item) => item.id === model); + const runs = + picked ?? offered?.models.find((item) => item.id === offered.current); + return { + ...(picked ? { model: picked.id } : {}), + ...(effort && runs?.efforts.some((level) => level.id === effort) + ? { effort } + : {}), + }; + } catch (error) { + console.warn(`Agent type ${type.key} could not list its models`, error); + return {}; + } +} /** What Agents2 needs of the communities: the viewer, the selected one, and * a connection to any joined one without selecting it. */ @@ -1010,17 +1065,20 @@ export class Agents2Service extends Service implements Agents2 { }); try { await Promise.race([ - Promise.resolve().then(() => - run( + Promise.resolve().then(async () => { + const pick = await pickFor(job.trigger, agent, type); + signal.throwIfAborted(); + return run( Object.freeze({ trigger: job.trigger, ...(job.channelId ? { channelId: job.channelId } : {}), agent: handle, config: agent.config, + ...pick, signal, }), - ), - ), + ); + }), new Promise((_, reject) => { if (signal.aborted) reject(signal.reason); signal.addEventListener("abort", () => reject(signal.reason), { diff --git a/src/features/messages/ComposerModels.module.css b/src/features/messages/ComposerModels.module.css new file mode 100644 index 000000000..cb6ee099b --- /dev/null +++ b/src/features/messages/ComposerModels.module.css @@ -0,0 +1,196 @@ +/* The controls are the popup: no surface around them. */ +.popup { + display: grid; + gap: var(--space-3); + width: 20rem; + max-width: var(--available-width); + outline: none; +} + +.agent { + display: grid; + gap: var(--space-2); +} + +.model { + display: grid; + min-width: 0; + max-width: 100%; +} + +.slider { + --track: calc(var(--space-8) + var(--space-2)); + --thumb-height: var(--space-8); + --thumb-width: var(--space-8); + --shoulder: var(--space-2); + --tab-inset: calc(var(--track) / 2); + display: grid; + width: 100%; +} + +.control { + position: relative; + display: flex; + align-items: center; + width: 100%; + height: var(--track); + cursor: pointer; + touch-action: none; + user-select: none; +} + +.slider[data-disabled] .control { + cursor: not-allowed; +} + +.track { + position: relative; + width: 100%; + height: 100%; + border: 1px solid var(--border-standard); + border-radius: var(--radius-pill); + background: var(--surface-popover); + box-shadow: var(--shadow-sm); +} + +.indicator { + inset-block-start: 50%; + max-height: var(--space-6); + border-radius: var(--radius-pill); + background: var(--affordance-subtle); + margin-inline-start: var(--space-1); + transform: translateY(-50%); + transition: width var(--duration-settle) var(--easing-settle); +} + +/* Without a pick it still shows the level the run uses, if known. */ +.slider[data-unknown] .indicator { + visibility: hidden; +} + +.stop { + position: absolute; + inset-block-start: 50%; + inset-inline-start: calc( + var(--thumb-width) / + 2 + + var(--stop) * + (100% - var(--thumb-width)) + ); + width: var(--space-half); + height: var(--space-1); + border-radius: var(--radius-pill); + background: var(--text-subtle); + transform: translate(-50%, -50%); + pointer-events: none; +} + +.thumb { + width: var(--thumb-width); + height: var(--thumb-height); + border: var(--space-1) solid transparent; + border-radius: var(--radius-pill); + background: var(--affordance-prominent); + background-clip: padding-box; + transition: inset-inline-start var(--duration-settle) var(--easing-settle); +} + +.slider[data-unknown] .thumb { + background-color: var(--affordance-subtle); +} + +html[data-keyboard-navigation] .thumb:focus-within { + outline: 2px solid var(--border-focus); + outline-offset: 2px; +} + +.slider[data-disabled] .thumb { + background-color: var(--affordance-disabled); +} + +/* A single continuous rim: the tab covers the track's top stroke, while + * concave shoulders turn its sides into that stroke without a square seam. */ +.tab { + position: relative; + z-index: 1; + justify-self: end; + margin-inline: var(--tab-inset); + translate: 0 1px; + display: flex; + align-items: center; + gap: var(--space-2); + max-width: calc(100% - 2 * var(--tab-inset)); + padding: var(--space-1) var(--space-2); + border: 1px solid var(--border-standard); + border-block-end: 0; + border-start-start-radius: var(--radius-row); + border-start-end-radius: var(--radius-row); + background: var(--surface-popover); + color: var(--text-standard); + white-space: nowrap; + font-size: var(--text-caption); + line-height: var(--text-caption--line-height); + letter-spacing: var(--text-caption--letter-spacing); + font-weight: var(--text-caption--font-weight); +} + +.tab::before, +.tab::after { + content: ""; + position: absolute; + inset-block-end: 0; + width: var(--shoulder); + height: var(--shoulder); + background: radial-gradient( + circle at top left, + transparent calc(var(--shoulder) - 1px), + var(--border-standard) calc(var(--shoulder) - 1px), + var(--border-standard) var(--shoulder), + var(--surface-popover) var(--shoulder) + ); +} + +.tab::before { + inset-inline-end: 100%; +} + +.tab::after { + inset-inline-start: 100%; + transform: scaleX(-1); +} + +.about { + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-subtle); + font-size: var(--text-caption); + line-height: var(--text-caption--line-height); + letter-spacing: var(--text-caption--letter-spacing); + font-weight: var(--text-caption--font-weight); +} + +.slider[data-dragging] .thumb, +.slider[data-dragging] .indicator, +html[data-keyboard-navigation] .slider * { + transition: none; +} + +@media (prefers-reduced-motion: reduce) { + .slider * { + transition: none; + } +} + +.loading { + display: grid; + gap: var(--space-2); + padding: var(--space-3); + border: 1px solid var(--border-standard); + border-radius: var(--radius-row); + background: var(--surface-popover); + color: var(--text-subtle); + font-size: var(--text-caption); + line-height: var(--text-caption--line-height); + letter-spacing: var(--text-caption--letter-spacing); + font-weight: var(--text-caption--font-weight); +} diff --git a/src/features/messages/ComposerModels.tsx b/src/features/messages/ComposerModels.tsx new file mode 100644 index 000000000..6b80dcbe7 --- /dev/null +++ b/src/features/messages/ComposerModels.tsx @@ -0,0 +1,282 @@ +import { Popover } from "@base-ui/react/popover"; +import { Slider } from "@base-ui/react/slider"; +import { + useRef, + useState, + type CSSProperties, + type ReactNode, + type Ref, +} from "react"; +import { useMentionModels } from "../agents2/react"; +import type { MentionModelChoice } from "../agents2/react"; +import type { Effort } from "../agents2/service"; +import type { MentionPicks, RunPick } from "../relay/messages"; +import { + CaretDownIcon, + GaugeIcon, +} from "../../shared/design-system/icons/index"; +import { IconButton } from "../../shared/design-system/ui/IconButton"; +import { Button } from "../../shared/design-system/ui/Button"; +import { + MenuRoot, + MenuTrigger, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, +} from "../../shared/design-system/ui/Menu"; +import styles from "./ComposerModels.module.css"; + +/** Model and effort for the next run of each mentioned agent of the viewer's + * that offers a choice. Unpicked agents run with their own. */ +export function ComposerModels({ + pubkeys, + value, + disabled, + onChange, +}: { + pubkeys: readonly string[]; + value: MentionPicks; + disabled: boolean; + onChange(value: MentionPicks): void; +}) { + const [open, setOpen] = useState(false); + const choices = useMentionModels(pubkeys); + const thumb = useRef(null); + const trigger = useRef(null); + if (!choices.length) return null; + const picked = choices.flatMap((choice) => { + const { model, effort } = value[choice.pubkey] ?? {}; + const runs = runModel(choice, model); + const text = [ + model !== undefined && runs.label, + runs.efforts.find((item) => item.id === effort)?.label, + ] + .filter(Boolean) + .join(" · "); + return text ? [choices.length > 1 ? `${choice.name}: ${text}` : text] : []; + }); + const pick = (pubkey: string, next: RunPick) => { + const { [pubkey]: _, ...rest } = value; + onChange(next.model || next.effort ? { ...rest, [pubkey]: next } : rest); + }; + return ( + + + ); +} + +/** The model a run uses: the picked one, or else the agent's own. */ +const runModel = (choice: MentionModelChoice, picked: string | undefined) => + choice.models.find((item) => item.id === picked) ?? + choice.models.find((item) => item.id === choice.current) ?? + choice.models[0] ?? { id: "", label: "", efforts: [] }; + +function AgentChoice({ + choice, + named, + value, + disabled, + pick, + thumb, +}: { + choice: MentionModelChoice; + named: boolean; + value: RunPick; + disabled: boolean; + pick(next: RunPick): void; + thumb?: Ref; +}) { + if (choice.loading) + return ( +
+ {named && {choice.name}} + Loading model and effort… +
+ ); + const runs = runModel(choice, value.model); + const several = choice.models.length > 1; + // Picking the agent's own model is no pick; an effort the new model lacks + // falls back to its default. + const chooseModel = (id: string) => { + const next = choice.models.find((item) => item.id === id); + const effort = next?.efforts.some((item) => item.id === value.effort) + ? value.effort + : undefined; + pick({ + ...(id !== choice.current ? { model: id } : {}), + ...(effort ? { effort } : {}), + }); + }; + const model = several ? ( +
+ + + {runs.label} + +
+ ) : ( + {runs.label} + ); + return ( +
+ {named && {choice.name}} + {runs.efforts.length > 0 ? ( + + pick({ + ...(value.model ? { model: value.model } : {}), + ...(effort ? { effort } : {}), + }) + } + {...(thumb ? { thumb } : {})} + /> + ) : ( + model + )} +
+ ); +} + +function EffortSlider({ + name, + model, + efforts, + resting, + value, + disabled, + pick, + thumb, +}: { + name: string; + model: ReactNode; + efforts: readonly Effort[]; + /** The effort the run uses without a pick, when known. */ + resting: string | undefined; + value: string | undefined; + disabled: boolean; + pick(effort: string | undefined): void; + thumb?: Ref; +}) { + const index = efforts.findIndex((effort) => effort.id === value); + const set = index >= 0; + const own = efforts.findIndex((effort) => effort.id === resting); + const at = set ? index : Math.max(own, 0); + const last = Math.max(efforts.length - 1, 1); + const known = set || own >= 0; + const level = known ? (efforts[at]?.label ?? "") : "Default"; + // A tap on the thumb's resting stop changes no value, so commits pick too. + // The run's own effort is no pick. + const choose = (next: number) => { + const effort = efforts[next]?.id; + pick(effort === resting ? undefined : effort); + }; + return ( + + {/* Keep the model outside the drag target, on the track's attached tab. */} +
+ {model} + +
+ + + + {efforts.map((effort, stop) => ( + + ))} + level} + /> + + +
+ ); +} diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index 3b682edc5..03836128a 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -44,6 +44,8 @@ import { UploadError, UPLOAD_FAILURES } from "../relay/attachments"; import { ToastProvider } from "../../shared/design-system/ui/Toast"; import type { OutgoingEvent } from "../relay/outbox"; import { ConversationPresentation } from "../conversation/ConversationPresentation"; +import { Agents2Context } from "../agents2/react"; +import type { Agents2 } from "../agents2/service"; import { MessageComposer, type MessageComposerProps } from "./MessageComposer"; import { createRelaySession, type RelaySession } from "../relay/session"; import { keypair, metadata, roster, signed } from "../relay/testing"; @@ -106,6 +108,7 @@ function mount( options: Partial = {}, control?: AgentControl, viewer?: string, + agents2?: Agents2, ) { let commands: ComposerToolProps; const completionRequests: ComposerCompletionProps["publish"][] = []; @@ -331,11 +334,13 @@ function mount( bindChoices(); let presented = true; const tree = () => ( - - - + + + + + ); const view = render(tree(), { reactStrictMode: true, @@ -2045,6 +2050,236 @@ it("retries publish-unknown attachment sends with the same signed event and no r expect(h.uploadCalls).toHaveLength(1); }); +it("opens the model picker while discovery is pending, then fills its controls", async () => { + const models = { + current: "fast", + models: [ + { + id: "fast", + label: "Fast", + efforts: [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + }, + ], + }; + const pending = deferred(); + const snapshot = { + status: "ready", + agents: [{ pubkey: first.pubkey, name: "Honey", type: "test/agent" }], + }; + const types = [{ key: "test/agent", models: () => pending.promise }]; + const agents2 = { + subscribe: () => () => {}, + snapshot: () => snapshot, + types: () => types, + } as unknown as Agents2; + const h = mount({}, undefined, undefined, agents2); + await h.user.click(screen.getByRole("button", { name: "First Honey" })); + await h.user.click( + screen.getByRole("button", { name: "Model and effort for the next run" }), + ); + expect( + screen.getByText("Loading model and effort…").closest("[role=status]"), + ).toHaveAttribute("aria-busy", "true"); + await act(async () => { + pending.resolve(models); + }); + expect( + await screen.findByLabelText("Effort for Honey's next run"), + ).toBeInTheDocument(); + expect( + screen.queryByText("Loading model and effort…"), + ).not.toBeInTheDocument(); +}); + +it("asks a mentioned agent of the viewer's for a model and effort on the next run only", async () => { + // Snapshots are stable, as the service's are. + const snapshot = { + status: "ready", + agents: [{ pubkey: first.pubkey, name: "Honey", type: "test/agent" }], + }; + const models = { + current: "fast", + models: [ + { + id: "fast", + label: "Fast", + efforts: [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + }, + { + id: "deep", + label: "Deep", + efforts: [ + { id: "high", label: "High" }, + { id: "max", label: "Max" }, + ], + }, + ], + }; + const types = [{ key: "test/agent", models: () => models }]; + const agents2 = { + subscribe: () => () => {}, + snapshot: () => snapshot, + types: () => types, + } as unknown as Agents2; + const h = mount({}, undefined, undefined, agents2); + const picker = () => + screen.queryByRole("button", { + name: "Model and effort for the next run", + }); + const open = async () => { + // Choices load asynchronously each time the agent is mentioned again. + await h.user.click( + await screen.findByRole("button", { + name: "Model and effort for the next run", + }), + ); + // The edge-aligned thumb stays hidden until it is measured, which jsdom + // cannot do, so the slider is not in the accessibility tree here. + return screen.findByLabelText("Effort for Honey's next run"); + }; + const model = () => + screen.getByRole("button", { name: "Model for Honey's next run" }); + // Writes the message and mentions an agent before any pick is made. + const write = async (name: string, text: string) => { + await h.user.type(h.input(), text); + await h.user.click(screen.getByRole("button", { name })); + }; + const send = async () => { + await h.user.keyboard("{Escape}"); + await h.user.click(screen.getByRole("button", { name: "Send message" })); + }; + expect(picker()).toBeNull(); + await h.user.click(screen.getByRole("button", { name: "Second Honey" })); + // Someone else's agent, or a person, offers no choice. + expect(picker()).toBeNull(); + await write("First Honey", "think hard"); + const slider = await open(); + expect(model()).toHaveTextContent("Fast"); + expect(slider).toHaveAttribute("aria-valuetext", "Default"); + expect(slider.closest("[data-unknown]")).not.toBeNull(); + slider.focus(); + await h.user.keyboard("{ArrowRight}"); + expect(slider).toHaveAttribute("aria-valuetext", "High"); + await send(); + expect(h.messages.send).toHaveBeenLastCalledWith( + "channel", + expect.stringContaining("think hard"), + [second.pubkey, first.pubkey], + [], + undefined, + [], + { [first.pubkey]: { effort: "high" } }, + ); + await write("First Honey", "think deeper"); + const again = await open(); + expect(again).toHaveAttribute("aria-valuetext", "Default"); + // A new model keeps an effort it also offers. + again.focus(); + await h.user.keyboard("{ArrowRight}"); + await h.user.click(model()); + await h.user.click( + await screen.findByRole("menuitemradio", { name: "Deep" }), + ); + expect(model()).toHaveTextContent("Deep"); + const deep = await screen.findByLabelText("Effort for Honey's next run"); + expect(deep).toHaveAttribute("aria-valuetext", "High"); + await send(); + expect(h.messages.send).toHaveBeenLastCalledWith( + "channel", + expect.stringContaining("think deeper"), + [first.pubkey], + [], + undefined, + [], + { [first.pubkey]: { model: "deep", effort: "high" } }, + ); + await write("First Honey", "as usual"); + await open(); + await h.user.click(model()); + await h.user.click( + await screen.findByRole("menuitemradio", { name: "Deep" }), + ); + // Back to the agent's own model is no pick at all. + await h.user.click(model()); + await h.user.click( + await screen.findByRole("menuitemradio", { name: "Fast" }), + ); + expect(model()).toHaveTextContent("Fast"); + await send(); + expect(h.messages.send).toHaveBeenLastCalledWith( + "channel", + expect.stringContaining("as usual"), + [first.pubkey], + [], + ); +}); + +it("opens the effort picker at the effort the agent's next run uses", async () => { + const snapshot = { + status: "ready", + agents: [{ pubkey: first.pubkey, name: "Honey", type: "test/agent" }], + }; + const models = { + current: "fast", + models: [ + { + id: "fast", + label: "Fast", + efforts: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + effort: "medium", + }, + ], + }; + const types = [{ key: "test/agent", models: () => models }]; + const agents2 = { + subscribe: () => () => {}, + snapshot: () => snapshot, + types: () => types, + } as unknown as Agents2; + const h = mount({}, undefined, undefined, agents2); + const open = async () => { + await h.user.click( + await screen.findByRole("button", { + name: "Model and effort for the next run", + }), + ); + return screen.findByLabelText("Effort for Honey's next run"); + }; + const send = async () => { + await h.user.keyboard("{Escape}"); + await h.user.click(screen.getByRole("button", { name: "Send message" })); + }; + await h.user.type(h.input(), "think hard"); + await h.user.click(screen.getByRole("button", { name: "First Honey" })); + const slider = await open(); + expect(slider).toHaveAttribute("aria-valuetext", "Medium"); + // A known level is drawn like a picked one, not muted. + expect(slider.closest("[data-unknown]")).toBeNull(); + slider.focus(); + await h.user.keyboard("{ArrowRight}"); + expect(slider).toHaveAttribute("aria-valuetext", "High"); + // Back to the agent's own effort is no pick at all. + await h.user.keyboard("{ArrowLeft}"); + expect(slider).toHaveAttribute("aria-valuetext", "Medium"); + await send(); + expect(h.messages.send).toHaveBeenLastCalledWith( + "channel", + expect.stringContaining("think hard"), + [first.pubkey], + [], + ); +}); + it("sends channel messages and thread replies through real form and keyboard events", async () => { const h = mount(); await h.user.type(h.input(), "channel draft"); diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx index 6224ca34d..03aef80b5 100644 --- a/src/features/messages/MessageComposer.tsx +++ b/src/features/messages/MessageComposer.tsx @@ -81,6 +81,8 @@ import { ComposerAccessories } from "../conversation/ComposerAccessories"; import { ComposerTools } from "../conversation/ComposerTools"; import { ComposerLinkDialog } from "./ComposerLinkDialog"; import { ComposerFormattingTools } from "./ComposerFormattingTools"; +import { ComposerModels } from "./ComposerModels"; +import type { MentionPicks } from "../relay/messages"; import type { ConversationExtensions, CompletionEdit, @@ -378,6 +380,8 @@ function Composer({ ); const draft = value.text; const valueRef = useRef(value); + // Model and effort picked for mentioned agents' next run; cleared by each send. + const [picks, setPicks] = useState({}); const caret = useRef(undefined); const input = useRef(null); const focusOnMount = useRef( @@ -1038,6 +1042,7 @@ function Composer({ sendAttempt.current = attempt; const captured = valueRef.current; const capturedAttachments = attachments.store.snapshot(); + const capturedPicks = picks; try { if (submission?.receiptOnly) { submission.submit(captured); @@ -1131,6 +1136,13 @@ function Composer({ ) : [], ); + // Only agents still notified by this message are asked for a model or effort. + const sentPicks: MentionPicks = Object.fromEntries( + Object.entries(capturedPicks).filter(([key]) => + recipients.includes(key), + ), + ); + const tuned = Object.keys(sentPicks).length > 0; // Destination and content are fixed here; a background send never retargets. const publish = (uploaded: readonly UploadedAttachment[]) => { requireCompletedSessionStart(); @@ -1141,10 +1153,13 @@ function Composer({ content, recipients, uploaded, - ...(replyParentId || references.length ? [replyParentId] : []), - ...(references.length ? [references] : []), + ...(replyParentId || references.length || tuned + ? [replyParentId] + : []), + ...(references.length || tuned ? [references] : []), + ...(tuned ? [sentPicks] : []), ) - : references.length + : references.length || tuned ? session.messages.send( channelId, content, @@ -1152,6 +1167,7 @@ function Composer({ uploaded, undefined, references, + ...(tuned ? [sentPicks] : []), ) : session.messages.send(channelId, content, recipients, uploaded); }; @@ -1237,6 +1253,7 @@ function Composer({ const changed = JSON.stringify(next) !== JSON.stringify(valueRef.current); valueRef.current = next; updateDraft(next); + setPicks({}); // An unchanged prefill may not render. Do not leave a caret command for // the next keystroke to consume after inserting its first character. caret.current = changed ? next.text.length : undefined; @@ -1634,6 +1651,17 @@ function Composer({ )} {active && !editing.target && trailingTool} + {active && + !editing.target && + !submission && + !!value.recipients.length && ( + item.pubkey)} + value={picks} + disabled={editingDisabled} + onChange={setPicks} + /> + )} { + const h = setup(); + await h.members([viewer.pubkey, honey.pubkey]); + const root = "f".repeat(64); + const post = ( + picks: Record, + ) => + reply + ? h.session.messages.reply( + "c", + root, + "@Honey", + [honey.pubkey], + [], + undefined, + [], + picks, + ) + : h.session.messages.send( + "c", + "@Honey", + [honey.pubkey], + [], + undefined, + [], + picks, + ); + post({ + [honey.pubkey]: { model: "claude-fable-5-1[1m]", effort: "xhigh" }, + }); + await flush(); + const tags = h.publish.mock.calls[0]?.[0]?.tags; + expect(tags).toContainEqual([ + "model", + honey.pubkey, + "claude-fable-5-1[1m]", + ]); + expect(tags).toContainEqual(["effort", honey.pubkey, "xhigh"]); + expect(() => post({ [namesake.pubkey]: { effort: "high" } })).toThrow( + /mentioned agent/, + ); + expect(() => post({ [honey.pubkey]: { effort: "High Effort" } })).toThrow( + /mentioned agent/, + ); + expect(() => post({ [honey.pubkey]: { model: "two words" } })).toThrow( + /mentioned agent/, + ); + }, +); + it.each(mentionConformance.tags.write)( "conforms to the portable mention tag writing contract: $name", async (fixture) => { diff --git a/src/features/relay/messages.ts b/src/features/relay/messages.ts index c414c33e1..a405244b5 100644 --- a/src/features/relay/messages.ts +++ b/src/features/relay/messages.ts @@ -24,6 +24,13 @@ export const REPORT_TYPES = [ "other", ] as const; export type ReportType = (typeof REPORT_TYPES)[number]; +/** `["effort", agentPubkey, level]` and `["model", agentPubkey, model]`: what + * a mentioned agent's owner asks of its next run. Only the owner is honoured. */ +export const EFFORT_TAG = "effort"; +export const MODEL_TAG = "model"; +export type RunPick = Readonly<{ model?: string; effort?: string }>; +/** The model and effort picked for each mentioned agent, by pubkey. */ +export type MentionPicks = Readonly>; /** Domain convenience only. Delivery and read reconciliation remain session-owned. */ export function createMessages( @@ -71,6 +78,19 @@ export function createMessages( throw new Error("Choose at most 32 valid mention references"); return [...new Set(pubkeys)].map((key) => ["mention", key]); }; + const pickTags = (mentions: readonly string[], picks: MentionPicks) => + Object.entries(picks).flatMap(([key, { model, effort }]) => { + if ( + !mentions.includes(key) || + (model !== undefined && !/^[\w.:[\]-]{1,64}$/.test(model)) || + (effort !== undefined && !/^[a-z0-9-]{1,32}$/.test(effort)) + ) + throw new Error("A model or effort applies only to a mentioned agent"); + return [ + ...(model === undefined ? [] : [[MODEL_TAG, key, model]]), + ...(effort === undefined ? [] : [[EFFORT_TAG, key, effort]]), + ]; + }); return Object.freeze({ send( channelId: string, @@ -79,6 +99,7 @@ export function createMessages( attachments: readonly UploadedAttachment[] = [], recovery?: OutboxRecovery, references: readonly string[] = [], + picks: MentionPicks = {}, ) { if (!channelId) throw new Error("A channel is required"); const message = attachmentMessage(content, attachments, relayOrigin); @@ -90,6 +111,7 @@ export function createMessages( ["h", channelId], ...mentionTags(channelId, mentions), ...referenceTags(references), + ...pickTags(mentions, picks), ...emojiTags(content), ...message.tags, ], @@ -184,6 +206,7 @@ export function createMessages( attachments: readonly UploadedAttachment[] = [], parentId: string = rootId, references: readonly string[] = [], + picks: MentionPicks = {}, ) { if (!channelId) throw new Error("A channel is required"); if (!/^[0-9a-f]{64}$/.test(rootId)) @@ -200,6 +223,7 @@ export function createMessages( ["e", parentId, "", "reply"], ...mentionTags(channelId, mentions), ...referenceTags(references), + ...pickTags(mentions, picks), ...emojiTags(content), ...message.tags, ], diff --git a/src/shared/design-system/DESIGN.md b/src/shared/design-system/DESIGN.md index 6e541540b..18f64f836 100644 --- a/src/shared/design-system/DESIGN.md +++ b/src/shared/design-system/DESIGN.md @@ -627,7 +627,10 @@ use the same 10px corners as icon actions, including hover and pressed fills. Small buttons use 16px side padding and 16px icons; medium and large use 24px side padding and 24px icons. Standard Button labels use the complete `text-label-sm` role with an 8px icon gap; the -extra-small capsule uses `text-caption`. +extra-small capsule uses `text-caption`. For a caption-sized inline choice such as +the composer's model name, `size="inline"` keeps a 24px minimum target with 4px +horizontal padding and icon gap, and 12px icons. Pair it with `variant="ghost"` +for an unframed label; shared hover, disabled, and keyboard focus still apply. IconButton defaults to a 10px rounded rectangle (`--radius-icon-button`) and uses the same sm/md/lg sizes. Existing names diff --git a/src/shared/design-system/icons/index.ts b/src/shared/design-system/icons/index.ts index 6fb716493..8b5c35174 100644 --- a/src/shared/design-system/icons/index.ts +++ b/src/shared/design-system/icons/index.ts @@ -442,3 +442,6 @@ import TablerCircleIcon from "@tabler/icons-react/dist/esm/icons/IconCircle.mjs" export const CircleIcon = defineIcon("tabler", TablerCircleIcon); import TablerCircleHalfIcon from "@tabler/icons-react/dist/esm/icons/IconCircleHalf.mjs"; export const CircleHalfIcon = defineIcon("tabler", TablerCircleHalfIcon); + +import TablerGaugeIcon from "@tabler/icons-react/dist/esm/icons/IconGauge.mjs"; +export const GaugeIcon = defineIcon("tabler", TablerGaugeIcon); diff --git a/src/shared/design-system/styles/components.css b/src/shared/design-system/styles/components.css index 9615d1240..c8868effd 100644 --- a/src/shared/design-system/styles/components.css +++ b/src/shared/design-system/styles/components.css @@ -62,6 +62,20 @@ width: var(--space-4); height: var(--space-4); } + /* Inline caption choices, such as the composer's model label. */ + .buzz-button[data-size="inline"] { + @apply text-caption; + min-height: var(--space-6); + padding: 0 var(--space-1); + } + .buzz-button[data-size="inline"] .buzz-button-label { + gap: var(--space-1); + } + .buzz-button[data-size="inline"] .buzz-button-label > svg, + .buzz-button[data-size="inline"] .buzz-button-spinner { + width: var(--space-3); + height: var(--space-3); + } .buzz-button[data-size="sm"] { @apply text-label-sm; min-height: var(--size-control-sm); diff --git a/src/shared/design-system/ui/Button.tsx b/src/shared/design-system/ui/Button.tsx index 609e62030..d66d5fea0 100644 --- a/src/shared/design-system/ui/Button.tsx +++ b/src/shared/design-system/ui/Button.tsx @@ -13,7 +13,7 @@ type ButtonVariant = | "link" | "primary" | "quiet"; -type ButtonSize = "xs" | "sm" | "md" | "lg" | "compact" | "default"; +type ButtonSize = "inline" | "xs" | "sm" | "md" | "lg" | "compact" | "default"; export type ButtonProps = Omit< ComponentProps, diff --git a/src/shared/design-system/ui/registry.ts b/src/shared/design-system/ui/registry.ts index 70007b467..39d757155 100644 --- a/src/shared/design-system/ui/registry.ts +++ b/src/shared/design-system/ui/registry.ts @@ -414,7 +414,7 @@ export const COMPONENTS: readonly ComponentDefinition[] = [ "outline", "inverted", "link", - "size: xs | sm | md | lg", + "size: inline | xs | sm | md | lg", "shape: capsule (default) | control (12px)", "loading", ], diff --git a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx index 98eb372dc..dc903a34f 100644 --- a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx +++ b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx @@ -3,6 +3,7 @@ import { SectionHeading } from "./primitives"; import { useState } from "react"; import { ArrowRightIcon, + CaretDownIcon, PlusIcon, DotsThreeIcon, GearIcon, @@ -140,6 +141,16 @@ export function ButtonSpecimen() { + Model
Date: Sun, 11 Oct 2026 17:08:02 -0700 Subject: [PATCH 11/39] Bind the popup CSS contract test to completions Composer model controls also use a popup class. Select the completion-specific data-mention variant instead of whichever CSS module loads first. Preserves all token and geometry assertions; reproduced before and passed in Chromium and WebKit after. Co-authored-by: codex00 <6922125dbee102fcf02d2b32441fd56927c1298ac16119167f82f72529d13676@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen --- tests/browser/design-system.spec.mjs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/browser/design-system.spec.mjs b/tests/browser/design-system.spec.mjs index 971150bf9..3e4b6ddb7 100644 --- a/tests/browser/design-system.spec.mjs +++ b/tests/browser/design-system.spec.mjs @@ -85,13 +85,13 @@ test("shared tokens reach app controls without history or chip overrides", async positioner.append(surface); probes.append(positioner); } else { + // The completion surface owns the data-mention variant. Other features + // also name classes popup, so stylesheet order is not an identity. const selector = selectors.find((s) => - new RegExp(`\\._${name}_`).test(s), + /\._popup_[\w]+\[data-mention\]/.test(s), ); - if (!selector) throw new Error(`Missing popup style: ${name}`); - surface.className = selector.match( - new RegExp(`\\.(_${name}_[\\w]+)`), - )[1]; + if (!selector) throw new Error("Missing completion popup style"); + surface.className = selector.match(/\.(_popup_[\w]+)/)[1]; probes.append(surface); } } From f9d68112607389985bd3e15445b2fabb3f9604f4 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 10 Oct 2026 22:23:21 -0700 Subject: [PATCH 12/39] [agents2] preserve picture and description in ordinary profiles Store optional picture and about fields on Agents2 identities and publish them with the name. Track accepted metadata so updates and clears publish even without a rename. Preserve compatibility with older rows and publication receipts. Implemented with goose AI assistance. Human acceptance: green confirmed working. Signed-off-by: Bradley Axen --- crates/agent-controller/src/app_agent.rs | 27 +++++++- .../agent-controller/src/app_agent/tests.rs | 67 +++++++++++++++++++ src-tauri/src/app_agents.rs | 10 ++- 3 files changed, 99 insertions(+), 5 deletions(-) diff --git a/crates/agent-controller/src/app_agent.rs b/crates/agent-controller/src/app_agent.rs index e07c14580..7b5e0f134 100644 --- a/crates/agent-controller/src/app_agent.rs +++ b/crates/agent-controller/src/app_agent.rs @@ -31,6 +31,11 @@ pub struct AppAgent { pub agent_type: String, #[serde(default)] pub name: String, + /// Public profile metadata, published as kind-0 picture and about. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub picture: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub about: Option, /// The last profile its community accepted. #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, @@ -43,6 +48,10 @@ pub struct AppAgent { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Published { pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub picture: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub about: Option, pub created_at: u64, } impl AppAgent { @@ -90,11 +99,23 @@ impl AppAgent { let tags = self.attested(key, tags)?; key.signed(kind, content, tags) } + /// Whether the community accepted this row's current public metadata. + pub fn profile_is_current(&self) -> bool { + self.profile.as_ref().is_some_and(|last| { + last.name == self.name && last.picture == self.picture && last.about == self.about + }) + } /// Its profile as this row describes it, replacing the last one accepted. - /// No other writer exists: only this device holds the key. pub fn profile_event(&self, key: &Secret) -> Result { valid_name(&self.name)?; - let content = serde_json::json!({ "name": self.name, "bot": true }).to_string(); + let mut content = serde_json::json!({ "name": self.name, "bot": true }); + if let Some(picture) = &self.picture { + content["picture"] = picture.as_str().into(); + } + if let Some(about) = &self.about { + content["about"] = about.as_str().into(); + } + let content = content.to_string(); let tags = self.attested(key, vec![])?; key.sign_event_after( 0, @@ -299,6 +320,8 @@ impl AppAgents { auth, agent_type: prepared.agent_type, name: prepared.name, + picture: None, + about: None, profile: None, deleted: false, }; diff --git a/crates/agent-controller/src/app_agent/tests.rs b/crates/agent-controller/src/app_agent/tests.rs index 219ee8a2d..2b4518878 100644 --- a/crates/agent-controller/src/app_agent/tests.rs +++ b/crates/agent-controller/src/app_agent/tests.rs @@ -122,6 +122,8 @@ fn renames_and_profiles_replace_the_last_accepted_profile() { let future = first["created_at"].as_u64().unwrap() + 100; let published = |created_at| Published { name: "Ada".into(), + picture: None, + about: None, created_at, }; agents.published(&agent.pubkey, published(future)).unwrap(); @@ -324,3 +326,68 @@ fn concurrent_writers_on_one_file_keep_every_identity() { saved.sort(); assert_eq!(saved, made); } + +#[test] +fn saved_public_profile_survives_updates_and_is_signed() { + let dir = tempfile::tempdir().unwrap(); + let credentials = Memory::default(); + let (agents, agent) = created(dir.path(), &credentials); + let mut saved: serde_json::Value = + serde_json::from_slice(&std::fs::read(&agents.path).unwrap()).unwrap(); + saved[0]["picture"] = "https://relay.example/media/a.png".into(); + saved[0]["about"] = "Reviews pull requests.\nKeeps changes small.".into(); + // Existing receipts predate picture/about tracking. + saved[0]["profile"] = serde_json::json!({"name": "Ada", "createdAt": 500}); + std::fs::write(&agents.path, saved.to_string()).unwrap(); + agents.rename(&agent.pubkey, "Bea").unwrap(); + let reopened = AppAgents::open(agents.path.clone()); + let agent = reopened.get(&agent.pubkey).unwrap(); + let event = agent + .profile_event(&agent.read_key(&credentials).unwrap()) + .unwrap(); + let content: serde_json::Value = + serde_json::from_str(event["content"].as_str().unwrap()).unwrap(); + assert_eq!( + content, + serde_json::json!({ + "name": "Bea", "bot": true, + "picture": "https://relay.example/media/a.png", + "about": "Reviews pull requests.\nKeeps changes small.", + }) + ); + assert!(!agent.profile_is_current()); + reopened + .published( + &agent.pubkey, + Published { + name: agent.name.clone(), + picture: agent.picture.clone(), + about: agent.about.clone(), + created_at: event["created_at"].as_u64().unwrap(), + }, + ) + .unwrap(); + assert!(reopened.get(&agent.pubkey).unwrap().profile_is_current()); + let accepted = std::fs::read(&reopened.path).unwrap(); + // Each metadata-only change/clear must publish, even without a rename. + for field in ["picture", "about"] { + for replacement in [Some("https://relay.example/changed"), None] { + let mut changed: serde_json::Value = serde_json::from_slice(&accepted).unwrap(); + changed[0][field] = serde_json::json!(replacement); + std::fs::write(&reopened.path, changed.to_string()).unwrap(); + let changed = reopened.get(&agent.pubkey).unwrap(); + assert!(!changed.profile_is_current()); + let event = changed + .profile_event(&agent.read_key(&credentials).unwrap()) + .unwrap(); + let content: serde_json::Value = + serde_json::from_str(event["content"].as_str().unwrap()).unwrap(); + assert_eq!( + content.get(field).and_then(serde_json::Value::as_str), + replacement + ); + } + } + let event: nostr::event::Event = serde_json::from_value(event).unwrap(); + event.verify().unwrap(); +} diff --git a/src-tauri/src/app_agents.rs b/src-tauri/src/app_agents.rs index 9fc68db66..049466bd1 100644 --- a/src-tauri/src/app_agents.rs +++ b/src-tauri/src/app_agents.rs @@ -336,8 +336,8 @@ pub(crate) async fn app_agent_publish( post(&agent, &key, signed).await } -/// Publishes the agent's profile if its name differs from the last one its -/// community accepted. Safe to call any time; one runs per agent at a time. +/// Publishes changed public metadata. Safe to call any time; one publication +/// runs per agent at a time and records only what its community accepted. #[tauri::command] pub(crate) async fn app_agent_publish_profile( state: tauri::State<'_, AppAgentHost>, @@ -346,7 +346,7 @@ pub(crate) async fn app_agent_publish_profile( let turn = state.profile_turn(&pubkey); let _turn = turn.lock().await; let agent = state.agent(pubkey).await?; - if agent.profile.as_ref().map(|last| &last.name) == Some(&agent.name) { + if agent.profile_is_current() { return Ok(()); } let (agent, key) = state.key(agent).await?; @@ -361,6 +361,8 @@ pub(crate) async fn app_agent_publish_profile( &agent.pubkey, Published { name: agent.name, + picture: agent.picture, + about: agent.about, created_at, }, ) @@ -590,6 +592,8 @@ mod tests { auth: "[]".into(), agent_type: "p/t".into(), name: "Ada".into(), + picture: None, + about: None, profile: None, deleted: false, }; From aa2cf6bfb4c47af8911dff6be1df8c2ccb3bfc8d Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 10 Oct 2026 16:08:07 -0700 Subject: [PATCH 13/39] [agents2-adopt] bring over agents from earlier agent systems Agents2 can now take over an identity that the harness controller or old Buzz (installed or dev) saved on this machine. It keeps the key, name and picture, and leaves the instructions and settings behind. Neither old store is written. - port.rs lists both stores as one flat list, one entry per key. The harness copy wins because its key is already in this app's store. It also re-reads an identity and its key at take-over time. - AppAgents::adopt is prepare with an existing key. When a harness agent saved for the same community already holds the key under the same credential ID, commit shares that key and never adds or withdraws it. Without this, a failed add would have deleted the harness copy. - An adopted agent keeps the profile its community already has. Without one, it publishes the name and picture it brought. If the read fails, it overwrites nothing until the agent is renamed. Agents2 profiles gain an optional picture. - Adds a "Bring over" side panel on the Agents2 page. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Bradley Axen --- crates/agent-controller/src/app_agent.rs | 85 +++++++- .../agent-controller/src/app_agent/tests.rs | 135 +++++++++++++ crates/agent-controller/src/import.rs | 2 +- crates/agent-controller/src/lib.rs | 2 + crates/agent-controller/src/port.rs | 177 +++++++++++++++++ crates/agent-controller/src/port/tests.rs | 186 ++++++++++++++++++ src-tauri/build.rs | 2 + src-tauri/capabilities/default.json | 2 + src-tauri/src/app_agents.rs | 139 ++++++++++++- src-tauri/src/browser_permissions_tests.rs | 2 + src-tauri/src/lib.rs | 9 + src/bundled/agents2/Agents2Page.module.css | 16 ++ src/bundled/agents2/Agents2Page.test.tsx | 54 +++++ src/bundled/agents2/Agents2Page.tsx | 183 +++++++++++++++-- src/bundled/builderlab/index.test.tsx | 2 + src/features/agents2/native.ts | 36 ++++ src/features/agents2/service.test.ts | 66 +++++++ src/features/agents2/service.ts | 57 +++++- 18 files changed, 1128 insertions(+), 27 deletions(-) create mode 100644 crates/agent-controller/src/port.rs create mode 100644 crates/agent-controller/src/port/tests.rs diff --git a/crates/agent-controller/src/app_agent.rs b/crates/agent-controller/src/app_agent.rs index 7b5e0f134..1b3ff11a6 100644 --- a/crates/agent-controller/src/app_agent.rs +++ b/crates/agent-controller/src/app_agent.rs @@ -39,6 +39,10 @@ pub struct AppAgent { /// The last profile its community accepted. #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, + /// Its key came from an earlier agent system, so its community may already + /// have a profile for it, which the first publication keeps instead. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub adopted: bool, /// Its key is gone. The row stays until its community confirms it left its /// channels and was archived, which the owner signs, not the agent. #[serde(default, skip_serializing_if = "std::ops::Not::not")] @@ -192,13 +196,16 @@ impl AppAgent { } } -/// A generated key awaiting its owner attestation. Dropping it discards the key. +/// A key awaiting its owner attestation. Dropping a generated one discards it. pub struct NewAppAgent { key: Secret, relay: String, owner: String, agent_type: String, name: String, + picture: Option, + /// An earlier agent system's key, which may already be saved under this ID. + adopted: bool, } impl NewAppAgent { pub fn pubkey(&self) -> &str { @@ -280,6 +287,27 @@ impl AppAgents { owner: &str, agent_type: &str, name: &str, + ) -> Result { + let mut agent = Self::adopt( + destination, + owner, + agent_type, + Secret::generate()?, + name, + None, + )?; + agent.adopted = false; + Ok(agent) + } + /// As `prepare`, for a key an earlier agent system saved, so the agent keeps + /// its identity and profile picture. + pub fn adopt( + destination: &str, + owner: &str, + agent_type: &str, + key: Secret, + name: &str, + picture: Option, ) -> Result { if !canonical_key(owner) { return Err("Choose a signed-in owner".into()); @@ -289,10 +317,12 @@ impl AppAgents { } Ok(NewAppAgent { relay: canonical_relay(destination)?, - key: Secret::generate()?, + key, owner: owner.into(), agent_type: agent_type.into(), name: valid_name(name)?, + picture: picture.filter(|url| crate::port::public_picture(url)), + adopted: true, }) } /// Saves the identity, then the key, so a saved key always has an identity to @@ -320,17 +350,33 @@ impl AppAgents { auth, agent_type: prepared.agent_type, name: prepared.name, - picture: None, + picture: prepared.picture, about: None, profile: None, + adopted: prepared.adopted, deleted: false, }; let id = agent.credential_id(); + // A harness agent saved for the same community keeps this key under the + // same ID. It is shared, never added again or deleted on failure here. + let shared = prepared.adopted && credentials.read(&id, &agent.pubkey)?.is_some(); self.update(|agents| { + if prepared.adopted { + if let Some(saved) = agents.iter().find(|saved| saved.pubkey == agent.pubkey) { + return Err(if saved.deleted { + "That agent is still being removed here; try again shortly".into() + } else { + "That agent is already here".into() + }); + } + } agents.retain(|saved| saved.pubkey != agent.pubkey); agents.push(agent.clone()); Ok(()) })?; + if shared { + return Ok((agent, prepared.key)); + } let saved = credentials.add(&id, &prepared.key).and_then(|()| { agent .read_key(credentials) @@ -338,7 +384,12 @@ impl AppAgents { .map_err(|_| "New agent key could not be verified".into()) }); if let Err(error) = saved { - if credentials.delete(&id, &agent.pubkey).is_ok() { + // Another writer may have saved the same adopted key meanwhile; that + // copy is theirs as much as this agent's. + if prepared.adopted && matches!(credentials.read(&id, &agent.pubkey), Ok(Some(_))) { + return Ok((agent, prepared.key)); + } + if prepared.adopted || credentials.delete(&id, &agent.pubkey).is_ok() { let _ = self.update(|agents| { agents.retain(|saved| saved.pubkey != agent.pubkey); Ok(()) @@ -364,6 +415,32 @@ impl AppAgents { } }) } + /// Takes the profile its community already has for an adopted key as the + /// last one accepted, so the agent keeps that name and picture and nothing + /// is republished until it is renamed here. A name the app could not + /// publish keeps the one it has here. + pub fn keep_profile( + &self, + pubkey: &str, + name: &str, + picture: Option, + created_at: u64, + ) -> Result<()> { + let name = valid_name(name).ok(); + let picture = picture.filter(|url| crate::port::public_picture(url)); + self.edit(pubkey, |agent| { + if let Some(name) = name { + agent.name = name; + } + agent.profile = Some(Published { + name: agent.name.clone(), + created_at, + }); + if picture.is_some() { + agent.picture = picture; + } + }) + } /// Deletes the key, then marks the row deleted, so a failed deletion can be /// retried. Absence is success. pub fn remove(&self, pubkey: &str, credentials: &dyn Credentials) -> Result<()> { diff --git a/crates/agent-controller/src/app_agent/tests.rs b/crates/agent-controller/src/app_agent/tests.rs index 2b4518878..ace920839 100644 --- a/crates/agent-controller/src/app_agent/tests.rs +++ b/crates/agent-controller/src/app_agent/tests.rs @@ -391,3 +391,138 @@ fn saved_public_profile_survives_updates_and_is_signed() { let event: nostr::event::Event = serde_json::from_value(event).unwrap(); event.verify().unwrap(); } + +#[test] +fn an_adopted_key_keeps_its_identity_picture_and_shared_saved_key() { + let credentials = Memory::default(); + let key = Secret::generate().unwrap(); + let pubkey = key.pubkey().to_owned(); + // A harness agent already keeps this key under the same community's ID. + let id = agent_id(&pubkey, "wss://relay.example"); + credentials.add(&id, &key).unwrap(); + set(&credentials.1.add, true); + let dirs: Vec<_> = (0..3).map(|_| tempfile::tempdir().unwrap()).collect(); + let store = |n: usize| AppAgents::open(dirs[n].path().join("agents2/identities.json")); + let adopt = |picture: &str| { + AppAgents::adopt( + "https://relay.example/", + &owner(), + "p/t", + Secret::parse(&key.hex(), &pubkey).unwrap(), + "Ada", + Some(picture.into()), + ) + .unwrap() + }; + let auth: Vec = + serde_json::from_str(&crate::secret::test_attestation(&pubkey)).unwrap(); + let agents = store(0); + let (agent, _) = agents + .commit( + adopt("https://relay.example/media/a.png"), + &auth, + &credentials, + ) + .unwrap(); + assert_eq!(agent.pubkey, pubkey); + assert!(agent.adopted); + let profile = agent + .profile_event(&agent.read_key(&credentials).unwrap()) + .unwrap(); + assert_eq!( + profile["content"], + r#"{"bot":true,"name":"Ada","picture":"https://relay.example/media/a.png"}"# + ); + // Once here, it is not taken over again, nor while it is being removed. + let again = adopt("https://a.example/b.png"); + assert!(agents.commit(again, &auth, &credentials).is_err()); + agents.edit(&pubkey, |agent| agent.deleted = true).unwrap(); + let again = adopt("https://a.example/b.png"); + assert!(agents.commit(again, &auth, &credentials).is_err()); + // A shared key that fails to verify is left where it was. + set(&credentials.1.read, true); + assert!(store(1) + .commit(adopt("https://a.example/b.png"), &auth, &credentials) + .is_err()); + set(&credentials.1.read, false); + assert!(credentials.read(&id, &pubkey).unwrap().is_some()); + assert!(rows(&store(1)).is_empty()); + // Inline pictures are not profile pictures. + let (agent, _) = store(2) + .commit(adopt("data:image/png;base64,AA"), &auth, &credentials) + .unwrap(); + assert_eq!(agent.picture, None); +} + +#[test] +fn an_adopted_key_that_cannot_be_saved_withdraws_only_its_row() { + let dir = tempfile::tempdir().unwrap(); + let credentials = Memory::default(); + let agents = AppAgents::open(dir.path().join("agents2/identities.json")); + let key = Secret::generate().unwrap(); + let pubkey = key.pubkey().to_owned(); + let auth: Vec = + serde_json::from_str(&crate::secret::test_attestation(&pubkey)).unwrap(); + let adopt = || { + AppAgents::adopt( + "https://relay.example/", + &owner(), + "p/t", + Secret::parse(&key.hex(), &pubkey).unwrap(), + "Ada", + None, + ) + .unwrap() + }; + set(&credentials.1.add, true); + assert!(agents.commit(adopt(), &auth, &credentials).is_err()); + assert!(rows(&agents).is_empty()); + set(&credentials.1.add, false); + let (agent, _) = agents.commit(adopt(), &auth, &credentials).unwrap(); + assert_eq!(agent.read_key(&credentials).unwrap().pubkey(), pubkey); +} + +#[test] +fn keeps_the_communitys_profile_for_an_adopted_key_until_renamed() { + let dir = tempfile::tempdir().unwrap(); + let credentials = Memory::default(); + let (agents, agent) = created(dir.path(), &credentials); + agents + .keep_profile( + &agent.pubkey, + " Old Ada ", + Some("https://a.example/p.png".into()), + 500, + ) + .unwrap(); + let kept = agents.get(&agent.pubkey).unwrap(); + assert_eq!(kept.name, "Old Ada"); + assert_eq!(kept.picture.as_deref(), Some("https://a.example/p.png")); + // Nothing to republish: the accepted profile already has this name. + assert_eq!( + kept.profile, + Some(Published { + name: "Old Ada".into(), + created_at: 500 + }) + ); + // No usable picture keeps the one it has; a rename republishes it, newer. + agents + .keep_profile(&agent.pubkey, "Old Ada", Some("data:,x".into()), 500) + .unwrap(); + agents.rename(&agent.pubkey, "Ada").unwrap(); + let renamed = agents.get(&agent.pubkey).unwrap(); + let next = renamed + .profile_event(&renamed.read_key(&credentials).unwrap()) + .unwrap(); + assert_eq!( + next["content"], + r#"{"bot":true,"name":"Ada","picture":"https://a.example/p.png"}"# + ); + assert!(next["created_at"].as_u64().unwrap() > 500); + // A name the app could not publish keeps the one it has. + agents.keep_profile(&agent.pubkey, " ", None, 900).unwrap(); + let kept = agents.get(&agent.pubkey).unwrap(); + assert_eq!(kept.name, "Ada"); + assert_eq!(kept.profile.unwrap().created_at, 900); +} diff --git a/crates/agent-controller/src/import.rs b/crates/agent-controller/src/import.rs index ce9c7ec2e..367a956ea 100644 --- a/crates/agent-controller/src/import.rs +++ b/crates/agent-controller/src/import.rs @@ -560,7 +560,7 @@ fn read_source(root: &Path) -> Result { digest: format!("{:x}", Sha256::digest(bytes)), }) } -fn read_json(path: &Path, optional: bool) -> Result { +pub(crate) fn read_json(path: &Path, optional: bool) -> Result { let meta = match fs::symlink_metadata(path) { Err(e) if optional && e.kind() == std::io::ErrorKind::NotFound => return Ok(json!({})), Err(_) => return Err("Could not read chosen Buzz source".into()), diff --git a/crates/agent-controller/src/lib.rs b/crates/agent-controller/src/lib.rs index cd74797ed..2a1af0d0b 100644 --- a/crates/agent-controller/src/lib.rs +++ b/crates/agent-controller/src/lib.rs @@ -21,6 +21,7 @@ mod import; pub mod logs; mod ownership; pub mod pi; +mod port; mod process; mod profile; mod restart; @@ -52,6 +53,7 @@ pub use harness_presets::{harness_preset, harness_presets, HarnessPreset}; pub use import::{ CloneSettings, CredentialedImport, ImportPreview, Imports, LegacySource, PreparedImport, }; +pub use port::{OldStores, PortSource, Portable, Portables}; pub use restart::{RestartChange, RestartDiffEntry}; pub use runtime::path::{prepare_tools_path, tools_path, warm_tools_path}; pub use runtime::{ diff --git a/crates/agent-controller/src/port.rs b/crates/agent-controller/src/port.rs new file mode 100644 index 000000000..0205020de --- /dev/null +++ b/crates/agent-controller/src/port.rs @@ -0,0 +1,177 @@ +//! Agent identities an earlier agent system saved on this machine, which Agents2 +//! can take over: the harness controller's library and old Buzz's. Only the +//! identity and its public name and picture move; instructions, harness settings +//! and memberships stay behind. Neither store is ever written. +use crate::config::{agent_id, canonical_key, canonical_relay, MAX_AGENTS}; +use crate::import::read_json; +use crate::{Credentials, LegacySource, Result, Secret}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeSet; +use std::path::PathBuf; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PortSource { + /// This app's harness agents, whose keys are already in its credential store. + Harness, + Installed, + Development, +} +impl PortSource { + fn legacy(self) -> Option { + match self { + Self::Harness => None, + Self::Installed => Some(LegacySource::Installed), + Self::Development => Some(LegacySource::Development), + } + } +} + +/// One identity another store holds, as it was last saved there. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Portable { + pub source: PortSource, + pub pubkey: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub picture: Option, + /// The community it was saved for there; the new identity uses the open one. + pub relay: String, +} + +#[derive(Debug, Default, Serialize)] +pub struct Portables { + pub agents: Vec, + /// A store that could not be read; the others are still listed. + pub warnings: Vec, +} + +/// The harness library file and the OS data directory old Buzz kept its own in. +#[derive(Clone)] +pub struct OldStores { + pub harness: PathBuf, + pub data_dir: PathBuf, +} +impl OldStores { + /// Every identity on this machine, one per key. The harness copy wins, since + /// reading its key needs no other app's keychain entry. A missing store adds + /// nothing. + pub fn list(&self) -> Portables { + let mut found = Portables::default(); + let mut seen = BTreeSet::new(); + for source in [ + PortSource::Harness, + PortSource::Installed, + PortSource::Development, + ] { + match self.read(source) { + Ok(agents) => found.agents.extend( + agents + .into_iter() + .map(|(agent, _)| agent) + .filter(|agent| seen.insert(agent.pubkey.clone())), + ), + Err(error) => found.warnings.push(error), + } + } + found + } + /// One listed identity and its key, both re-read from the store it is in, so + /// the caller names only its choice. + pub fn take( + &self, + source: PortSource, + pubkey: &str, + credentials: &dyn Credentials, + ) -> Result<(Portable, Secret)> { + let (agent, saved) = self + .read(source)? + .into_iter() + .find(|(agent, _)| agent.pubkey == pubkey) + .ok_or("That agent is no longer saved there")?; + let key = match source.legacy() { + Some(_) if !saved.is_empty() => Secret::parse(&saved, pubkey)?, + Some(legacy) => credentials.read_legacy(legacy, pubkey)?, + None => credentials + .read(&saved, pubkey)? + .ok_or("That agent's key is no longer saved")?, + }; + Ok((agent, key)) + } + /// Its identities, each with where its key is: the harness credential ID, or + /// the key old Buzz kept in the record itself, if it did. + fn read(&self, source: PortSource) -> Result> { + let (path, records) = match source.legacy() { + None => (self.harness.clone(), "agents"), + Some(legacy) => ( + self.data_dir + .join(legacy.app_directory()) + .join("agents/managed-agents.json"), + "", + ), + }; + if !path.try_exists().unwrap_or(true) { + return Ok(vec![]); + } + let file = + read_json(&path, false).map_err(|_| format!("Could not read {}", path.display()))?; + let records = if records.is_empty() { + &file + } else { + &file[records] + }; + let records = records + .as_array() + .filter(|records| records.len() <= MAX_AGENTS) + .ok_or_else(|| format!("Could not read {}", path.display()))?; + Ok(records + .iter() + .filter_map(|record| portable(source, record)) + .collect()) + } +} + +fn portable(source: PortSource, record: &Value) -> Option<(Portable, String)> { + let text = |key: &str| record.get(key).and_then(Value::as_str).unwrap_or(""); + let harness = source == PortSource::Harness; + let pubkey = text("pubkey"); + let relay = + canonical_relay(text(if harness { "relayUrl" } else { "relay_url" })).unwrap_or_default(); + let name = text("name").trim(); + if !canonical_key(pubkey) || name.is_empty() { + return None; + } + let credential = if harness { + Some(text("credentialId")) + .filter(|id| !id.is_empty()) + .map_or_else(|| agent_id(pubkey, &relay), str::to_owned) + } else { + text("private_key_nsec").to_owned() + }; + let picture = Some(text(if harness { "picture" } else { "avatar_url" })) + .filter(|url| public_picture(url)) + .map(str::to_owned); + Some(( + Portable { + source, + pubkey: pubkey.into(), + name: name.into(), + picture, + relay, + }, + credential, + )) +} + +/// A profile picture is a public HTTPS URL of bounded length, never inline data. +pub(crate) fn public_picture(url: &str) -> bool { + url.len() <= 2048 + && url::Url::parse(url).is_ok_and(|url| { + url.scheme() == "https" && url.username().is_empty() && url.password().is_none() + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/agent-controller/src/port/tests.rs b/crates/agent-controller/src/port/tests.rs new file mode 100644 index 000000000..e19423ee9 --- /dev/null +++ b/crates/agent-controller/src/port/tests.rs @@ -0,0 +1,186 @@ +use super::*; +use serde_json::json; +use std::collections::BTreeMap; +use std::sync::Mutex; + +/// Saved keys by credential ID, and old Buzz's by source and pubkey. +#[derive(Default)] +struct Memory(Mutex>); +impl Credentials for Memory { + fn read_legacy(&self, source: LegacySource, pubkey: &str) -> Result { + let saved = self.0.lock().unwrap(); + let hex = saved + .get(&format!("{}:{pubkey}", source.keyring_service())) + .ok_or("Absent")?; + Secret::parse(hex, pubkey) + } + fn read(&self, id: &str, pubkey: &str) -> Result> { + let saved = self.0.lock().unwrap(); + saved + .get(id) + .map(|hex| Secret::parse(hex, pubkey)) + .transpose() + } + fn add(&self, id: &str, key: &Secret) -> Result<()> { + let mut saved = self.0.lock().unwrap(); + saved.insert(id.into(), key.hex().to_string()); + Ok(()) + } + fn delete(&self, id: &str, _: &str) -> Result<()> { + self.0.lock().unwrap().remove(id); + Ok(()) + } +} + +fn stores(dir: &std::path::Path) -> OldStores { + OldStores { + harness: dir.join("app/agent-controller/agents.json"), + data_dir: dir.join("data"), + } +} +fn write(path: std::path::PathBuf, value: Value) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, value.to_string()).unwrap(); +} +fn legacy(dir: &std::path::Path, source: LegacySource, value: Value) { + let path = dir + .join("data") + .join(source.app_directory()) + .join("agents/managed-agents.json"); + write(path, value); +} +fn key() -> Secret { + Secret::generate().unwrap() +} + +#[test] +fn lists_each_identity_once_preferring_the_harness_copy() { + let dir = tempfile::tempdir().unwrap(); + let (shared, old, dev) = (key(), key(), key()); + write( + stores(dir.path()).harness, + json!({"version": 1, "agents": [{ + "pubkey": shared.pubkey(), "relayUrl": "wss://relay.example", + "name": "Ada", "picture": "data:image/png;base64,AA", + "credentialId": "ignored-by-list", + }]}), + ); + legacy( + dir.path(), + LegacySource::Installed, + json!([ + {"pubkey": shared.pubkey(), "name": "Ada (old)", "relay_url": "wss://old.example"}, + {"pubkey": old.pubkey(), "name": " Bea ", "relay_url": "wss://old.example/", + "avatar_url": "https://old.example/media/b.png"}, + // A persona with no identity, and an unnamed identity. + {"pubkey": "", "name": "Persona"}, + {"pubkey": key().pubkey(), "name": ""}, + ]), + ); + legacy( + dir.path(), + LegacySource::Development, + json!([{"pubkey": dev.pubkey(), "name": "Cy", "relay_url": "not a relay"}]), + ); + let found = stores(dir.path()).list(); + assert!(found.warnings.is_empty()); + assert_eq!( + found.agents, + [ + Portable { + source: PortSource::Harness, + pubkey: shared.pubkey().into(), + name: "Ada".into(), + picture: None, + relay: "wss://relay.example".into(), + }, + Portable { + source: PortSource::Installed, + pubkey: old.pubkey().into(), + name: "Bea".into(), + picture: Some("https://old.example/media/b.png".into()), + relay: "wss://old.example".into(), + }, + Portable { + source: PortSource::Development, + pubkey: dev.pubkey().into(), + name: "Cy".into(), + picture: None, + relay: String::new(), + }, + ] + ); +} + +#[test] +fn missing_stores_list_nothing_and_a_broken_one_hides_only_itself() { + let dir = tempfile::tempdir().unwrap(); + assert!(stores(dir.path()).list().agents.is_empty()); + assert!(stores(dir.path()).list().warnings.is_empty()); + let old = key(); + write(stores(dir.path()).harness, json!({"agents": "nope"})); + legacy( + dir.path(), + LegacySource::Installed, + json!([{"pubkey": old.pubkey(), "name": "Bea"}]), + ); + let found = stores(dir.path()).list(); + assert_eq!(found.warnings.len(), 1); + assert_eq!(found.agents.len(), 1); + assert_eq!(found.agents[0].pubkey, old.pubkey()); +} + +#[test] +fn takes_the_key_from_the_store_it_was_listed_in() { + let dir = tempfile::tempdir().unwrap(); + let credentials = Memory::default(); + let (harness, old, inline) = (key(), key(), key()); + let id = agent_id(harness.pubkey(), "wss://relay.example"); + credentials.add(&id, &harness).unwrap(); + credentials.0.lock().unwrap().insert( + format!("buzz-desktop:{}", old.pubkey()), + old.hex().to_string(), + ); + write( + stores(dir.path()).harness, + json!({"agents": [{ + "pubkey": harness.pubkey(), "relayUrl": "wss://relay.example", + "name": "Ada", "credentialId": id, + }]}), + ); + legacy( + dir.path(), + LegacySource::Installed, + json!([ + {"pubkey": old.pubkey(), "name": "Bea"}, + {"pubkey": inline.pubkey(), "name": "Cy", "private_key_nsec": inline.hex().to_string()}, + ]), + ); + let stores = stores(dir.path()); + let (agent, key) = stores + .take(PortSource::Harness, harness.pubkey(), &credentials) + .unwrap(); + assert_eq!( + (agent.name.as_str(), key.pubkey()), + ("Ada", harness.pubkey()) + ); + let (agent, key) = stores + .take(PortSource::Installed, old.pubkey(), &credentials) + .unwrap(); + assert_eq!((agent.name.as_str(), key.pubkey()), ("Bea", old.pubkey())); + // A key old Buzz kept in the record is read from there, not the keychain. + let (_, key) = stores + .take(PortSource::Installed, inline.pubkey(), &credentials) + .unwrap(); + assert_eq!(key.pubkey(), inline.pubkey()); + assert!(!serde_json::to_string(&stores.list()) + .unwrap() + .contains(&*inline.hex())); + // Each source reads only its own store. + assert!(stores + .take(PortSource::Development, old.pubkey(), &credentials) + .is_err()); + assert!(stores + .take(PortSource::Harness, old.pubkey(), &credentials) + .is_err()); +} diff --git a/src-tauri/build.rs b/src-tauri/build.rs index ec00cd6b2..83dd36045 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -103,6 +103,8 @@ fn main() { "agent_control_create_prepare", "app_agent_list", "app_agent_create", + "app_agent_portable", + "app_agent_port", "app_agent_rename", "app_agent_delete", "app_agent_forget", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index fac28ed35..3d600b509 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -73,6 +73,8 @@ "allow-agent-control-create-prepare", "allow-app-agent-list", "allow-app-agent-create", + "allow-app-agent-portable", + "allow-app-agent-port", "allow-app-agent-rename", "allow-app-agent-delete", "allow-app-agent-forget", diff --git a/src-tauri/src/app_agents.rs b/src-tauri/src/app_agents.rs index 049466bd1..5349cf6a7 100644 --- a/src-tauri/src/app_agents.rs +++ b/src-tauri/src/app_agents.rs @@ -4,7 +4,8 @@ //! to the agent's community. use crate::agents::profile_http::authorization; use buzz_agent_controller::{ - AppAgent, AppAgents, Credentials, PlatformCredentials, Published, Secret, CREDENTIALS_BUSY, + AppAgent, AppAgents, Credentials, OldStores, PlatformCredentials, PortSource, Portables, + Published, Secret, CREDENTIALS_BUSY, }; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -22,6 +23,8 @@ const BUSY_WAIT: std::time::Duration = std::time::Duration::from_millis(100); #[derive(Clone)] pub(crate) struct AppAgentHost { agents: Result, + /// Earlier agent systems' identities on this machine, which it can take over. + old: Result, credentials: Arc, /// Each key after its first read, so only create and first use reach the /// credential store, whose lock refuses rather than waits. Waiting on this @@ -34,13 +37,18 @@ pub(crate) struct AppAgentHost { } impl AppAgentHost { /// `packaged` is false for a dev build, which steps aside for a packaged app. - pub(crate) fn new(path: Result, packaged: bool) -> Self { + pub(crate) fn new( + path: Result, + old: Result, + packaged: bool, + ) -> Self { let hosts = path .as_ref() .map_err(Clone::clone) .and_then(|path| Ok(path.parent().ok_or("Invalid agent storage")?.join("hosts"))); Self { agents: path.map(AppAgents::open), + old, credentials: Arc::new(PlatformCredentials::default()), keys: Arc::default(), profiles: Arc::default(), @@ -272,6 +280,105 @@ pub(crate) async fn app_agent_create( .await } +/// Identities earlier agent systems saved on this machine, as Agents2 could take +/// them over; the caller leaves out its own. +#[tauri::command] +pub(crate) async fn app_agent_portable( + state: tauri::State<'_, AppAgentHost>, +) -> Result { + let old = state.old.clone()?; + blocking(move || Ok(old.list())).await +} + +/// Takes over one identity an earlier agent system saved, keeping its key, name +/// and picture, as `app_agent_create` does for a new one. Its old store keeps it. +#[tauri::command] +pub(crate) async fn app_agent_port( + state: tauri::State<'_, AppAgentHost>, + identity: tauri::State<'_, crate::identity::IdentityHost>, + source: PortSource, + pubkey: String, + destination: String, + owner: String, + agent_type: String, +) -> Result { + let agents = state.agents.clone()?; + let old = state.old.clone()?; + let credentials = state.credentials.clone(); + let (found, key) = blocking(move || { + credentials.retry(); + let mut waits = 0; + loop { + match old.take(source, &pubkey, credentials.as_ref()) { + Err(error) if error == CREDENTIALS_BUSY && waits < BUSY_WAITS => { + waits += 1; + std::thread::sleep(BUSY_WAIT); + } + taken => return taken, + } + } + }) + .await?; + let prepared = AppAgents::adopt( + &destination, + &owner, + &agent_type, + key, + &found.name, + found.picture, + )?; + let auth = identity + .inner() + .authorize_agent(owner, prepared.pubkey().to_owned()) + .await?; + let host = state.inner().clone(); + blocking(move || { + let mut keys = host.keys(); + let (agent, key) = agents.commit(prepared, &auth, host.credentials.as_ref())?; + keys.insert(agent.pubkey.clone(), Arc::new(key)); + Ok(agent.into()) + }) + .await +} + +/// The newest profile (kind 0) its community has for the agent: its name (empty +/// without one), picture and `created_at`. +async fn current_profile( + agent: &AppAgent, + key: &Secret, +) -> Result, u64)>, String> { + let filters = serde_json::json!([{ "kinds": [0], "authors": [agent.pubkey], "limit": 1 }]); + let events = read(agent, key, filters.as_array().ok_or("Invalid agent read")?).await?; + let Some(event) = events.as_array().and_then(|events| { + events + .iter() + .filter(|event| event["pubkey"] == agent.pubkey.as_str() && event["kind"] == 0) + .max_by_key(|event| event["created_at"].as_u64()) + }) else { + return Ok(None); + }; + // One the app cannot read is replaced; one without a name keeps the + // agent's name here and the picture it has. + let Ok(content) = serde_json::from_str::(event["content"].as_str().unwrap_or("")) else { + return Ok(None); + }; + let name = ["display_name", "name"] + .iter() + .find_map(|field| { + content[field] + .as_str() + .filter(|name| !name.trim().is_empty()) + }) + .unwrap_or_default(); + Ok(Some(( + name.to_owned(), + content["picture"].as_str().map(str::to_owned), + event["created_at"] + .as_u64() + .ok_or("Invalid agent profile")?, + ))) +} + #[tauri::command] pub(crate) async fn app_agent_rename( state: tauri::State<'_, AppAgentHost>, @@ -350,12 +457,22 @@ pub(crate) async fn app_agent_publish_profile( return Ok(()); } let (agent, key) = state.key(agent).await?; + let agents = state.agents.clone()?; + // An adopted key keeps the profile its community already has; only one it + // has none for publishes the name and picture it brought. Until that is + // known, nothing is published. + if agent.adopted && agent.profile.is_none() { + if let Some((name, picture, created_at)) = current_profile(&agent, &key).await? { + let pubkey = agent.pubkey.clone(); + return blocking(move || agents.keep_profile(&pubkey, &name, picture, created_at)) + .await; + } + } let signed = post(&agent, &key, agent.profile_event(&key)?).await?; let created_at = signed .get("created_at") .and_then(Value::as_u64) .ok_or("Invalid agent event")?; - let agents = state.agents.clone()?; blocking(move || { agents.published( &agent.pubkey, @@ -382,13 +499,17 @@ pub(crate) async fn app_agent_query( return Err("Agents read with one to eight filters".into()); } let (agent, key) = state.key(state.agent(pubkey).await?).await?; - let bytes = serde_json::to_vec(&filters).map_err(|_| "Could not encode agent read")?; + read(&agent, &key, &filters).await +} + +async fn read(agent: &AppAgent, key: &Secret, filters: &[Value]) -> Result { + let bytes = serde_json::to_vec(filters).map_err(|_| "Could not encode agent read")?; let response = client(20)? .post(agent.query_url()) .header("Content-Type", "application/json") .header( "Authorization", - authorization(agent.http_auth(&key, &agent.query_url(), &bytes)?)?, + authorization(agent.http_auth(key, &agent.query_url(), &bytes)?)?, ) .header("x-auth-tag", &agent.auth) .body(bytes) @@ -580,6 +701,7 @@ mod tests { let key = Secret::generate().unwrap(); let host = AppAgentHost { agents: Err("unused".into()), + old: Err("unused".into()), credentials: Arc::new(Busy(key.hex().to_string(), AtomicU32::new(3))), keys: Arc::default(), profiles: Arc::default(), @@ -595,6 +717,7 @@ mod tests { picture: None, about: None, profile: None, + adopted: false, deleted: false, }; let (_, read) = host.key(agent).await.unwrap(); @@ -604,7 +727,11 @@ mod tests { #[tokio::test] async fn delete_waits_for_a_profile_publication_in_flight() { let dir = tempfile::tempdir().unwrap(); - let host = AppAgentHost::new(Ok(dir.path().join("identities.json")), true); + let host = AppAgentHost::new( + Ok(dir.path().join("identities.json")), + Err("unused".into()), + true, + ); let pubkey = "c".repeat(64); let turn = host.profile_turn(&pubkey); let publishing = turn.lock().await; diff --git a/src-tauri/src/browser_permissions_tests.rs b/src-tauri/src/browser_permissions_tests.rs index b9c5add18..d794e38ec 100644 --- a/src-tauri/src/browser_permissions_tests.rs +++ b/src-tauri/src/browser_permissions_tests.rs @@ -120,6 +120,8 @@ fn native_command_permissions_allow_only_main_webview() { "agent_control_create_prepare", "app_agent_list", "app_agent_create", + "app_agent_portable", + "app_agent_port", "app_agent_rename", "app_agent_delete", "app_agent_forget", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5e2d6fc75..5740ce4dd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -504,6 +504,8 @@ fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen agent_control_create_prepare, app_agents::app_agent_list, app_agents::app_agent_create, + app_agents::app_agent_portable, + app_agents::app_agent_port, app_agents::app_agent_rename, app_agents::app_agent_delete, app_agents::app_agent_forget, @@ -673,6 +675,13 @@ pub fn run() { .as_ref() .map(|(root, _, _)| root.with_file_name("agents2").join("identities.json")) .map_err(Clone::clone), + paths + .as_ref() + .map(|(root, legacy, _)| buzz_agent_controller::OldStores { + harness: root.join("agents.json"), + data_dir: legacy.clone(), + }) + .map_err(Clone::clone), // `just desktop` builds step aside for a packaged app. !tauri::is_dev(), )); diff --git a/src/bundled/agents2/Agents2Page.module.css b/src/bundled/agents2/Agents2Page.module.css index eedc80421..f476b3c32 100644 --- a/src/bundled/agents2/Agents2Page.module.css +++ b/src/bundled/agents2/Agents2Page.module.css @@ -32,6 +32,22 @@ padding: var(--space-4) var(--space-5) var(--space-6); } +.portList { + display: grid; + margin: 0; + padding: 0; + list-style: none; +} +.portRow { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) 0; +} +.portRow + .portRow { + border-top: 1px solid var(--border-standard); +} + .grid { display: grid; /* Fixed columns, so opening the side panel never resizes a tile. */ diff --git a/src/bundled/agents2/Agents2Page.test.tsx b/src/bundled/agents2/Agents2Page.test.tsx index 3eb719e8a..e7d166814 100644 --- a/src/bundled/agents2/Agents2Page.test.tsx +++ b/src/bundled/agents2/Agents2Page.test.tsx @@ -11,6 +11,7 @@ import type { AgentsSnapshot, RegisteredAgentType, } from "../../features/agents2/service"; +import type { PortableAgent } from "../../features/agents2/native"; import type { RelayData } from "../../features/relay/service"; import { Agents2Page } from "./Agents2Page"; @@ -48,6 +49,22 @@ const agent = (name: string, n: number): Agent => ({ }, }); +const portable: PortableAgent[] = [ + { + source: "harness", + pubkey: "a".repeat(64), + name: "Ada", + relay: "wss://relay.example", + }, + { + source: "installed", + pubkey: "b".repeat(64), + name: "Bea", + picture: "https://old.example/media/b.png", + relay: "", + }, +]; + function setup(agents: Agent[], { shown = true } = {}) { let snapshot: AgentsSnapshot = { status: "ready", agents, running: agents }; const listeners = new Set<() => void>(); @@ -82,6 +99,8 @@ function setup(agents: Agent[], { shown = true } = {}) { for (const listener of listeners) listener(); return created; }), + portable: vi.fn(async () => ({ agents: portable, warnings: [] })), + port: vi.fn(async () => created), save: vi.fn(async () => {}), remove: vi.fn(async () => {}), } satisfies Agents2; @@ -185,3 +204,38 @@ it("saves a rename on Enter and discards it on Escape", async () => { name: "Kept", }); }); + +it("brings over an earlier agent as the chosen type and drops it from the list", async () => { + const user = userEvent.setup(); + const { agents2 } = setup([agent("Ada", 1)]); + await user.click(screen.getByRole("button", { name: "Bring over" })); + const list = await screen.findByRole("list", { + name: "Agents to bring over", + }); + expect( + within(list).getByText(/Buzz 1\.0 harness agent · npub/), + ).toBeInTheDocument(); + expect(within(list).getByText(/Buzz 0\.X · npub/)).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Bring over Bea" })); + expect(agents2.port).toHaveBeenCalledWith({ + source: "installed", + pubkey: "b".repeat(64), + type: "test/echo", + }); + expect(within(list).queryByText("Bea")).not.toBeInTheDocument(); + expect(within(list).getByText("Ada")).toBeInTheDocument(); +}); + +it("shows why bringing an agent over failed and keeps it listed", async () => { + const user = userEvent.setup(); + const { agents2 } = setup([]); + agents2.port.mockRejectedValueOnce(new Error("Keychain access was denied")); + await user.click(screen.getByRole("button", { name: "Bring over" })); + await user.click( + await screen.findByRole("button", { name: "Bring over Ada" }), + ); + expect(screen.getByRole("alert")).toHaveTextContent( + "Keychain access was denied", + ); + expect(screen.getByRole("button", { name: "Bring over Ada" })).toBeEnabled(); +}); diff --git a/src/bundled/agents2/Agents2Page.tsx b/src/bundled/agents2/Agents2Page.tsx index 02f7ebaaa..284ddd713 100644 --- a/src/bundled/agents2/Agents2Page.tsx +++ b/src/bundled/agents2/Agents2Page.tsx @@ -4,7 +4,8 @@ // - Peek: identity, the type's own read-only glance, and what wakes it. // - Build: the identity rail on the left, the type's tabs and the app's // Attention tab on the right. -// New agents are made in the peek slot, so making one never covers the grid. +// New agents are made in the peek slot, so making one never covers the grid, +// and so are agents brought over from an earlier agent system. import { useEffect, useRef, @@ -18,6 +19,11 @@ import type { ChannelChoice } from "../../features/agents2/attention"; import { AgentDelete } from "../../features/agents2/AgentManage"; import { AttentionSummary } from "../../features/agents2/AttentionPanel"; import { useAgents2, useChannelChoices } from "../../features/agents2/react"; +import type { + PortableAgent, + PortableAgents, + PortSource, +} from "../../features/agents2/native"; import type { Agent, Agents2, @@ -40,13 +46,20 @@ import { IconButton } from "../../shared/design-system/ui/IconButton"; import { Input } from "../../shared/design-system/ui/Input"; import { PanelHeader } from "../../shared/design-system/ui/PanelHeader"; import { Radio, RadioGroup } from "../../shared/design-system/ui/RadioGroup"; -import { formatPublicKey } from "../../shared/identity/public-key"; +import { + formatPublicKey, + publicKeyLabels, +} from "../../shared/identity/public-key"; const message = (error: unknown) => error instanceof Error ? error.message : String(error); type Frame = - | { view: "browse"; selected?: string; creating?: boolean } + | { + view: "browse"; + selected?: string; + side?: "creating" | "porting"; + } | { view: "build"; pubkey: string }; /** The type's one-line summary of an agent; its title when it has none. */ @@ -78,11 +91,13 @@ export function Agents2Page({ snapshot.agents.find((agent) => agent.pubkey === pubkey); const building = frame.view === "build" ? find(frame.pubkey) : undefined; const selected = frame.view === "browse" ? find(frame.selected) : undefined; - const creating = frame.view === "browse" && !!frame.creating; + const side = frame.view === "browse" ? frame.side : undefined; + const creating = side === "creating"; const browse = (selected?: string) => setFrame(selected ? { view: "browse", selected } : { view: "browse" }); const build = (pubkey: string) => setFrame({ view: "build", pubkey }); - const newAgent = () => setFrame({ view: "browse", creating: true }); + const newAgent = () => setFrame({ view: "browse", side: "creating" }); + const bringOver = () => setFrame({ view: "browse", side: "porting" }); const body = (() => { if (snapshot.status === "unavailable") @@ -112,13 +127,15 @@ export function Agents2Page({ onRemoved={() => browse()} /> ); - const side = creating ? ( + const panel = creating ? ( browse()} onCreated={(agent) => build(agent.pubkey)} /> + ) : side === "porting" ? ( + browse()} /> ) : selected ? ( ) : undefined; return ( -
+
{snapshot.agents.length ? ( - ) : snapshot.status === "ready" && !creating ? ( + ) : snapshot.status === "ready" && !side ? ( ) : null}
- {side && } + {panel && }
); })(); @@ -186,9 +203,18 @@ export function Agents2Page({ title={building ? building.name : "Agents2"} actions={ !building && types.length > 0 && snapshot.status === "ready" ? ( - +
+ + +
) : undefined } /> @@ -457,6 +483,139 @@ function NewAgent({ ); } +const SOURCES: Record = { + harness: "Buzz 1.0 harness agent", + installed: "Buzz 0.X", + development: "Buzz 0.X dev", +}; +const host = (relay: string) => relay.replace(/^wss:\/\//, ""); + +/** Identities earlier agent systems saved on this machine, each brought over + * with its key, name and picture as an agent of the chosen type. */ +function BringOver({ + agents2, + types, + onClose, +}: { + agents2: Agents2; + types: readonly RegisteredAgentType[]; + onClose(): void; +}) { + const [found, setFound] = useState(); + const [type, setType] = useState(types[0]?.key ?? ""); + const [pending, setPending] = useState(); + const [error, setError] = useState(""); + useEffect(() => { + let live = true; + agents2.portable().then( + (next) => live && setFound(next), + (reason) => live && setError(message(reason)), + ); + return () => { + live = false; + }; + }, [agents2]); + const chosen = types.find((item) => item.key === type) ?? types[0]; + const labels = publicKeyLabels(found?.agents.map((a) => a.pubkey) ?? []); + const port = async (agent: PortableAgent) => { + if (!chosen || pending) return; + setPending(agent.pubkey); + setError(""); + try { + await agents2.port({ + source: agent.source, + pubkey: agent.pubkey, + type: chosen.key, + }); + setFound( + (last) => + last && { + ...last, + agents: last.agents.filter((item) => item.pubkey !== agent.pubkey), + }, + ); + } catch (reason) { + setError(message(reason)); + } finally { + setPending(undefined); + } + }; + return ( +
+ Bring over agents} + onClose={onClose} + /> +

+ Keeps each agent's key, name and picture in this community. Its + instructions and settings stay behind. Stop it where it runs now first, + or both will answer. +

+ + setType(String(value))} + > + {types.map((item) => ( + + ))} + + + {error && ( +

+ {error} +

+ )} + {found?.warnings.map((warning) => ( +

+ {warning} +

+ ))} + {!found ? ( + !error && ( +

+ Looking for agents… +

+ ) + ) : found.agents.length ? ( +
    + {found.agents.map((agent) => ( +
  • + + + {agent.name} + + {[ + SOURCES[agent.source], + labels.get(agent.pubkey), + agent.relay && host(agent.relay), + ] + .filter(Boolean) + .join(" · ")} + + + +
  • + ))} +
+ ) : ( +

+ No other agents are saved on this machine. +

+ )} +
+ ); +} + function AgentBuild({ agents2, agent, diff --git a/src/bundled/builderlab/index.test.tsx b/src/bundled/builderlab/index.test.tsx index c07d2ad43..b92951050 100644 --- a/src/bundled/builderlab/index.test.tsx +++ b/src/bundled/builderlab/index.test.tsx @@ -53,6 +53,8 @@ function provideAgents2(root: Context, relay: RelayData) { return new Agents2Service(root, communities, { list: async () => [], create: vi.fn(), + portable: vi.fn(), + port: vi.fn(), rename: vi.fn(), remove: vi.fn(), forget: vi.fn(), diff --git a/src/features/agents2/native.ts b/src/features/agents2/native.ts index 197e2d167..af98eb19c 100644 --- a/src/features/agents2/native.ts +++ b/src/features/agents2/native.ts @@ -15,6 +15,23 @@ export type AgentIdentity = Readonly<{ * archive it, after which `forget` removes it. */ deleted: boolean; }>; +/** Where an earlier agent system saved an identity on this machine: this app's + * harness agents, or the installed or development build of old Buzz. */ +export type PortSource = "harness" | "installed" | "development"; +/** An identity an earlier agent system saved, which Agents2 can take over. */ +export type PortableAgent = Readonly<{ + source: PortSource; + pubkey: string; + name: string; + picture?: string; + /** The community it was saved for there, if valid. */ + relay: string; +}>; +export type PortableAgents = Readonly<{ + agents: readonly PortableAgent[]; + /** A store that could not be read; the others are still listed. */ + warnings: readonly string[]; +}>; export type AgentEventTemplate = Readonly<{ kind: number; content: string; @@ -40,6 +57,16 @@ export type AgentsNative = { type: string, name: string, ): Promise; + /** Earlier agent systems' identities on this machine. */ + portable(): Promise; + /** Takes one over with its key, name and picture, as `create` does. */ + port( + source: PortSource, + pubkey: string, + destination: string, + owner: string, + type: string, + ): Promise; rename(pubkey: string, name: string): Promise; /** Deletes the key and marks the identity deleted. */ remove(pubkey: string): Promise; @@ -76,6 +103,15 @@ export const nativeAgents = (): AgentsNative | undefined => 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 }), remove: (pubkey) => invoke("app_agent_delete", { pubkey }), forget: (pubkey) => invoke("app_agent_forget", { pubkey }), diff --git a/src/features/agents2/service.test.ts b/src/features/agents2/service.test.ts index 93f2111f3..a3a210b33 100644 --- a/src/features/agents2/service.test.ts +++ b/src/features/agents2/service.test.ts @@ -183,6 +183,38 @@ function fakeNative(identities: AgentIdentity[] = []) { identities.push(identity); return identity; }), + portable: vi.fn(async () => ({ + agents: [ + { source: "harness" as const, pubkey: bot, name: "Old", relay: "" }, + { + source: "installed" as const, + pubkey: sibling, + name: "Older", + relay: "", + }, + ], + warnings: ["Could not read one store"], + })), + port: vi.fn( + async ( + _: string, + pubkey: string, + __: string, + ___: string, + type: string, + ) => { + const identity = { + pubkey, + relay: "wss://relay.example.test", + owner: viewer, + type, + name: "Older", + deleted: false, + }; + identities.push(identity); + return identity; + }, + ), rename: vi.fn(async (pubkey: string, name: string) => { change(pubkey, { name }); }), @@ -319,6 +351,40 @@ it("creates an agent in the community shown, with its type's defaults, and publi expect(service.find(bot)).toEqual(agent); }); +it("brings over an identity not already an agent, with its type's defaults, and publishes its profile", async () => { + const { service, native } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + const found = await service.portable(); + // `bot` is already an agent here; the warning still shows. + expect(found.agents.map((agent) => agent.pubkey)).toEqual([sibling]); + expect(found.warnings).toEqual(["Could not read one store"]); + const agent = await service.port({ + source: "installed", + pubkey: sibling, + type: "example/echo", + }); + expect(native.port).toHaveBeenCalledWith( + "installed", + sibling, + "https://relay.example.test", + viewer, + "example/echo", + ); + expect(native.publishProfile).toHaveBeenCalledWith(sibling); + expect(agent).toMatchObject({ + pubkey: sibling, + name: "Older", + config: { reply: "ok" }, + }); + expect(Object.keys(agent.attention)).toEqual([ + "interest/default", + "watch/channel", + ]); + await expect( + service.port({ source: "harness", pubkey: bot, type: "nope/none" }), + ).rejects.toThrow("That agent type is not available"); +}); + it("delivers mentions and matching watches once, and never the agent's own events", async () => { const { service, run, emit } = await setup(); await service.create({ type: "example/echo", name: "Echo" }); diff --git a/src/features/agents2/service.ts b/src/features/agents2/service.ts index 7c5fc46bc..795626734 100644 --- a/src/features/agents2/service.ts +++ b/src/features/agents2/service.ts @@ -41,6 +41,8 @@ import { type AgentIdentity, type AgentsNative, type AgentUpload, + type PortableAgents, + type PortSource, } from "./native"; import { readRecords, @@ -195,6 +197,14 @@ export type Agents2 = { * the selected one: read the agent's conversations through this. */ relay(pubkey: string): RelayData | undefined; create(input: Readonly<{ type: string; name: string }>): Promise; + /** Identities earlier agent systems saved on this machine that are not + * Agents2 agents yet. */ + portable(): Promise; + /** Takes one over as an agent of `type` in the selected community, keeping its + * key, name and picture. Its instructions and settings stay behind. */ + port( + input: Readonly<{ source: PortSource; pubkey: string; type: string }>, + ): Promise; save(pubkey: string, change: AgentChange): Promise; /** Deletes its key at once. Leaving its channels and archiving it follow in * the background whenever its community is open. */ @@ -439,6 +449,47 @@ export class Agents2Service extends Service implements Agents2 { }; async create({ type, name }: Readonly<{ type: string; name: string }>) { + if (!name.trim()) throw new Error("Name the agent"); + return this.add(type, (native, destination, owner) => + native.create(destination, owner, type, name.trim()), + ); + } + + async portable() { + const native = this.native; + if (!native) throw new Error("Agents run only in the desktop app"); + const found = await native.portable(); + const own = new Set( + this.identities + .filter((identity) => !identity.deleted) + .map((identity) => identity.pubkey), + ); + return { + ...found, + agents: found.agents.filter((agent) => !own.has(agent.pubkey)), + }; + } + + async port({ + source, + pubkey, + type, + }: Readonly<{ source: PortSource; pubkey: string; type: string }>) { + return this.add(type, (native, destination, owner) => + native.port(source, pubkey, destination, owner, type), + ); + } + + /** Saves a new identity of `type` in the selected community with the type's + * default settings. */ + private async add( + type: string, + save: ( + native: AgentsNative, + destination: string, + owner: string, + ) => Promise, + ) { const native = this.native; const selected = this.communities.snapshot().selected; const binding = selected @@ -448,13 +499,11 @@ export class Agents2Service extends Service implements Agents2 { if (!native) throw new Error("Agents run only in the desktop app"); if (!binding) throw new Error("Connect to a community first"); if (!kind) throw new Error("That agent type is not available"); - if (!name.trim()) throw new Error("Name the agent"); const settings = defaults("", kind); - const identity = await native.create( + const identity = await save( + native, relayOrigin(binding.origin), binding.viewer, - type, - name.trim(), ); const record = { ...settings, pubkey: identity.pubkey }; try { From 0859001f0091e8bdb2588e551b7febc886f17c40 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sun, 11 Oct 2026 10:13:34 -0700 Subject: [PATCH 14/39] Preserve complete publication receipts when adopting profiles Adapt adoption to the metadata layer so accepted pictures and descriptions are recorded with the name. Co-authored-by: Codex Signed-off-by: Bradley Axen --- crates/agent-controller/src/app_agent.rs | 8 +++++--- crates/agent-controller/src/app_agent/tests.rs | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/agent-controller/src/app_agent.rs b/crates/agent-controller/src/app_agent.rs index 1b3ff11a6..761b0b559 100644 --- a/crates/agent-controller/src/app_agent.rs +++ b/crates/agent-controller/src/app_agent.rs @@ -432,13 +432,15 @@ impl AppAgents { if let Some(name) = name { agent.name = name; } + if picture.is_some() { + agent.picture = picture; + } agent.profile = Some(Published { name: agent.name.clone(), + picture: agent.picture.clone(), + about: agent.about.clone(), created_at, }); - if picture.is_some() { - agent.picture = picture; - } }) } /// Deletes the key, then marks the row deleted, so a failed deletion can be diff --git a/crates/agent-controller/src/app_agent/tests.rs b/crates/agent-controller/src/app_agent/tests.rs index ace920839..eaba121b0 100644 --- a/crates/agent-controller/src/app_agent/tests.rs +++ b/crates/agent-controller/src/app_agent/tests.rs @@ -503,6 +503,8 @@ fn keeps_the_communitys_profile_for_an_adopted_key_until_renamed() { kept.profile, Some(Published { name: "Old Ada".into(), + picture: Some("https://a.example/p.png".into()), + about: None, created_at: 500 }) ); From 84ed1b2dc0478d5c38621db915112696aec09485 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 10 Oct 2026 21:20:02 -0700 Subject: [PATCH 15/39] Keep Agents2 agents through erase and renames, and show their pictures Erase kept only the earlier agent registry: "keep agents" wiped agents2/identities.json, orphaning its keys, and "remove agents" deleted only keys listed in the earlier registry. Both registries are now kept, and removing agents deletes the keys of both. A rename republished an adopted agent's profile with only its name, bot flag and picture, dropping about, banner, NIP-05 and the rest. It now reads the community's current profile and changes only the name and bot flag, adding the picture only where there is none. The Agents2 grid, peek and build view now show the picture an agent brought over instead of only its initials. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Bradley Axen --- crates/agent-controller/src/app_agent.rs | 39 ++++++-- .../agent-controller/src/app_agent/tests.rs | 76 +++++++++++++++- src-tauri/src/app_agents.rs | 56 +++++++----- src-tauri/src/lib.rs | 16 +++- src-tauri/src/sign_out.rs | 88 +++++++++++-------- src-tauri/src/sign_out/tests.rs | 66 ++++++++++---- src/bundled/agents2/Agents2Page.test.tsx | 14 +++ src/bundled/agents2/Agents2Page.tsx | 17 +++- src/features/agents2/native.ts | 2 + src/features/agents2/service.ts | 3 + src/features/identity/SignOut.tsx | 2 +- 11 files changed, 280 insertions(+), 99 deletions(-) diff --git a/crates/agent-controller/src/app_agent.rs b/crates/agent-controller/src/app_agent.rs index 761b0b559..c2d8714ea 100644 --- a/crates/agent-controller/src/app_agent.rs +++ b/crates/agent-controller/src/app_agent.rs @@ -109,23 +109,36 @@ impl AppAgent { last.name == self.name && last.picture == self.picture && last.about == self.about }) } - /// Its profile as this row describes it, replacing the last one accepted. - pub fn profile_event(&self, key: &Secret) -> Result { + /// Its profile as this row describes it, replacing the last one accepted + /// and `current`, the newest its community has (content and `created_at`). + /// Only the name and bot flag replace what `current` says, and the picture + /// is added only where it has none: an adopted key's profile was made + /// elsewhere and keeps every other field. + pub fn profile_event( + &self, + key: &Secret, + current: Option<(serde_json::Map, u64)>, + ) -> Result { valid_name(&self.name)?; - let mut content = serde_json::json!({ "name": self.name, "bot": true }); - if let Some(picture) = &self.picture { - content["picture"] = picture.as_str().into(); + let after = current.as_ref().map(|(_, at)| *at); + let mut content = current.map(|(content, _)| content).unwrap_or_default(); + content.insert("name".into(), self.name.as_str().into()); + if content.contains_key("display_name") { + content.insert("display_name".into(), self.name.as_str().into()); } - if let Some(about) = &self.about { - content["about"] = about.as_str().into(); + content.insert("bot".into(), true.into()); + if let Some(picture) = &self.picture { + content + .entry("picture") + .or_insert_with(|| picture.as_str().into()); } - let content = content.to_string(); + let content = serde_json::Value::Object(content).to_string(); let tags = self.attested(key, vec![])?; key.sign_event_after( 0, content, tags, - self.profile.as_ref().map(|p| p.created_at), + self.profile.as_ref().map(|p| p.created_at).max(after), ) } fn attested(&self, key: &Secret, mut tags: Vec>) -> Result>> { @@ -456,6 +469,14 @@ impl AppAgents { credentials.delete(&agent.credential_id(), &agent.pubkey)?; self.edit(pubkey, |agent| agent.deleted = true) } + /// Deletes every key it still has, as an erase that removes agents does + /// before the rows go with app data. Absence is success, so it repeats safely. + pub fn delete_keys(&self, credentials: &dyn Credentials) -> Result<()> { + for agent in self.list()?.into_iter().filter(|agent| !agent.deleted) { + credentials.delete(&agent.credential_id(), &agent.pubkey)?; + } + Ok(()) + } /// Drops a deleted row once its community has been cleaned up. pub fn forget(&self, pubkey: &str) -> Result<()> { self.update(|agents| { diff --git a/crates/agent-controller/src/app_agent/tests.rs b/crates/agent-controller/src/app_agent/tests.rs index eaba121b0..544c34117 100644 --- a/crates/agent-controller/src/app_agent/tests.rs +++ b/crates/agent-controller/src/app_agent/tests.rs @@ -113,7 +113,7 @@ fn renames_and_profiles_replace_the_last_accepted_profile() { let credentials = Memory::default(); let (agents, agent) = created(dir.path(), &credentials); let key = agent.read_key(&credentials).unwrap(); - let first = agent.profile_event(&key).unwrap(); + let first = agent.profile_event(&key, None).unwrap(); assert_eq!(first["kind"], 0); assert_eq!(first["content"], r#"{"bot":true,"name":"Ada"}"#); let saved: Vec = serde_json::from_str(&agent.auth).unwrap(); @@ -135,7 +135,7 @@ fn renames_and_profiles_replace_the_last_accepted_profile() { let renamed = agents.get(&agent.pubkey).unwrap(); assert_eq!(renamed.name, "Bea"); assert_eq!(renamed.profile, Some(published(future))); - let next = renamed.profile_event(&key).unwrap(); + let next = renamed.profile_event(&key, None).unwrap(); assert_eq!(next["created_at"], future + 1); assert_eq!(next["content"], r#"{"bot":true,"name":"Bea"}"#); } @@ -427,7 +427,7 @@ fn an_adopted_key_keeps_its_identity_picture_and_shared_saved_key() { assert_eq!(agent.pubkey, pubkey); assert!(agent.adopted); let profile = agent - .profile_event(&agent.read_key(&credentials).unwrap()) + .profile_event(&agent.read_key(&credentials).unwrap(), None) .unwrap(); assert_eq!( profile["content"], @@ -515,7 +515,7 @@ fn keeps_the_communitys_profile_for_an_adopted_key_until_renamed() { agents.rename(&agent.pubkey, "Ada").unwrap(); let renamed = agents.get(&agent.pubkey).unwrap(); let next = renamed - .profile_event(&renamed.read_key(&credentials).unwrap()) + .profile_event(&renamed.read_key(&credentials).unwrap(), None) .unwrap(); assert_eq!( next["content"], @@ -528,3 +528,71 @@ fn keeps_the_communitys_profile_for_an_adopted_key_until_renamed() { assert_eq!(kept.name, "Ada"); assert_eq!(kept.profile.unwrap().created_at, 900); } + +#[test] +fn a_rename_keeps_the_rest_of_the_communitys_profile() { + let dir = tempfile::tempdir().unwrap(); + let credentials = Memory::default(); + let (agents, agent) = created(dir.path(), &credentials); + agents + .keep_profile( + &agent.pubkey, + "Old Ada", + Some("https://a.example/p.png".into()), + 500, + ) + .unwrap(); + agents.rename(&agent.pubkey, "Ada").unwrap(); + let renamed = agents.get(&agent.pubkey).unwrap(); + let current = serde_json::json!({ + "name": "Old Ada", + "display_name": "Old Ada", + "about": "Reviews pull requests", + "banner": "https://a.example/b.png", + "nip05": "ada@a.example", + "picture": "https://a.example/new.png", + "bot": false, + }); + let current = current.as_object().unwrap().clone(); + // Edited elsewhere after the one kept here: the rename must still be newer. + let next = renamed + .profile_event( + &renamed.read_key(&credentials).unwrap(), + Some((current, 9_000_000_000)), + ) + .unwrap(); + let content: serde_json::Value = + serde_json::from_str(next["content"].as_str().unwrap()).unwrap(); + assert_eq!( + content, + serde_json::json!({ + "name": "Ada", + "display_name": "Ada", + "about": "Reviews pull requests", + "banner": "https://a.example/b.png", + "nip05": "ada@a.example", + "picture": "https://a.example/new.png", + "bot": true, + }) + ); + assert_eq!(next["created_at"], 9_000_000_001u64); +} + +#[test] +fn deleting_keys_removes_every_agents_key_and_repeats_safely() { + let dir = tempfile::tempdir().unwrap(); + let credentials = Memory::default(); + let (agents, first) = created(dir.path(), &credentials); + let prepared = AppAgents::prepare("https://relay.example/", &owner(), "p/t", "Bea").unwrap(); + let auth: Vec = + serde_json::from_str(&crate::secret::test_attestation(prepared.pubkey())).unwrap(); + let (second, _) = agents.commit(prepared, &auth, &credentials).unwrap(); + agents.delete_keys(&credentials).unwrap(); + assert!(first.read_key(&credentials).is_err()); + assert!(second.read_key(&credentials).is_err()); + agents.delete_keys(&credentials).unwrap(); + // No saved identities is nothing to delete. + AppAgents::open(dir.path().join("absent/identities.json")) + .delete_keys(&credentials) + .unwrap(); +} diff --git a/src-tauri/src/app_agents.rs b/src-tauri/src/app_agents.rs index 5349cf6a7..2dc971a17 100644 --- a/src-tauri/src/app_agents.rs +++ b/src-tauri/src/app_agents.rs @@ -228,6 +228,8 @@ pub(crate) struct AppAgentSummary { #[serde(rename = "type")] agent_type: String, name: String, + #[serde(skip_serializing_if = "Option::is_none")] + picture: Option, deleted: bool, } impl From for AppAgentSummary { @@ -238,6 +240,7 @@ impl From for AppAgentSummary { owner: agent.owner, agent_type: agent.agent_type, name: agent.name, + picture: agent.picture, deleted: agent.deleted, } } @@ -341,12 +344,12 @@ pub(crate) async fn app_agent_port( .await } -/// The newest profile (kind 0) its community has for the agent: its name (empty -/// without one), picture and `created_at`. +/// The newest profile (kind 0) its community has for the agent: its content +/// and `created_at`. async fn current_profile( agent: &AppAgent, key: &Secret, -) -> Result, u64)>, String> { +) -> Result, u64)>, String> { let filters = serde_json::json!([{ "kinds": [0], "authors": [agent.pubkey], "limit": 1 }]); let events = read(agent, key, filters.as_array().ok_or("Invalid agent read")?).await?; let Some(event) = events.as_array().and_then(|events| { @@ -357,26 +360,28 @@ async fn current_profile( }) else { return Ok(None); }; - // One the app cannot read is replaced; one without a name keeps the - // agent's name here and the picture it has. - let Ok(content) = serde_json::from_str::(event["content"].as_str().unwrap_or("")) else { + // One the app cannot read is replaced. + let Ok(content) = serde_json::from_str(event["content"].as_str().unwrap_or("")) else { return Ok(None); }; + let created_at = event["created_at"] + .as_u64() + .ok_or("Invalid agent profile")?; + Ok(Some((content, created_at))) +} +/// A profile's name (empty without one) and picture. +fn name_and_picture(content: &serde_json::Map) -> (String, Option) { let name = ["display_name", "name"] .iter() .find_map(|field| { - content[field] - .as_str() + content + .get(*field) + .and_then(Value::as_str) .filter(|name| !name.trim().is_empty()) }) .unwrap_or_default(); - Ok(Some(( - name.to_owned(), - content["picture"].as_str().map(str::to_owned), - event["created_at"] - .as_u64() - .ok_or("Invalid agent profile")?, - ))) + let picture = content.get("picture").and_then(Value::as_str); + (name.to_owned(), picture.map(str::to_owned)) } #[tauri::command] @@ -459,16 +464,19 @@ pub(crate) async fn app_agent_publish_profile( let (agent, key) = state.key(agent).await?; let agents = state.agents.clone()?; // An adopted key keeps the profile its community already has; only one it - // has none for publishes the name and picture it brought. Until that is - // known, nothing is published. - if agent.adopted && agent.profile.is_none() { - if let Some((name, picture, created_at)) = current_profile(&agent, &key).await? { - let pubkey = agent.pubkey.clone(); - return blocking(move || agents.keep_profile(&pubkey, &name, picture, created_at)) - .await; - } + // has none for publishes the name and picture it brought. A rename changes + // only the name, keeping the rest. Until that is known, nothing is published. + let current = if agent.adopted { + current_profile(&agent, &key).await? + } else { + None + }; + if let (None, Some((content, created_at))) = (&agent.profile, ¤t) { + let (name, picture) = name_and_picture(content); + let (pubkey, created_at) = (agent.pubkey.clone(), *created_at); + return blocking(move || agents.keep_profile(&pubkey, &name, picture, created_at)).await; } - let signed = post(&agent, &key, agent.profile_event(&key)?).await?; + let signed = post(&agent, &key, agent.profile_event(&key, current)?).await?; let created_at = signed .get("created_at") .and_then(Value::as_u64) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5740ce4dd..376e2d8f8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -576,11 +576,19 @@ pub fn run() { let instance = sign_out::Paths::resolve(&context.config().identifier).map(|paths| { sign_out::boot( &paths, - |registry| { - buzz_agent_controller::delete_local_agent_keys( - registry.to_path_buf(), - &buzz_agent_controller::PlatformCredentials::default(), + |app_data| { + let credentials = buzz_agent_controller::PlatformCredentials::default(); + let old = app_data.join(sign_out::OLD_AGENTS); + if old + .try_exists() + .map_err(|error| format!("check agent registry: {error}"))? + { + buzz_agent_controller::delete_local_agent_keys(old, &credentials)?; + } + buzz_agent_controller::AppAgents::open( + app_data.join(sign_out::AGENTS).join("identities.json"), ) + .delete_keys(&credentials) }, identity::remove_saved_key, ) diff --git a/src-tauri/src/sign_out.rs b/src-tauri/src/sign_out.rs index 037ae5a24..2539e67b4 100644 --- a/src-tauri/src/sign_out.rs +++ b/src-tauri/src/sign_out.rs @@ -32,15 +32,23 @@ const AGENT_STOPPING: &str = const ALREADY: &str = "Buzz is already signing out."; const LINKED: &str = "Erasing is unavailable because a Buzz storage folder is a link or couldn't be checked; nothing was removed"; const DEV_WIPE: &str = "Erasing is unavailable in development builds because they share agent keys and plugin storage with the installed Buzz"; -const KEPT: &str = "agent-controller"; +/// App data folders of the Agents2 list and of the earlier agent system, +/// whose agents Agents2 can still port. +pub(crate) const AGENTS: &str = "agents2"; +pub(crate) const OLD_AGENTS: &str = "agent-controller"; /// One lock per running agent, held by its supervisor until the agent has /// stopped, even after Buzz exits. Shared by every Buzz, under the user data folder. pub(crate) const AGENT_OWNERSHIP: &str = "dev.local.buzz.agent-ownership"; -/// What a kept agent needs to be identified and start again: the agent list -/// with each agent's settings, and the shared agent defaults. Its keys live in -/// the keychain. Anything else in `KEPT` (saved logins, logs, run folders, and -/// whatever is added later) is wiped. -const KEPT_FILES: [&str; 2] = ["agents.json", "defaults.json"]; +/// What kept agents need to be identified and start again, by app data folder: +/// the Agents2 list, and the earlier system's list and defaults to port from. +/// Their keys live in the keychain. Anything else in these folders (hosting +/// locks, saved logins, logs, run folders, and whatever is added later) is wiped. +const KEPT: Kept = &[ + (AGENTS, &["identities.json"]), + (OLD_AGENTS, &["agents.json", "defaults.json"]), +]; +/// The children a wiped folder keeps, each with the files it keeps. +type Kept = &'static [(&'static str, &'static [&'static str])]; /// Held shared by every Buzz and alone only to wipe. Named for the storage every /// Buzz shares whatever its identifier: the default plugin folder and the fixed /// agent key service. It is the release app's name too. @@ -118,18 +126,20 @@ impl Paths { others, }) } - /// Each wiped folder, and the child it keeps (the agent registry) unless agents go too. - fn targets(&self, choices: Choices) -> Vec<(&Path, Option<&str>)> { - let kept = (!choices.remove_agents).then_some(KEPT); + /// Each wiped folder, and the children it keeps (the agent registries) unless agents go too. + fn targets(&self, choices: Choices) -> Vec<(&Path, Kept)> { + let kept = if choices.remove_agents { &[] } else { KEPT }; std::iter::once((self.app_data.as_path(), kept)) - .chain(self.others.iter().map(|path| (path.as_path(), None))) + .chain(self.others.iter().map(|path| (path.as_path(), &[] as Kept))) .collect() } /// The wipe never deletes through a link: every folder it moves, opens or - /// removes, its trash and the kept registry must be a real folder or absent. + /// removes, its trash and the kept registries must be a real folder or absent. fn unlinked(&self, choices: Choices) -> std::io::Result<()> { if choices.remove_agents { - real_dir(&self.app_data.join(KEPT))?; + for (registry, _) in KEPT { + real_dir(&self.app_data.join(registry))?; + } } if !choices.wipe { return Ok(()); @@ -137,7 +147,7 @@ impl Paths { for (path, kept) in self.targets(choices) { for path in [path.to_owned(), trash(path)] { if real_dir(&path)? { - if let Some(child) = kept { + for (child, _) in kept { real_dir(&path.join(child))?; } } @@ -445,15 +455,15 @@ fn missing(result: std::io::Result<()>) -> std::io::Result<()> { } } -/// Move `path` to its trash name, then return its kept child. Resumes an +/// Move `path` to its trash name, then return its kept children. Resumes an /// earlier attempt that stopped part-way. -fn move_aside(path: &Path, kept: Option<&str>) -> std::io::Result<()> { +fn move_aside(path: &Path, kept: Kept) -> std::io::Result<()> { let moved = trash(path); if !real_dir(&moved)? { real_dir(path)?; missing(fs::rename(path, &moved))?; } - if let Some(child) = kept { + for (child, _) in kept { if real_dir(&moved.join(child))? && !(real_dir(path)? && real_dir(&path.join(child))?) { fs::create_dir_all(path)?; fs::rename(moved.join(child), path.join(child))?; @@ -462,32 +472,36 @@ fn move_aside(path: &Path, kept: Option<&str>) -> std::io::Result<()> { Ok(()) } -fn move_back(path: &Path, kept: Option<&str>) -> std::io::Result<()> { +fn move_back(path: &Path, kept: Kept) -> std::io::Result<()> { let moved = trash(path); if !real_dir(&moved)? { return Ok(()); } - if let Some(child) = kept { + for (child, _) in kept { if real_dir(path)? && real_dir(&path.join(child))? { real_dir(&moved.join(child))?; fs::rename(path.join(child), moved.join(child))?; } } - // Only the kept child was returned, so anything else here is unexpected; keep it. + // Only the kept children were returned, so anything else here is unexpected; keep it. if real_dir(path)? { missing(fs::remove_dir(path))?; } fs::rename(moved, path) } -/// Delete `path` except its kept child's `KEPT_FILES`, whether it is the +/// Delete `path` except its kept children's kept files, whether it is the /// original or was recreated. -fn clear(path: &Path, kept: Option<&str>) -> std::io::Result<()> { - let Some(kept) = kept else { +fn clear(path: &Path, kept: Kept) -> std::io::Result<()> { + if kept.is_empty() { return remove_real_dir(path); - }; - clear_except(path, &[kept])?; - clear_except(&path.join(kept), &KEPT_FILES) + } + let children: Vec<&str> = kept.iter().map(|(child, _)| *child).collect(); + clear_except(path, &children)?; + for (child, files) in kept { + clear_except(&path.join(child), files)?; + } + Ok(()) } /// Delete a real folder; absent is done, and a link is refused, never followed or unlinked. @@ -498,11 +512,11 @@ fn remove_real_dir(path: &Path) -> std::io::Result<()> { missing(fs::remove_dir_all(path)) } -/// Delete `path`'s trash, refusing a link at the trash or its kept child. -fn remove_trash(path: &Path, kept: Option<&str>) -> std::io::Result<()> { +/// Delete `path`'s trash, refusing a link at the trash or a kept child. +fn remove_trash(path: &Path, kept: Kept) -> std::io::Result<()> { let moved = trash(path); - if let Some(child) = kept { - if real_dir(&moved)? { + if real_dir(&moved)? { + for (child, _) in kept { real_dir(&moved.join(child))?; } } @@ -549,19 +563,15 @@ fn finish( remove_agent_keys: impl FnOnce(&Path) -> Result<(), String>, remove_key: impl FnOnce() -> Result<(), String>, ) -> Result<(), String> { - // Agent keys go first, while their registry is still in place; nothing is - // moved until every key is gone, so a registry confirmed absent means it's done. - // Failing to look is not absence: stop before moving anything. + // Agent keys go first, while their registries in app data are still in + // place; nothing is moved until every key is gone, so a registry confirmed + // absent means it's done. Failing to look is not absence: stop before moving anything. paths .unlinked(choices) .map_err(|error| format!("check wipe folders: {error}"))?; - let registry = paths.app_data.join(KEPT); - if choices.remove_agents - && registry - .try_exists() - .map_err(|error| format!("check agent registry: {error}"))? - { - remove_agent_keys(®istry).map_err(|error| format!("remove agent keys: {error}"))?; + if choices.remove_agents { + remove_agent_keys(&paths.app_data) + .map_err(|error| format!("remove agent keys: {error}"))?; } let targets = if choices.wipe { paths.targets(choices) diff --git a/src-tauri/src/sign_out/tests.rs b/src-tauri/src/sign_out/tests.rs index 845668871..bf20de392 100644 --- a/src-tauri/src/sign_out/tests.rs +++ b/src-tauri/src/sign_out/tests.rs @@ -17,8 +17,18 @@ fn fixture() -> (tempfile::TempDir, Paths) { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let paths = paths_for(root, "identity"); - fs::create_dir_all(paths.app_data.join(KEPT)).unwrap(); - fs::write(paths.app_data.join(KEPT).join("agents.json"), "agents").unwrap(); + fs::create_dir_all(paths.app_data.join(OLD_AGENTS)).unwrap(); + fs::write( + paths.app_data.join(OLD_AGENTS).join("agents.json"), + "agents", + ) + .unwrap(); + fs::create_dir_all(paths.app_data.join(AGENTS)).unwrap(); + fs::write( + paths.app_data.join(AGENTS).join("identities.json"), + "identities", + ) + .unwrap(); fs::write(paths.app_data.join("archive.sqlite3"), "archive").unwrap(); fs::create_dir_all(&paths.others[0]).unwrap(); fs::write(paths.others[0].join("localstorage"), "prefs").unwrap(); @@ -127,6 +137,8 @@ fn wipe_keeps_agents_unless_they_are_removed_too() { "app/", "app/agent-controller/", "app/agent-controller/agents.json", + "app/agents2/", + "app/agents2/identities.json", ] }; assert_eq!(listing(dir.path()), expected); @@ -166,7 +178,9 @@ fn a_wipe_interrupted_part_way_resumes_on_the_next_launch() { [ "app/", "app/agent-controller/", - "app/agent-controller/agents.json" + "app/agent-controller/agents.json", + "app/agents2/", + "app/agents2/identities.json" ] ); } @@ -230,7 +244,9 @@ fn a_retry_clears_storage_recreated_after_the_key_was_removed() { [ "app/", "app/agent-controller/", - "app/agent-controller/agents.json" + "app/agent-controller/agents.json", + "app/agents2/", + "app/agents2/identities.json" ] ); } @@ -454,7 +470,9 @@ fn across_processes_a_running_instance_blocks_sign_out_and_a_later_launch_finish [ "app/", "app/agent-controller/", - "app/agent-controller/agents.json" + "app/agent-controller/agents.json", + "app/agents2/", + "app/agents2/identities.json" ] ); } @@ -521,7 +539,7 @@ fn an_uninspectable_kept_registry_stops_the_wipe_and_the_retry_keeps_it() { let (dir, paths) = fixture(); mark(&paths, true, false); let before = listing(dir.path()); - let registry = paths.app_data.join(KEPT); + let registry = paths.app_data.join(OLD_AGENTS); let chmod = |args: &[&str]| { let status = std::process::Command::new("chmod") .args(args) @@ -544,7 +562,9 @@ fn an_uninspectable_kept_registry_stops_the_wipe_and_the_retry_keeps_it() { [ "app/", "app/agent-controller/", - "app/agent-controller/agents.json" + "app/agent-controller/agents.json", + "app/agents2/", + "app/agents2/identities.json" ] ); } @@ -656,7 +676,9 @@ fn a_sign_out_escalated_to_a_wipe_during_recovery_waits_for_every_copy() { [ "app/", "app/agent-controller/", - "app/agent-controller/agents.json" + "app/agent-controller/agents.json", + "app/agents2/", + "app/agents2/identities.json" ] ); } @@ -700,8 +722,9 @@ fn agent_keys_go_first_and_an_interruption_retries_without_moving_anything() { assert_eq!( finish_pending( &paths, - |registry| { - assert!(registry.join("agents.json").exists()); + |app_data| { + assert!(app_data.join(OLD_AGENTS).join("agents.json").exists()); + assert!(app_data.join(AGENTS).join("identities.json").exists()); Err("keychain interrupted".into()) }, || panic!("the human key waits for the agent keys") @@ -728,7 +751,7 @@ fn agent_keys_go_first_and_an_interruption_retries_without_moving_anything() { #[test] fn kept_agents_keep_only_their_list_settings_and_keys() { let (dir, paths) = fixture(); - let controller = paths.app_data.join(KEPT); + let controller = paths.app_data.join(OLD_AGENTS); for file in [ "defaults.json", "agents.previous.json", @@ -744,6 +767,11 @@ fn kept_agents_keep_only_their_list_settings_and_keys() { fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, "x").unwrap(); } + for file in ["identities.lock", "identities.tmp", "hosts/agent-1.lock"] { + let path = paths.app_data.join(AGENTS).join(file); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, "x").unwrap(); + } mark(&paths, true, false); assert_eq!(finish_pending(&paths, no_agents, || Ok(())), Ok(())); assert_eq!( @@ -752,18 +780,24 @@ fn kept_agents_keep_only_their_list_settings_and_keys() { "app/", "app/agent-controller/", "app/agent-controller/agents.json", - "app/agent-controller/defaults.json" + "app/agent-controller/defaults.json", + "app/agents2/", + "app/agents2/identities.json" ] ); assert_eq!(fs::read(controller.join("agents.json")).unwrap(), b"agents"); assert_eq!(fs::read(controller.join("defaults.json")).unwrap(), b"x"); + assert_eq!( + fs::read(paths.app_data.join(AGENTS).join("identities.json")).unwrap(), + b"identities" + ); } #[cfg(unix)] #[test] fn a_linked_kept_registry_stops_the_wipe_and_deletes_nothing_through_it() { let (dir, paths) = fixture(); - let controller = paths.app_data.join(KEPT); + let controller = paths.app_data.join(OLD_AGENTS); let outside = dir.path().join("outside"); fs::create_dir(&outside).unwrap(); let files = [ @@ -1017,7 +1051,7 @@ fn a_kept_registry_recreated_as_a_link_in_trash_fails_and_the_retry_finishes() { let (dir, paths) = fixture(); let files = ["sentinel"]; let outside = outside(dir.path(), &files); - let link = trash(&paths.app_data).join(KEPT); + let link = trash(&paths.app_data).join(OLD_AGENTS); mark(&paths, true, false); let result = finish_pending(&paths, no_agents, || { std::os::unix::fs::symlink(&outside, &link).unwrap(); @@ -1031,7 +1065,7 @@ fn a_kept_registry_recreated_as_a_link_in_trash_fails_and_the_retry_finishes() { assert_eq!(finish_pending(&paths, no_agents, || Ok(())), Ok(())); assert!(!paths.marker.exists()); assert_eq!( - fs::read(paths.app_data.join(KEPT).join("agents.json")).unwrap(), + fs::read(paths.app_data.join(OLD_AGENTS).join("agents.json")).unwrap(), b"agents" ); unchanged(&outside, &files); @@ -1067,7 +1101,7 @@ fn a_wipe_folder_or_trash_recreated_as_a_link_fails_and_the_retry_finishes() { #[test] fn a_linked_wipe_folder_refuses_before_any_marker() { let (dir, paths) = fixture(); - let controller = paths.app_data.join(KEPT); + let controller = paths.app_data.join(OLD_AGENTS); fs::remove_dir_all(&controller).unwrap(); std::os::unix::fs::symlink(dir.path(), &controller).unwrap(); let failure = run(prepare( diff --git a/src/bundled/agents2/Agents2Page.test.tsx b/src/bundled/agents2/Agents2Page.test.tsx index e7d166814..b1cd647e3 100644 --- a/src/bundled/agents2/Agents2Page.test.tsx +++ b/src/bundled/agents2/Agents2Page.test.tsx @@ -157,6 +157,20 @@ it("opens the build view with the type's tabs and the app's Attention tab", asyn ); }); +it("shows an agent's picture in the grid, peek and build view", async () => { + const user = userEvent.setup(); + const picture = "https://old.example/media/a.png"; + setup([{ ...agent("Ada", 1), picture }]); + const pictures = () => + document.querySelectorAll(`img[src="${picture}"]`).length; + expect(pictures()).toBe(1); + await user.click(screen.getByRole("option", { name: /Ada/ })); + expect(pictures()).toBe(2); + await user.keyboard("{Enter}"); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + expect(pictures()).toBe(1); +}); + it("makes a new agent in the side panel and goes straight to building it", async () => { const user = userEvent.setup(); const { agents2 } = setup([agent("Ada", 1)]); diff --git a/src/bundled/agents2/Agents2Page.tsx b/src/bundled/agents2/Agents2Page.tsx index 284ddd713..e21c8715e 100644 --- a/src/bundled/agents2/Agents2Page.tsx +++ b/src/bundled/agents2/Agents2Page.tsx @@ -303,6 +303,7 @@ function Inventory({ >
- +

{agent.name}

@@ -639,7 +646,13 @@ function AgentBuild({
- ); -} diff --git a/src/app/AgentSettings.module.css b/src/app/AgentSettings.module.css deleted file mode 100644 index 607cea12d..000000000 --- a/src/app/AgentSettings.module.css +++ /dev/null @@ -1,58 +0,0 @@ -.rows { - margin: var(--space-3) 0; - padding: 0; - list-style: none; -} - -.rows > li + li { - border-top: 1px solid var(--border-standard); -} - -.piSetup { - margin-top: var(--space-3); - margin-left: var(--space-2); - padding-left: var(--space-4); - border-left: 1px solid var(--border-standard); -} - -.command { - overflow-wrap: anywhere; - user-select: all; -} - -.catalog { - display: grid; - grid-template-columns: 12rem minmax(0, 1fr); - gap: var(--space-6); - min-height: 100%; -} - -.catalogSidebar { - border-right: 1px solid var(--border-standard); - padding-right: var(--space-4); -} - -.catalogDetails { - min-width: 0; - padding: var(--space-6); - border-radius: var(--radius-control); - background: var(--surface-inset); -} - -@media (max-width: 640px) { - .catalog { - grid-template-columns: minmax(0, 1fr); - gap: var(--space-3); - } - - .catalogDetails { - padding: var(--space-4); - } - - .catalogSidebar { - border-right: 0; - border-bottom: 1px solid var(--border-standard); - padding-right: 0; - padding-bottom: var(--space-3); - } -} diff --git a/src/app/AgentSettings.test.tsx b/src/app/AgentSettings.test.tsx index f4147ff22..72d5a52b5 100644 --- a/src/app/AgentSettings.test.tsx +++ b/src/app/AgentSettings.test.tsx @@ -1,35 +1,17 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; import { ToastProvider } from "../shared/design-system/ui/Toast"; -import { - act, - cleanup, - render, - screen, - waitFor, - within, -} from "@testing-library/react"; +import { act, cleanup, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; -import { - createAgentControl, - type AgentControlHost, - type HarnessInstallReport, -} from "../features/agents/control"; -import { controlFixture } from "../features/agents/control-testing"; import { AgentSettings } from "./AgentSettings"; -import { AgentHarnessEditor } from "../bundled/agents/AgentHarnessEditor"; -import { agentDraft } from "../bundled/agents/agent-edit"; import { rememberAgentsPreference, setRememberAgentsPreference, } from "../features/messages/mention-preferences"; -const browserControl = createAgentControl(null); -const disposals: (() => void)[] = []; afterEach(() => { cleanup(); - for (const dispose of disposals.splice(0)) dispose(); vi.restoreAllMocks(); setRememberAgentsPreference(true); localStorage.clear(); @@ -37,7 +19,7 @@ afterEach(() => { it("defaults on, persists opt-out, and follows another window's preference", async () => { const user = userEvent.setup(); - render(, { + render(, { wrapper: ToastProvider, }); const toggle = () => @@ -46,7 +28,7 @@ it("defaults on, persists opt-out, and follows another window's preference", asy await user.click(toggle()); expect(rememberAgentsPreference()).toBe(false); cleanup(); - render(, { + render(, { wrapper: ToastProvider, }); expect(toggle()).not.toBeChecked(); @@ -61,7 +43,7 @@ it("defaults on, persists opt-out, and follows another window's preference", asy it("keeps an unsaved opt-out effective and offers retry without changing the choice", async () => { const user = userEvent.setup(); - render(, { + render(, { wrapper: ToastProvider, }); const write = vi @@ -86,1017 +68,3 @@ it("does not prefill when storage cannot be read", () => { }); expect(rememberAgentsPreference()).toBe(false); }); - -function setupHarnesses( - piStatus: "ready" | "cli-needed" | "adapter-needed", - pi: { - installSupported?: boolean; - updateSupported?: boolean; - installPi?: NonNullable; - claude?: NonNullable< - ReturnType["data"]["claudeSetup"] - >; - installClaude?: NonNullable; - checkClaudeAuth?: () => Promise; - } = {}, -) { - const fixture = controlFixture(); - if (pi.installPi) fixture.host.installPi = pi.installPi; - if (pi.installClaude) fixture.host.installClaude = pi.installClaude; - if (pi.checkClaudeAuth) - Object.assign(fixture.host, { checkClaudeAuth: pi.checkClaudeAuth }); - if (pi.claude) fixture.data.claudeSetup = pi.claude; - fixture.data.harnessOptions = [ - { - command: "buzz-agent", - label: "Buzz Agent", - available: true, - status: "ready", - providers: [], - }, - { - command: "goose", - label: "Goose", - available: true, - status: "ready", - providers: [], - }, - { - command: "buzz-pi-acp", - label: "Pi", - available: piStatus === "ready", - status: piStatus, - ...(pi.installSupported !== undefined - ? { installSupported: pi.installSupported } - : {}), - ...(pi.updateSupported !== undefined - ? { updateSupported: pi.updateSupported } - : {}), - providers: [], - }, - { - command: "hermes-acp", - label: "Hermes Agent", - available: false, - status: "cli-needed", - installSupported: false, - providers: [], - }, - ]; - const control = createAgentControl(fixture.host); - disposals.push(() => control.dispose()); - render(, { wrapper: ToastProvider }); - return { fixture, control }; -} - -it.each(["cli-needed", "ready"] as const)( - "shows Harnesses and keeps manual Pi commands collapsed until requested (%s)", - async (piStatus) => { - const user = userEvent.setup(); - setupHarnesses(piStatus); - const list = await screen.findByRole("list", { name: "Harnesses" }); - const rows = within(list) - .getAllByRole("listitem") - .filter((row) => row.parentElement === list); - expect(rows).toHaveLength(3); - const pi = rows[2]; - if (!pi) throw new Error("Missing Pi row"); - expect(rows[0]).toHaveTextContent("Buzz AgentReady"); - expect(rows[1]).toHaveTextContent("GooseReady"); - expect(rows[2]).toHaveTextContent( - `Pi${piStatus === "ready" ? "Ready" : piStatus === "cli-needed" ? "CLI needed" : "Adapter needed"}`, - ); - const copyPi = screen.queryByRole("button", { name: "Copy Pi command" }); - if (piStatus === "ready") { - expect(copyPi).not.toBeInTheDocument(); - expect(screen.queryByText(/npm install -g/)).not.toBeInTheDocument(); - } else { - expect(screen.getByLabelText("Copy Pi command")).not.toBeVisible(); - const manual = within(pi).getByText("Manual setup"); - expect(manual.closest("details")).not.toHaveAttribute("open"); - await user.click(manual); - const steps = within( - screen.getByRole("list", { name: "Manual setup steps" }), - ).getAllByRole("listitem"); - expect(steps).toHaveLength(4); - expect(steps[0]).toHaveTextContent("Install Node.js"); - expect(steps[1]).toHaveTextContent("Install Pi"); - expect(steps[2]).toHaveTextContent("Install the ACP adapter"); - expect(steps[3]).toHaveTextContent("Check the installation"); - expect( - screen.getByText( - "npm install -g '@earendil-works/pi-coding-agent@>=0.99.0'", - ), - ).toBeVisible(); - expect( - screen.getByText( - /git\+https:\/\/github.com\/salman1993\/buzz-pi-acp.git#72015de/, - ), - ).toBeVisible(); - const write = vi - .spyOn(navigator.clipboard, "writeText") - .mockResolvedValue(); - await user.click(screen.getByRole("button", { name: "Copy Pi command" })); - expect(write).toHaveBeenCalledWith( - "npm install -g '@earendil-works/pi-coding-agent@>=0.99.0'", - ); - await user.click( - screen.getByRole("button", { name: "Copy Adapter command" }), - ); - expect(write).toHaveBeenCalledWith( - "npm install -g --install-links=true 'git+https://github.com/salman1993/buzz-pi-acp.git#72015de'", - ); - expect(await screen.findByRole("status", { name: "" })).toHaveTextContent( - "Adapter command copied.", - ); - } - await user.hover(screen.getByRole("button", { name: "About ACP" })); - expect(await screen.findByRole("tooltip")).toHaveTextContent( - "Hermes Agent uses its own ACP launcher and sign-in.", - ); - }, -); - -it("discovers Tier 2 Hermes through Add harness and follows installation changes", async () => { - const user = userEvent.setup(); - const { fixture } = setupHarnesses("ready"); - const list = await screen.findByRole("list", { name: "Harnesses" }); - expect(within(list).queryByText("Hermes Agent")).not.toBeInTheDocument(); - const add = screen.getByRole("button", { name: "Add harness" }); - await user.click(add); - const dialog = await screen.findByRole("dialog", { name: "Add harness" }); - expect(within(dialog).getByText("CLI needed")).toBeVisible(); - expect( - within(dialog).queryByRole("button", { name: /Install|Update/ }), - ).toBeNull(); - expect( - within(dialog).getByRole("link", { name: "Hermes Agent setup guide" }), - ).toHaveAttribute( - "href", - "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp/", - ); - await user.click(within(dialog).getByText("Terminal setup")); - expect(within(dialog).getByText("hermes model")).toBeVisible(); - expect(within(dialog).getByText("hermes acp --check")).toBeVisible(); - const hermes = fixture.data.harnessOptions?.find( - (option) => option.label === "Hermes Agent", - ); - if (!hermes) throw new Error("Missing Hermes option"); - Object.assign(hermes, { - command: "/local/hermes-acp", - available: true, - status: "ready", - }); - await user.click(within(dialog).getByRole("button", { name: "Check again" })); - await waitFor(() => expect(within(dialog).getByText("Ready")).toBeVisible()); - expect(within(dialog).getByText("/local/hermes-acp")).toBeVisible(); - await user.keyboard("{Escape}"); - await waitFor(() => - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), - ); - await waitFor(() => expect(add).toHaveFocus()); - expect( - within(list).getByText("Hermes Agent").closest("li"), - ).toHaveTextContent("Ready"); - expect( - within(list).queryByText(/Uses the default model and credentials/), - ).toBeNull(); - expect(within(list).queryByText("Manual Hermes Agent setup")).toBeNull(); - Object.assign(hermes, { - command: "hermes-acp", - available: false, - status: "cli-needed", - }); - await user.click(screen.getByRole("button", { name: "Check again" })); - await waitFor(() => - expect(within(list).queryByText("Hermes Agent")).not.toBeInTheDocument(), - ); - await user.click(add); - expect( - within(await screen.findByRole("dialog")).getByText("CLI needed"), - ).toBeVisible(); -}); - -it("keeps Tier 1 harnesses usable when an older desktop has no Hermes option", async () => { - const user = userEvent.setup(); - const { fixture, control } = setupHarnesses("ready"); - fixture.data.harnessOptions = (fixture.data.harnessOptions ?? []).filter( - (option) => option.label !== "Hermes Agent", - ); - await act(() => control.refresh()); - const list = await screen.findByRole("list", { name: "Harnesses" }); - expect(within(list).getByText("Goose")).toBeVisible(); - await user.click(screen.getByRole("button", { name: "Add harness" })); - expect( - await screen.findByRole("dialog", { name: "Add harness" }), - ).toHaveTextContent("Update the desktop app to check Hermes Agent."); -}); - -it("recognizes harness commands independently of native display labels", async () => { - const { fixture, control } = setupHarnesses("ready"); - for (const option of fixture.data.harnessOptions ?? []) { - option.label = `Renamed ${option.label}`; - } - await act(() => control.refresh()); - const list = await screen.findByRole("list", { name: "Harnesses" }); - expect(within(list).getByText("Renamed Goose")).toBeVisible(); - expect( - within(list).queryByText("Renamed Hermes Agent"), - ).not.toBeInTheDocument(); - const user = userEvent.setup(); - await user.click(screen.getByRole("button", { name: "Add harness" })); - const dialog = await screen.findByRole("dialog", { name: "Add harness" }); - expect( - within(dialog).getByRole("heading", { name: "Renamed Hermes Agent" }), - ).toBeVisible(); - expect( - within(dialog).getByRole("link", { - name: "Renamed Hermes Agent setup guide", - }), - ).toHaveAttribute( - "href", - "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp/", - ); -}); - -type CodexStatus = "ready" | "cli-needed" | "adapter-needed"; -function codexHarnessOptions(status: CodexStatus, installSupported = true) { - return [ - ...(["buzz-agent", "goose", "buzz-pi-acp"] as const).map((command) => ({ - command, - label: command, - available: true, - status: "ready" as const, - providers: [], - })), - { - id: "codex" as const, - command: "/tools/codex-acp", - label: "Codex", - available: status === "ready", - status, - installSupported, - providers: [], - }, - ]; -} -function codexHarnesses(status: CodexStatus, installSupported = true) { - const fixture = controlFixture(); - fixture.data.harnessOptions = codexHarnessOptions(status, installSupported); - return fixture; -} -const codexRow = () => { - const row = screen.getByText("Codex").closest("li"); - if (!row) throw new Error("Missing Codex row"); - return within(row); -}; - -it("reads Codex sign-in only once its tools are found, and rechecks on Check again", async () => { - const user = userEvent.setup(); - const fixture = codexHarnesses("cli-needed"); - const checkCodexAuth = vi - .fn>() - .mockResolvedValue(false); - fixture.host.checkCodexAuth = checkCodexAuth; - const control = createAgentControl(fixture.host); - disposals.push(() => control.dispose()); - render(, { wrapper: ToastProvider }); - - await screen.findByText("Codex"); - const codexIcon = screen - .getByText("Codex") - .closest("li") - ?.querySelector("svg"); - expect(codexIcon).not.toHaveClass("tabler-icon"); - expect(codexIcon).toHaveAttribute("viewBox", "176 176 364 364"); - expect(await codexRow().findByText("CLI needed")).toBeVisible(); - expect(codexRow().getByText(/Install the Codex CLI/)).toBeVisible(); - expect(codexRow().queryByRole("button", { name: "Install" })).toBeNull(); - expect(checkCodexAuth).not.toHaveBeenCalled(); - - fixture.data.harnessOptions = codexHarnessOptions("ready"); - await user.click(screen.getByRole("button", { name: "Check again" })); - expect(await codexRow().findByText("Sign-in needed")).toBeVisible(); - expect(codexRow().getByText(/Run codex login/)).toBeVisible(); - - let finish!: (signedIn: boolean) => void; - checkCodexAuth.mockReturnValueOnce( - new Promise((resolve) => { - finish = resolve; - }), - ); - await user.click(screen.getByRole("button", { name: "Check again" })); - try { - expect(await codexRow().findByText("Checking sign-in…")).toBeVisible(); - expect(codexRow().queryByText(/Run codex login/)).toBeNull(); - } finally { - await act(async () => finish(true)); - } - expect(await codexRow().findByText("Ready")).toBeVisible(); - // The status is the whole success state; no message or version suffix. - expect(screen.getByText("Codex").closest("li")).toHaveTextContent( - /^CodexReady$/, - ); -}); - -it("offers the Codex ACP adapter Install only when the adapter is missing, then re-detects", async () => { - const user = userEvent.setup(); - const unsupported = codexHarnesses("adapter-needed", false); - unsupported.host.installCodex = vi.fn(); - const blocked = createAgentControl(unsupported.host); - disposals.push(() => blocked.dispose()); - const first = render(, { - wrapper: ToastProvider, - }); - await screen.findByText("Codex"); - expect(await codexRow().findByText("Adapter needed")).toBeVisible(); - expect(codexRow().queryByRole("button", { name: "Install" })).toBeNull(); - first.unmount(); - - const fixture = codexHarnesses("adapter-needed"); - fixture.host.checkCodexAuth = vi.fn(async () => true); - const failed: HarnessInstallReport = { - ready: false, - restarted: 0, - restartFailures: 0, - logPath: "/fixture/codex-install.log", - output: "npm ERR! network", - error: "Installing the Codex ACP adapter failed", - }; - let complete!: (report: HarnessInstallReport) => void; - const install = vi - .fn>() - .mockResolvedValueOnce(failed) - .mockImplementationOnce( - () => - new Promise((resolve) => { - complete = resolve; - }), - ); - fixture.host.installCodex = install; - const control = createAgentControl(fixture.host); - disposals.push(() => control.dispose()); - render(, { wrapper: ToastProvider }); - - await screen.findByText("Codex"); - expect(await codexRow().findByText("Adapter needed")).toBeVisible(); - expect( - codexRow().getByText(/Buzz installs Node.js and the Codex ACP adapter/), - ).toBeVisible(); - await user.click(codexRow().getByText("Manual Codex ACP adapter setup")); - expect( - codexRow().getByText("npm install -g @agentclientprotocol/codex-acp@2.1.1"), - ).toBeVisible(); - - await user.click(codexRow().getByRole("button", { name: "Install" })); - const alert = await codexRow().findByRole("alert"); - expect(alert).toHaveTextContent("Installing the Codex ACP adapter failed"); - await user.click(within(alert).getByText("Codex ACP adapter install log")); - expect(within(alert).getByText("npm ERR! network")).toBeVisible(); - - await user.click(codexRow().getByRole("button", { name: "Install" })); - expect( - await screen.findByText("Installing Node.js and the Codex ACP adapter…"), - ).toBeVisible(); - // Loading stays focusable for keyboard users but cannot start another install. - const installing = codexRow().getByRole("button", { name: "Install" }); - expect(installing).toHaveAttribute("aria-disabled", "true"); - await user.click(installing); - expect(install).toHaveBeenCalledTimes(2); - fixture.data.harnessOptions = codexHarnessOptions("ready"); - await act(async () => - complete({ ...failed, ready: true, error: null, output: "" }), - ); - expect(await codexRow().findByText("Ready")).toBeVisible(); - expect(codexRow().queryByRole("button", { name: "Install" })).toBeNull(); - expect(install).toHaveBeenCalledTimes(2); -}); - -it.each([false, true] as const)( - "keeps Codex keyboard retry usable and hands off success only while Install owns focus (moved: %s)", - async (moveFocus) => { - const user = userEvent.setup(); - const report: HarnessInstallReport = { - ready: false, - restarted: 0, - restartFailures: 0, - logPath: "/fixture/codex-install.log", - output: "npm ERR! network", - error: "Installation failed", - }; - let finish!: (report: HarnessInstallReport) => void; - const pending = new Promise((resolve) => { - finish = resolve; - }); - const install = vi - .fn>() - .mockReturnValue(pending); - const fixture = codexHarnesses("adapter-needed"); - fixture.host.installCodex = install; - fixture.host.checkCodexAuth = async () => !moveFocus; - const control = createAgentControl(fixture.host); - disposals.push(() => control.dispose()); - render(, { wrapper: ToastProvider }); - - await screen.findByText("Codex"); - const button = await codexRow().findByRole("button", { name: "Install" }); - for (let i = 0; i < 20 && document.activeElement !== button; i++) { - await user.tab(); - } - expect(button).toHaveFocus(); - await user.keyboard("{Enter}"); - try { - expect( - await screen.findByText( - "Installing Node.js and the Codex ACP adapter…", - ), - ).toBeVisible(); - expect(button).toHaveFocus(); - } finally { - await act(async () => { - finish(report); - await pending; - }); - } - expect(await codexRow().findByRole("alert")).toHaveTextContent( - "Installation failed", - ); - expect(button).toHaveFocus(); - const retry = new Promise((resolve) => { - finish = resolve; - }); - install.mockReturnValue(retry); - await user.keyboard("{Enter}"); - await waitFor(() => expect(install).toHaveBeenCalledTimes(2)); - let next: Element | null = null; - try { - expect(button).toHaveFocus(); - if (moveFocus) { - next = screen.getByRole("button", { name: "Add harness" }); - for (let i = 0; i < 20 && document.activeElement !== next; i++) { - await user.tab(); - } - expect(next).toHaveFocus(); - } - fixture.data.harnessOptions = codexHarnessOptions("ready"); - } finally { - await act(async () => { - finish({ ...report, ready: true, error: null }); - await retry; - }); - } - const status = await codexRow().findByText( - moveFocus ? "Sign-in needed" : "Ready", - ); - expect(codexRow().queryByRole("button", { name: "Install" })).toBeNull(); - if (moveFocus) expect(next).toHaveFocus(); - else expect(status).toHaveFocus(); - }, -); - -it("offers manual copying when clipboard access fails", async () => { - const user = userEvent.setup(); - setupHarnesses("cli-needed"); - await user.click(await screen.findByText("Manual setup")); - expect( - await screen.findByRole("button", { name: "Copy Pi command" }), - ).toBeVisible(); - vi.spyOn(navigator.clipboard, "writeText").mockRejectedValue( - new Error("denied"), - ); - await user.click(screen.getByRole("button", { name: "Copy Pi command" })); - expect(await screen.findByRole("status")).toHaveTextContent( - "Select it to copy manually.", - ); - expect( - screen.getByText( - "npm install -g '@earendil-works/pi-coding-agent@>=0.99.0'", - ), - ).toBeVisible(); -}); - -it("Check again re-reads the native snapshot without restarting the app", async () => { - const user = userEvent.setup(); - const { fixture } = setupHarnesses("adapter-needed"); - expect(await screen.findByText("Adapter needed")).toBeVisible(); - await user.hover(screen.getByRole("button", { name: "Check again" })); - expect(await screen.findByRole("tooltip")).toHaveTextContent("Check again"); - const before = fixture.calls.filter( - (call) => call.action === "snapshot", - ).length; - const pi = fixture.data.harnessOptions?.[2]; - const goose = fixture.data.harnessOptions?.[1]; - if (!pi || !goose) throw new Error("Missing Harness fixture"); - pi.status = "ready"; - pi.available = true; - goose.status = "ready"; - goose.available = true; - await user.click(screen.getByRole("button", { name: "Check again" })); - await waitFor(() => - expect( - within(screen.getByRole("list", { name: "Harnesses" })).getAllByRole( - "listitem", - )[2], - ).toHaveTextContent("PiReady"), - ); - expect(screen.queryByRole("button", { name: "Copy Pi command" })).toBeNull(); - expect( - within(screen.getByRole("list", { name: "Harnesses" })).getAllByRole( - "listitem", - )[1], - ).toHaveTextContent("GooseReady"); - expect( - fixture.calls.filter((call) => call.action === "snapshot"), - ).toHaveLength(before + 1); -}); - -it("keeps the last statuses and offers Check again after a failed read", async () => { - const user = userEvent.setup(); - const installPi = vi.fn(); - const { fixture } = setupHarnesses("cli-needed", { - installSupported: true, - installPi, - }); - const list = await screen.findByRole("list", { name: "Harnesses" }); - const piRow = within(list).getByText("Pi", { exact: true }).closest("li"); - if (!piRow) throw new Error("Missing Pi row"); - expect(within(piRow).getByText("CLI needed")).toBeVisible(); - const original = fixture.host.snapshot.bind(fixture.host); - fixture.host.snapshot = vi - .fn() - .mockRejectedValueOnce("unavailable") - .mockImplementation(original); - await user.click(screen.getByRole("button", { name: "Check again" })); - expect(await screen.findByRole("alert")).toHaveTextContent("last check"); - expect(screen.getByRole("button", { name: "Install" })).toBeDisabled(); - expect(installPi).not.toHaveBeenCalled(); - expect( - within(screen.getByRole("list", { name: "Harnesses" })).getAllByRole( - "listitem", - )[0], - ).toHaveTextContent("Buzz AgentReady"); - await user.click(screen.getByRole("button", { name: "Check again" })); - await waitFor(() => expect(screen.queryByRole("alert")).toBeNull()); - expect(screen.getByRole("button", { name: "Install" })).toBeEnabled(); -}); - -it.each([ - ["cli-needed", true, true], - ["ready", true, false], - ["cli-needed", false, false], -] as const)( - "shows Pi Install only when needed and supported (%s, %s)", - async (status, supported, visible) => { - setupHarnesses(status, { installSupported: supported, installPi: vi.fn() }); - const pi = within( - await screen.findByRole("list", { name: "Harnesses" }), - ).getAllByRole("listitem")[2]; - if (!pi) throw new Error("Missing Pi row"); - expect(within(pi).queryByRole("button", { name: "Install" }) !== null).toBe( - visible, - ); - if (status !== "ready") { - expect(screen.getByLabelText("Copy Pi command")).not.toBeVisible(); - if (supported) - expect( - within(pi).getByText( - /Buzz installs Node.js, Pi, and its ACP adapter for you/, - ), - ).toBeVisible(); - else - expect( - within(pi).getByText(/Use Manual setup on this device/), - ).toBeVisible(); - } - }, -); - -it("offers no Pi update or command for a current or user-global Pi install", async () => { - setupHarnesses("ready", { - installSupported: true, - updateSupported: false, - installPi: vi.fn(), - }); - const pi = within( - await screen.findByRole("list", { name: "Harnesses" }), - ).getAllByRole("listitem")[2]; - if (!pi) throw new Error("Missing Pi row"); - expect(within(pi).getByText("Ready")).toBeVisible(); - expect(screen.queryByRole("button", { name: "Update Pi" })).toBeNull(); - expect( - screen.queryByRole("button", { name: "Copy Adapter command" }), - ).toBeNull(); - expect(screen.queryByText(/buzz-pi-acp.git#/)).toBeNull(); -}); - -it("updates an outdated app-owned Pi and tells the user to restart running agents", async () => { - const user = userEvent.setup(); - const installPi = vi.fn().mockResolvedValue({ - ready: true, - restarted: 0, - restartFailures: 0, - logPath: "/fixture/pi-install.log", - output: "done", - error: null, - }); - setupHarnesses("ready", { - installSupported: true, - updateSupported: true, - installPi, - }); - const pi = within( - await screen.findByRole("list", { name: "Harnesses" }), - ).getAllByRole("listitem")[2]; - if (!pi) throw new Error("Missing Pi row"); - await user.click(within(pi).getByRole("button", { name: "Update Pi" })); - expect(installPi).toHaveBeenCalledTimes(1); - expect( - await screen.findByText(/Restart running Pi agents to use them/), - ).toBeVisible(); -}); - -it("keeps Pi install progress and report across Settings remounts without taking the agent-write lane", async () => { - const user = userEvent.setup(); - let complete!: (report: HarnessInstallReport) => void; - const installing = new Promise((resolve) => { - complete = resolve; - }); - const installPi = vi.fn(() => installing); - const { control, fixture } = setupHarnesses("adapter-needed", { - installSupported: true, - installPi, - }); - const pi = within( - await screen.findByRole("list", { name: "Harnesses" }), - ).getAllByRole("listitem")[2]; - if (!pi) throw new Error("Missing Pi row"); - await user.click(within(pi).getByRole("button", { name: "Install" })); - expect( - await screen.findByText("Installing Pi and its ACP adapter…"), - ).toBeVisible(); - expect(control.snapshot().busy).toBe(false); - expect(installPi).toHaveBeenCalledTimes(1); - cleanup(); - render(, { wrapper: ToastProvider }); - expect( - await screen.findByText("Installing Pi and its ACP adapter…"), - ).toBeVisible(); - const option = fixture.data.harnessOptions?.[2]; - if (!option) throw new Error("Missing Pi fixture"); - option.status = "ready"; - option.available = true; - complete({ - ready: true, - restarted: 1, - restartFailures: 0, - logPath: "/fixture/pi-install.log", - output: "done", - error: null, - }); - expect( - await screen.findByText( - /Pi and its adapter are up to date.*Restarted 1 waiting agents\./, - ), - ).toBeVisible(); - cleanup(); - render(, { wrapper: ToastProvider }); - expect( - await screen.findByText( - /Pi and its adapter are up to date.*Restarted 1 waiting agents\./, - ), - ).toBeVisible(); - expect(screen.queryByRole("button", { name: "Copy Pi command" })).toBeNull(); -}); - -it("keeps bundled Goose Ready during a Pi installation", async () => { - const user = userEvent.setup(); - let complete!: (report: HarnessInstallReport) => void; - const pending = new Promise((resolve) => { - complete = resolve; - }); - setupHarnesses("cli-needed", { - installSupported: true, - installPi: () => pending, - }); - const rows = within( - await screen.findByRole("list", { name: "Harnesses" }), - ).getAllByRole("listitem"); - if (!rows[1] || !rows[2]) throw new Error("Missing Harness rows"); - await user.click(within(rows[2]).getByRole("button", { name: "Install" })); - expect(within(rows[1]).queryByRole("button", { name: "Install" })).toBeNull(); - expect(rows[1]).toHaveTextContent("GooseReady"); - complete({ - ready: false, - restarted: 0, - restartFailures: 0, - logPath: "/fixture/pi-install.log", - output: "Full diagnostic output", - error: - "Installing Pi failed (exit code 1).\nnpm error code E404\nnpm error 404 No match found for version >=0.99.0", - }); - const alert = await screen.findByRole("alert"); - expect(rows[2]).toContainElement(alert); - expect(within(alert).getByText(/Installing Pi failed/)).toBeVisible(); - expect(alert).toHaveTextContent("E404"); - expect(alert).toHaveTextContent("No match found for version >=0.99.0"); - const log = within(alert).getByText("Pi install log"); - expect(log.closest("details")).not.toHaveAttribute("open"); - expect(within(alert).getByText("Full diagnostic output")).not.toBeVisible(); - await user.click(log); - expect(within(alert).getByText("Full diagnostic output")).toBeVisible(); -}); - -it("installs Claude Code with durable progress, failed-step logs, retry and native re-detection", async () => { - const user = userEvent.setup(); - let finish!: (report: HarnessInstallReport) => void; - const pending = new Promise((resolve) => { - finish = resolve; - }); - const failed: HarnessInstallReport = { - ready: false, - restarted: 0, - restartFailures: 0, - logPath: "/fixture/claude-install.log", - output: "npm error EACCES", - error: "Installing Claude Code failed", - }; - const install = vi - .fn() - .mockReturnValueOnce(pending) - .mockResolvedValueOnce({ ...failed, ready: true, error: null }); - const { control, fixture } = setupHarnesses("cli-needed", { - installSupported: true, - installPi: vi.fn(), - claude: { - status: "adapter-needed", - installSupported: true, - loginCommand: "'/fixture/claude' auth login", - }, - installClaude: install, - }); - const row = () => - within(screen.getByRole("listitem", { name: "Claude Code harness" })); - await screen.findByRole("listitem", { name: "Claude Code harness" }); - await user.click(row().getByRole("button", { name: "Install" })); - try { - expect(await row().findByRole("status")).toHaveTextContent( - "Installing Claude Code", - ); - expect(screen.getByRole("button", { name: "Check again" })).toBeDisabled(); - const piRow = screen.getByText("Pi", { exact: true }).closest("li"); - if (!piRow) throw new Error("Missing Pi row"); - expect( - within(piRow).getByRole("button", { name: "Install" }), - ).toBeDisabled(); - cleanup(); - render(, { wrapper: ToastProvider }); - expect(await row().findByRole("status")).toHaveTextContent( - "Installing Claude Code", - ); - } finally { - finish(failed); - } - expect(await row().findByRole("alert")).toHaveTextContent( - "Installing Claude Code failed", - ); - await user.click(row().getByText("Claude Code install log")); - expect(row().getByText("npm error EACCES")).toBeVisible(); - expect(row().getByText("/fixture/claude-install.log")).toBeVisible(); - if (!fixture.data.claudeSetup) throw new Error("Missing Claude fixture"); - fixture.data.claudeSetup.status = "ready"; - await user.click(row().getByRole("button", { name: "Install" })); - expect(await row().findByText("Sign-in unconfirmed")).toBeVisible(); - expect(row().queryByRole("alert")).toBeNull(); - expect(row().queryByRole("button", { name: "Install" })).toBeNull(); - await user.click(row().getByText("Sign in to Claude Code")); - expect(row().getByText("'/fixture/claude' auth login")).toBeVisible(); - expect(row().getByText(/Couldn’t confirm Claude Code sign-in/)).toBeVisible(); - expect(install).toHaveBeenCalledTimes(2); -}); - -it.each([false, true] as const)( - "keeps keyboard retry usable and hands off success only while Install owns focus (moved: %s)", - async (moveFocus) => { - const user = userEvent.setup(); - let finish!: (report: HarnessInstallReport) => void; - const pending = new Promise((resolve) => { - finish = resolve; - }); - const report: HarnessInstallReport = { - ready: false, - restarted: 0, - restartFailures: 0, - logPath: "/fixture/claude-install.log", - output: "npm error EACCES", - error: "Installation failed", - }; - const install = vi.fn(() => pending); - const { fixture } = setupHarnesses("ready", { - claude: { - status: "cli-needed", - installSupported: true, - loginCommand: "'/fixture/claude' auth login", - }, - installClaude: install, - checkClaudeAuth: async () => !moveFocus, - }); - const row = within( - await screen.findByRole("listitem", { name: "Claude Code harness" }), - ); - const button = row.getByRole("button", { name: "Install" }); - for (let i = 0; i < 10 && document.activeElement !== button; i++) { - await user.tab(); - } - expect(button).toHaveFocus(); - await user.keyboard("{Enter}"); - try { - expect(await row.findByText(/Installing Claude Code and/)).toBeVisible(); - expect(button).toHaveFocus(); - await user.keyboard("{Enter}"); - expect(install).toHaveBeenCalledTimes(1); - } finally { - await act(async () => { - finish(report); - await pending; - }); - } - expect(await row.findByRole("alert")).toHaveTextContent( - "Installation failed", - ); - expect(button).toHaveFocus(); - expect(button).not.toBeDisabled(); - const retry = new Promise((resolve) => { - finish = resolve; - }); - install.mockReturnValue(retry); - await user.keyboard("{Enter}"); - await waitFor(() => expect(install).toHaveBeenCalledTimes(2)); - let next: Element | null = null; - try { - expect(button).toHaveFocus(); - if (moveFocus) { - next = screen.getByRole("button", { name: "Add harness" }); - for (let i = 0; i < 10 && document.activeElement !== next; i++) { - await user.tab(); - } - expect(next).toHaveFocus(); - } - if (!fixture.data.claudeSetup) throw new Error("Missing Claude fixture"); - fixture.data.claudeSetup.status = "ready"; - } finally { - await act(async () => { - finish({ ...report, ready: true, error: null }); - await retry; - }); - } - const status = await row.findByText(moveFocus ? "Sign-in needed" : "Ready"); - expect(row.queryByRole("button", { name: "Install" })).toBeNull(); - if (moveFocus) expect(next).toHaveFocus(); - else expect(status).toHaveFocus(); - }, -); - -it("offers manual Claude setup and disables creation when its tools are missing", async () => { - const user = userEvent.setup(); - const { control, fixture } = setupHarnesses("ready", { - claude: { - status: "cli-needed", - installSupported: false, - loginCommand: null, - }, - installClaude: vi.fn(), - }); - const row = within( - await screen.findByRole("listitem", { name: "Claude Code harness" }), - ); - expect(row.queryByRole("button", { name: "Install" })).toBeNull(); - await user.click(row.getByText("Manual Claude Code setup")); - expect( - row.getByText(/npm install -g @anthropic-ai\/claude-code/), - ).toBeVisible(); - expect( - row.getByText(/npm install -g @agentclientprotocol\/claude-agent-acp/), - ).toBeVisible(); - // Exercise creation's real picker with the same native snapshot as Settings. - fixture.data.harnessOptions?.push({ - command: "claude-agent-acp", - label: "Claude Code", - available: false, - status: "cli-needed", - providers: [], - defaultArgs: [], - }); - await control.refresh(); - cleanup(); - render( - {}} - />, - ); - await user.click(screen.getByRole("combobox", { name: "Harness" })); - expect( - await screen.findByRole("option", { name: "Buzz Agent" }), - ).toBeVisible(); - expect( - screen.getByRole("option", { name: "Claude Code (install first)" }), - ).toHaveAttribute("aria-disabled", "true"); - expect(control.snapshot().data?.claudeSetup?.status).toBe("cli-needed"); -}); - -it.each([true, false, null] as const)( - "shows Claude setup guidance only when sign-in needs attention (%s)", - async (signedIn) => { - const user = userEvent.setup(); - const checkClaudeAuth = vi.fn().mockResolvedValue(signedIn); - setupHarnesses("ready", { - claude: { - status: "ready", - installSupported: true, - loginCommand: "'/fixture/claude' auth login", - }, - checkClaudeAuth, - }); - await screen.findByRole("listitem", { name: "Claude Code harness" }); - const row = () => - within(screen.getByRole("listitem", { name: "Claude Code harness" })); - if (signedIn === true) { - await waitFor(() => expect(checkClaudeAuth).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(row().getByText("Ready")).toBeVisible()); - expect(row().queryByText("Sign in to Claude Code")).toBeNull(); - expect( - row().queryByText(/Claude Code and its ACP adapter are installed/), - ).toBeNull(); - } else { - expect( - await row().findByText( - signedIn === false ? "Sign-in needed" : "Sign-in unconfirmed", - ), - ).toBeVisible(); - expect(row().queryByText("Ready")).toBeNull(); - await user.click(row().getByText("Sign in to Claude Code")); - expect(row().getByText("'/fixture/claude' auth login")).toBeVisible(); - } - checkClaudeAuth.mockResolvedValue(true); - await user.click(screen.getByRole("button", { name: "Check again" })); - await waitFor(() => expect(checkClaudeAuth).toHaveBeenCalledTimes(2)); - await waitFor(() => expect(row().getByText("Ready")).toBeVisible()); - expect(row().queryByText("Sign in to Claude Code")).toBeNull(); - }, -); - -it("ignores a late Claude auth check after Check again and retains an install failure on a ready row", async () => { - const user = userEvent.setup(); - let finish!: (signedIn: boolean) => void; - const pending = new Promise((resolve) => { - finish = resolve; - }); - const checkClaudeAuth = vi - .fn() - .mockReturnValueOnce(pending) - .mockResolvedValue(false); - const { control } = setupHarnesses("ready", { - claude: { - status: "ready", - installSupported: true, - loginCommand: "'/fixture/claude' auth login", - }, - checkClaudeAuth, - installClaude: vi.fn().mockResolvedValue({ - ready: false, - restarted: 0, - restartFailures: 0, - logPath: "/fixture/claude-install.log", - output: "npm error EACCES", - error: "Update failed; previous tools retained", - }), - }); - await screen.findByRole("listitem", { name: "Claude Code harness" }); - const row = () => - within(screen.getByRole("listitem", { name: "Claude Code harness" })); - try { - await waitFor(() => expect(checkClaudeAuth).toHaveBeenCalledTimes(1)); - expect(row().getByText("Checking sign-in…")).toBeVisible(); - expect(row().queryByText("Ready")).toBeNull(); - await user.click(screen.getByRole("button", { name: "Check again" })); - expect(await row().findByText("Sign-in needed")).toBeVisible(); - } finally { - await act(async () => { - finish(true); - await pending; - }); - } - expect(row().getByText("Sign-in needed")).toBeVisible(); - checkClaudeAuth.mockResolvedValue(true); - await act(async () => { - await control.installClaude?.(); - }); - expect(await row().findByText("Ready")).toBeVisible(); - expect(row().getByRole("alert")).toHaveTextContent( - "Update failed; previous tools retained", - ); - expect(row().queryByText("Sign in to Claude Code")).toBeNull(); - await user.click(row().getByText("Claude Code install log")); - expect(row().getByText("npm error EACCES")).toBeVisible(); -}); diff --git a/src/app/AgentSettings.tsx b/src/app/AgentSettings.tsx index 0b3a1ab9d..6422af27c 100644 --- a/src/app/AgentSettings.tsx +++ b/src/app/AgentSettings.tsx @@ -1,695 +1,28 @@ -import { SettingsGroup } from "../shared/design-system/ui/SettingsGroup"; -import { - useCallback, - useEffect, - useRef, - useState, - useSyncExternalStore, -} from "react"; -import { - harnessPresets, - harnessKind, - harnessPreset, -} from "../features/agents/harness-presets"; -import { PresetSetupHint } from "../features/agents/PresetSetupHint"; -import type { AgentControl } from "../features/agents/control"; +import { useState, type ReactNode } from "react"; import { setRememberAgentsPreference, useRememberAgentsPreference, } from "../features/messages/mention-preferences"; -import { - ArrowsClockwiseIcon, - ArrowSquareOutIcon, - CodexLogoIcon, - CopyIcon, - GooseLogoIcon, - HermesLogoIcon, - PiLogoIcon, - PlusIcon, - QuestionIcon, - RobotIcon, - TerminalWindowIcon, -} from "../shared/design-system/icons"; import { Header, InlineHeader } from "../shared/design-system/ui/Header"; import { Button } from "../shared/design-system/ui/Button"; -import { Dialog } from "../shared/design-system/ui/Dialog"; -import { NavigationItem } from "../shared/design-system/ui/NavigationItem"; -import { IconButton } from "../shared/design-system/ui/IconButton"; -import { PreferenceRow } from "../shared/design-system/ui/PreferenceRow"; +import { SettingsGroup } from "../shared/design-system/ui/SettingsGroup"; import { SwitchPreferenceRow } from "../shared/design-system/ui/SwitchPreferenceRow"; - import { ToastNotice } from "../shared/design-system/ui/Toast"; -import { Tooltip } from "../shared/design-system/ui/Tooltip"; -import type { ReactNode } from "react"; -import { AgentDefaultsCard } from "./AgentDefaultsCard"; -import { ClaudeHarnessSetup } from "./ClaudeHarnessSetup"; -import styles from "./AgentSettings.module.css"; - -const acpHint = - "Buzz talks to harnesses through the Agent Client Protocol (ACP). Goose ships with Buzz. Pi needs the buzz-pi-acp adapter. Hermes Agent uses its own ACP launcher and sign-in."; -const piCommand = "npm install -g '@earendil-works/pi-coding-agent@>=0.99.0'"; -const adapterCommand = - "npm install -g --install-links=true 'git+https://github.com/salman1993/buzz-pi-acp.git#72015de'"; -const labels = { - ready: "Ready", - "cli-needed": "CLI needed", - "adapter-needed": "Adapter needed", - "check-needed": "Check needed", - "not-enabled": "Not enabled", -} as const; -// Artwork only; native harnessOptions still own availability and configuration. -const harnessIcons: Record = { - "buzz-agent": , - codex: , - goose: , - hermes: , - pi: , -}; -const codexAdapterCommand = - "npm install -g @agentclientprotocol/codex-acp@2.1.1"; -const commands = [ - ["Pi", "Install Pi", piCommand], - ["Adapter", "Install the ACP adapter", adapterCommand], -] as const; export function AgentSettings({ - control, - active = true, archive, + active = true, }: { - control: AgentControl; - active?: boolean; archive?: ReactNode; + active?: boolean; }) { const preference = useRememberAgentsPreference(); const [error, setError] = useState(null); - const [checking, setChecking] = useState(false); - const [authCheck, setAuthCheck] = useState(0); - const [catalogOpen, setCatalogOpen] = useState(false); - const [selectedPresetId, setSelectedPresetId] = useState(); - const addHarnessRef = useRef(null); - const [copyMessage, setCopyMessage] = useState(""); - const copyAttempt = useRef(0); - const state = useSyncExternalStore(control.subscribe, control.snapshot); - const { - installing: installingPi, - report: piResult, - error: piError, - } = state.piInstall ?? { installing: false, report: null, error: null }; - useEffect(() => { - if (active) void control.refresh(); - }, [active, control]); - const options = state.data?.harnessOptions; - const coreHarnesses = (["buzz-agent", "goose", "pi"] as const).map((id) => - options?.find((option) => harnessKind(option.command) === id), - ); - const presets = - options?.filter( - (option) => - !!harnessPreset(option.command) && - harnessKind(option.command) !== "claude", - ) ?? []; - const setup = - harnessPresets.find((preset) => preset.id === selectedPresetId) ?? - harnessPresets[0]; - // Native owns availability; registry metadata also works on older snapshots. - const selected = options?.find( - (option) => harnessPreset(option.command) === setup, - ); - const presetLabel = selected?.label ?? setup?.label ?? "Harness"; - const codexOption = options?.find((option) => option.id === "codex"); - const harnesses = [ - ...coreHarnesses, - ...presets.filter((option) => option.available), - ...(codexOption ? [codexOption] : []), - ]; - const available = coreHarnesses.every((option) => !!option?.status); - const pi = coreHarnesses[2]; - const codexInstall = state.codexInstall; - const codexToolsReady = codexOption?.status === "ready"; - const [codexAuth, setCodexAuth] = useState( - "checking", - ); - // Like Claude Code: read the existing CLI login only once its tools are found. - // biome-ignore lint/correctness/useExhaustiveDependencies: Check again and install completion must trigger this read even when tool presence is unchanged. - useEffect(() => { - if (!active || !codexToolsReady || codexInstall?.installing) return; - let current = true; - setCodexAuth("checking"); - void (control.checkCodexAuth?.() ?? Promise.resolve(null)) - .catch(() => null) - .then((result) => { - if (current) setCodexAuth(result); - }); - return () => { - current = false; - }; - }, [ - active, - codexToolsReady, - codexInstall?.installing, - codexInstall?.report, - authCheck, - control, - ]); - // Like Claude Code: keep keyboard focus when a successful install removes Install. - const codexStatusRef = useRef(null); - const codexInstallRef = useCallback((button: HTMLButtonElement | null) => { - if (!button) return; - return () => { - if (button.ownerDocument.activeElement === button) { - codexStatusRef.current?.focus(); - } - }; - }, []); - const offerCodexInstall = - !!control.installCodex && - !!codexOption?.installSupported && - codexOption.status === "adapter-needed"; - const codexStatus = !codexToolsReady - ? codexOption?.status - ? labels[codexOption.status] - : "Unknown" - : codexAuth === "checking" - ? "Checking sign-in…" - : codexAuth === true - ? "Ready" - : codexAuth === false - ? "Sign-in needed" - : "Sign-in unconfirmed"; - const checkDisabled = - state.status === "unavailable" || - state.busy || - installingPi || - state.claudeInstall?.installing || - codexInstall?.installing; - const checkAgain = () => { - setChecking(true); - void control.refresh().finally(() => { - setChecking(false); - setAuthCheck((value) => value + 1); - }); - }; const change = (enabled: boolean) => setError(setRememberAgentsPreference(enabled)); - const copy = async (name: string, command: string) => { - const attempt = ++copyAttempt.current; - try { - await navigator.clipboard.writeText(command); - if (attempt === copyAttempt.current) - setCopyMessage(`${name} command copied.`); - } catch { - if (attempt === copyAttempt.current) - setCopyMessage( - `Couldn’t copy the ${name} command. Select it to copy manually.`, - ); - } - }; return (
-
- - - - - - - } - /> - - {state.status === "unavailable" ? ( -

- Harness detection requires the desktop app. -

- ) : state.status === "loading" || state.status === "idle" ? ( -

- Checking harnesses… -

- ) : !available ? ( -

- {state.status === "error" - ? "Couldn’t check harnesses. Select Check again to retry." - : "Update the desktop app to check harnesses."} -

- ) : ( - <> - {state.status === "error" && ( -

- Couldn’t confirm harnesses. Showing the last check; try Check - again. -

- )} -
    - {harnesses.map((option) => ( -
  • -
    - - {option && - (harnessIcons[ - harnessKind(option.command) ?? option.id ?? "" - ] ?? ( - - ))} - {option?.label} - - - - {option?.id === "codex" - ? codexStatus - : option?.status - ? labels[option.status] - : "Unknown"} - - {option && - harnessKind(option.command) === "pi" && - (option.status !== "ready" || - option.updateSupported) && - option.installSupported && - control.installPi && - !piResult?.ready && ( - - )} - {option?.id === "codex" && offerCodexInstall && ( - - )} - -
    - {option && - option.status !== "ready" && - harnessPreset(option.command) && ( -
    -

    - Uses the default model and credentials configured in{" "} - {option.label}. Install and update the harness - yourself, then use Check again. -

    -
    - Manual {option.label} setup -
    - -
    -
    -
    - )} - {option?.id === "codex" && - (codexInstall?.installing || - codexInstall?.report?.error || - codexInstall?.error) && ( -
    - {codexInstall.installing ? ( -

    - Installing Node.js and the Codex ACP adapter… -

    - ) : ( -
    -

    - {codexInstall.report?.error || - codexInstall.error} -

    - {codexInstall.report && ( -
    - - Codex ACP adapter install log - -

    - {codexInstall.report.logPath} -

    -
    -                                    {codexInstall.report.output ||
    -                                      "No output was recorded."}
    -                                  
    -
    - )} -
    - )} -
    - )} - {option?.id === "codex" && - offerCodexInstall && - !codexInstall?.installing && ( -
    -

    - Click Install. Buzz installs Node.js and the Codex - ACP adapter for you. Your Codex CLI and sign-in are - left untouched. -

    -
    - Manual Codex ACP adapter setup - - {codexAdapterCommand} - -
    -
    - )} - {option?.id === "codex" && - (option.status === "cli-needed" || - (codexToolsReady && - (codexAuth === false || codexAuth === null))) && ( -

    - {option.status === "cli-needed" - ? "Install the Codex CLI, then use Check again." - : "Run codex login in your terminal, then use Check again."} -

    - )} - {option && - harnessKind(option.command) === "pi" && - (installingPi || - piResult?.ready || - piResult?.error || - piError || - pi?.status !== "ready") && ( -
    - {installingPi && ( -

    - Installing Pi and its ACP adapter… -

    - )} - {!installingPi && - piResult?.ready && - pi?.status === "ready" && ( -

    - Pi and its adapter are up to date. Restart - running Pi agents to use them. Restarted{" "} - {piResult.restarted} waiting agents. - {piResult.restartFailures > 0 && - ` ${piResult.restartFailures} agents could not restart; check Agents.`} -

    - )} - {!installingPi && (piResult?.error || piError) && ( -
    -

    - {piResult?.error || piError} -

    - {piResult && ( -
    - Pi install log -

    - {piResult.logPath} -

    -
    -                                    {piResult.output ||
    -                                      "No output was recorded."}
    -                                  
    -
    - )} -
    - )} - {pi?.status !== "ready" && ( -
    -

    - {pi?.installSupported && control.installPi - ? "Click Install. Buzz installs Node.js, Pi, and its ACP adapter for you." - : "Use Manual setup on this device, then click Check again."}{" "} - Your existing CLI setup and sign-in are left - untouched. -

    -
    - Manual setup -
    -
      -
    1. - -
    2. - {commands.map(([name, title, command]) => ( -
    3. - - {command} - - } - trailing={ - - - } - /> -
    4. - ))} -
    5. - -
    6. -
    - {copyMessage && ( -

    - {copyMessage} -

    - )} -
    -
    -
    - )} -
    - )} -
  • - ))} - -
- - - )} -
-
- - - {archive}
@@ -712,31 +45,3 @@ export function AgentSettings({
); } - -function PresetSetup({ - label, - setup, -}: { - label: string; - setup: { setupUrl: string; setupHint: string } | undefined; -}) { - if (!setup) return null; - return ( -
-

- Follow the{" "} - - {label} setup guide - {" "} - for installation instructions.{" "} - -

-

Use Check again to refresh the installation status.

-
- ); -} diff --git a/src/app/App.profile.test.tsx b/src/app/App.profile.test.tsx index 5262fce99..71dfbfd01 100644 --- a/src/app/App.profile.test.tsx +++ b/src/app/App.profile.test.tsx @@ -13,7 +13,6 @@ import { afterEach, expect, it, vi } from "vitest"; import { finalizeEvent, getPublicKey } from "nostr-tools"; import { StrictMode } from "react"; import { App } from "./App"; -import { requestSnapshotPreview } from "../features/agents/snapshot-preview"; import { createServices, type AppServices } from "./services"; import { matchesEvent } from "../features/relay/projection"; import type { ReadFilter } from "../features/relay/events"; @@ -284,33 +283,3 @@ it("opens the viewer's profile from New message without discarding recipients or await user.keyboard(" and keep editing"); expect(editor).toHaveTextContent("Keep this unsent draft and keep editing"); }); - -it("mounts the received snapshot preview host without automatically importing", async () => { - const user = await setup(); - await screen.findByRole("button", { name: "Your profile" }); - const current = services; - if (!current) throw new Error("Missing services"); - const connection = current.relay.snapshot(); - if (connection.status !== "ready") throw new Error("Missing ready session"); - const create = vi.fn(); - Object.assign(current.agentControl, { create }); - act(() => - requestSnapshotPreview(connection.session, { - url: `${origin}/media/${"a".repeat(64)}.png`, - name: "helper.agent.png", - kind: "image", - }), - ); - const dialog = await screen.findByRole("dialog", { - name: "Preview snapshot", - }); - expect(dialog).toHaveTextContent("Preview snapshot"); - expect(create).not.toHaveBeenCalled(); - await user.click(within(dialog).getByRole("button", { name: "Cancel" })); - await waitFor(() => - expect( - screen.queryByRole("dialog", { name: "Preview snapshot" }), - ).not.toBeInTheDocument(), - ); - expect(create).not.toHaveBeenCalled(); -}); diff --git a/src/app/App.tsx b/src/app/App.tsx index 63cab60d1..41672f0e6 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -6,9 +6,6 @@ import { ChannelNavigationProvider } from "../features/channel-navigation/Channe import { Agents2Context } from "../features/agents2/react"; import { ToastProvider } from "../shared/design-system/ui/Toast"; import { Button } from "../shared/design-system/ui/Button"; -import { AgentWakeNotice } from "../features/agents/AgentWakeNotice"; -import { AgentUpdateReview } from "../bundled/agents/AgentUpdateReview"; -import { SnapshotPreview } from "../bundled/agents/SnapshotPreview"; import { UpdateNotice } from "../features/updates/UpdateNotice"; import { useEffect, useSyncExternalStore } from "react"; import { registerAppShortcuts } from "./shortcuts"; @@ -242,15 +239,6 @@ function ConnectedApp({ services }: { services: AppServices }) { startup === "ready" && route.page?.layout === "workspace" } > - - - {startup === "recovery" && !settings ? ( @@ -286,7 +274,6 @@ function ConnectedApp({ services }: { services: AppServices }) { shortcuts={services.shortcuts} shortcutBindings={services.shortcutBindings} notifications={services.notifications} - agentControl={services.agentControl} updates={services.updates} navigation={route.request} navigationPane diff --git a/src/app/ClaudeHarnessSetup.tsx b/src/app/ClaudeHarnessSetup.tsx deleted file mode 100644 index c6800e805..000000000 --- a/src/app/ClaudeHarnessSetup.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import type { - AgentControl, - AgentControlState, -} from "../features/agents/control"; -import { ClaudeLogoIcon } from "../shared/design-system/icons"; -import { Button } from "../shared/design-system/ui/Button"; -import styles from "./AgentSettings.module.css"; - -const cliCommand = "npm install -g @anthropic-ai/claude-code@2.1.289"; -const adapterCommand = - "npm install -g @agentclientprotocol/claude-agent-acp@0.85.1"; - -export function ClaudeHarnessSetup({ - control, - state, - active = true, -}: { - control: AgentControl; - state: AgentControlState; - active?: boolean; -}) { - const setup = state.data?.claudeSetup; - - const { installing, report, error } = state.claudeInstall ?? { - installing: false, - report: null, - error: null, - }; - const toolsReady = setup?.status === "ready"; - const [auth, setAuth] = useState("checking"); - const statusRef = useRef(null); - const installRef = useCallback((button: HTMLButtonElement | null) => { - if (!button) return; - return () => { - if (button.ownerDocument.activeElement === button) { - statusRef.current?.focus(); - } - }; - }, []); - // A new install result also rechecks auth if React batches a fast install. - // biome-ignore lint/correctness/useExhaustiveDependencies: install completion must trigger this read even when tool presence is unchanged. - useEffect(() => { - if (!active || !toolsReady || installing) return; - let current = true; - setAuth("checking"); - void (control.checkClaudeAuth?.() ?? Promise.resolve(null)) - .catch(() => null) - .then((result) => { - if (current) setAuth(result); - }); - return () => { - current = false; - }; - }, [active, toolsReady, installing, report, control]); - if (!setup) return null; - const ready = toolsReady && auth === true; - const needsGuidance = !toolsReady || auth === false || auth === null; - const showDetails = installing || report?.error || error || needsGuidance; - return ( -
  • -
    - - - Claude Code - - - - {ready - ? "Ready" - : toolsReady - ? auth === "checking" - ? "Checking sign-in…" - : auth === false - ? "Sign-in needed" - : "Sign-in unconfirmed" - : setup.status === "cli-needed" - ? "CLI needed" - : "Adapter needed"} - - {!toolsReady && setup.installSupported && control.installClaude && ( - - )} - -
    - {showDetails && ( -
    - {installing && ( -

    Installing Claude Code and its ACP adapter…

    - )} - {!installing && (report?.error || error) && ( -
    -

    - {report?.error || error} -

    - {report && ( -
    - Claude Code install log -

    {report.logPath}

    -
    -                    {report.output || "No output was recorded."}
    -                  
    -
    - )} -
    - )} - {!toolsReady && ( - <> -

    - {setup.installSupported && control.installClaude - ? "Click Install. Buzz installs Node.js, Claude Code, and its ACP adapter for you." - : "Use Manual setup on this device, then click Check again."} -

    -
    - Manual Claude Code setup -
    -

    - Install Node.js 22 or newer, then run these in your - terminal: -

    - {[cliCommand, adapterCommand].map((command) => ( - - {command} - - ))} -

    Use Check again to refresh the installation status.

    -
    -
    - - )} - {toolsReady && auth === null && ( -

    - Couldn’t confirm Claude Code sign-in. Use Check again to retry, or - sign in below. -

    - )} - {needsGuidance && setup.loginCommand && ( -
    - Sign in to Claude Code -

    - Run this in your terminal and follow Claude’s sign-in steps: -

    - - {setup.loginCommand} - -
    - )} -
    - )} -
  • - ); -} diff --git a/src/app/Settings.tsx b/src/app/Settings.tsx index ee7290f21..cbff4f34d 100644 --- a/src/app/Settings.tsx +++ b/src/app/Settings.tsx @@ -27,7 +27,6 @@ import { ShortcutSettings } from "./ShortcutSettings"; import { DeveloperSettings } from "./DeveloperSettings"; import { ArchiveSettings } from "./ArchiveSettings"; import { AgentSettings } from "./AgentSettings"; -import type { AgentControl } from "../features/agents/control"; import type { SettingsCards } from "../features/settings/service"; import { OwnedContribution } from "../plugins/OwnedContribution"; import type { Updates } from "../features/updates/updates"; @@ -43,14 +42,12 @@ export function Settings({ shortcuts, shortcutBindings, notifications, - agentControl, updates, navigation, onSection, navigationPane = false, }: { cards: SettingsCards; - agentControl: AgentControl; plugins: PluginManager; communities: Communities; identity?: Identity | undefined; @@ -163,7 +160,6 @@ export function Settings({