diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index 4cc98d0a..63886dd3 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -47,6 +47,10 @@ pub fn valid_id(id: &str) -> Result<()> { } pub fn bundled_manifests() -> Vec { vec![ + serde_json::from_str(include_str!( + "../../../src/bundled/agent-activity/manifest.json" + )) + .expect("agent activity manifest"), serde_json::from_str(include_str!("../../../src/bundled/terminal/manifest.json")) .expect("terminal manifest"), serde_json::from_str(include_str!("../../../src/bundled/profiles/manifest.json")) diff --git a/dev/agent-observer.mjs b/dev/agent-observer.mjs new file mode 100644 index 00000000..362684cc --- /dev/null +++ b/dev/agent-observer.mjs @@ -0,0 +1,39 @@ +import { getPublicKey, nip44 } from "nostr-tools"; +import { eventDto } from "../src/features/relay/events.ts"; +import { + OBSERVER_KIND, + observerFrame, +} from "../src/features/agents/observer.ts"; + +/** Purpose-bound, synchronous decode in the existing key-owning host only. + * Relay admission establishes the agent/owner relationship; tags alone do not. */ +export function decodeAgentObserver(input, secret, viewer) { + if (getPublicKey(secret) !== viewer) + throw new Error("Observer viewer changed"); + const event = eventDto(input); + const exact = (name, value) => { + const tags = event.tags.filter((tag) => tag[0] === name); + return tags.length === 1 && tags[0].length === 2 && tags[0][1] === value; + }; + if ( + event.kind !== OBSERVER_KIND || + !exact("p", viewer) || + !exact("agent", event.pubkey) || + !exact("frame", "telemetry") || + event.content.length < 132 || + event.content.length > 87472 || + Math.abs(event.created_at - Math.floor(Date.now() / 1000)) > 300 + ) + throw new Error("Invalid observer envelope"); + const key = nip44.v2.utils.getConversationKey(secret, event.pubkey); + try { + return observerFrame({ + id: event.id, + agent: event.pubkey, + createdAt: event.created_at, + plaintext: nip44.v2.decrypt(event.content, key), + }); + } finally { + key.fill(0); + } +} diff --git a/dev/agent-observer.test.mjs b/dev/agent-observer.test.mjs new file mode 100644 index 00000000..b113e130 --- /dev/null +++ b/dev/agent-observer.test.mjs @@ -0,0 +1,75 @@ +import { test, expect } from "vitest"; +import { + finalizeEvent, + generateSecretKey, + getPublicKey, + nip44, +} from "nostr-tools"; +import { decodeAgentObserver } from "./agent-observer.mjs"; + +const owner = generateSecretKey(), + agent = generateSecretKey(), + stranger = generateSecretKey(); +const viewer = getPublicKey(owner), + sender = getPublicKey(agent); +const raw = JSON.stringify({ + kind: "acp_read", + channelId: null, + sessionId: null, + turnId: null, + seq: 1, + timestamp: new Date().toISOString(), + payload: { text: "" }, +}); +function frame(patch = {}, plaintext = raw) { + return finalizeEvent( + { + kind: 24200, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["p", viewer], + ["agent", sender], + ["frame", "telemetry"], + ], + content: nip44.v2.encrypt( + plaintext, + nip44.v2.utils.getConversationKey(agent, viewer), + ), + ...patch, + }, + agent, + ); +} +test("purpose-bound host decoder preserves raw JSON and never returns keys", () => { + const event = frame(); + expect(decodeAgentObserver(event, owner, viewer)).toEqual({ + id: event.id, + agent: sender, + createdAt: event.created_at, + plaintext: raw, + }); +}); +test("rejects signature, recipient, sender, direction, cardinality, freshness, content and captured-viewer violations", () => { + const tags = frame().tags; + const invalid = [ + { ...frame(), sig: "0".repeat(128) }, + frame({ kind: 9 }), + frame({ tags: [["p", getPublicKey(stranger)], ...tags.slice(1)] }), + frame({ tags: [tags[0], ["agent", viewer], tags[2]] }), + frame({ tags: [tags[0], tags[1], ["frame", "control"]] }), + ...tags.map((tag) => frame({ tags: [...tags, tag] })), + frame({ tags: [["p", viewer, "extra"], ...tags.slice(1)] }), + frame({ content: "x" }), + frame({ content: "x".repeat(87473) }), + frame({ created_at: Math.floor(Date.now() / 1000) - 301 }), + frame({ created_at: Math.floor(Date.now() / 1000) + 301 }), + frame({}, "not JSON"), + ]; + for (const event of invalid) + expect(() => decodeAgentObserver(event, owner, viewer)).toThrow(); + expect(() => decodeAgentObserver(frame(), stranger, viewer)).toThrow(); + // Cached verification symbols from finalizeEvent must not bypass verification. + const cached = frame(); + cached.content = frame({}, "{}").content; + expect(() => decodeAgentObserver(cached, owner, viewer)).toThrow(); +}); diff --git a/dev/relay-broker-live.test.mjs b/dev/relay-broker-live.test.mjs index 471ecf80..68c311ea 100644 --- a/dev/relay-broker-live.test.mjs +++ b/dev/relay-broker-live.test.mjs @@ -2,7 +2,13 @@ import { fixtureRelayUrl, fixtureAliases } from "../tests/relay-config.ts"; import { createServer } from "node:http"; import { setTimeout as delay } from "node:timers/promises"; import { test, expect, vi } from "vitest"; -import { getPublicKey } from "nostr-tools"; +import { + getPublicKey, + generateSecretKey, + finalizeEvent, + nip44, +} from "nostr-tools"; +import { createRelaySession } from "../src/features/relay/session.ts"; import { relayBrokerPlugin } from "./relay-broker.mjs"; import { connectBrokerTransport } from "../src/features/relay/transport.ts"; @@ -32,7 +38,7 @@ async function harness( if (kind === "AUTH") queueMicrotask(() => this.receive(["OK", id.id, true])); if (kind !== "REQ") return; - requests.push({ at: performance.now(), filter, socket }); + requests.push({ at: performance.now(), id, filter, socket }); const refused = requests.length === refuseAt; queueMicrotask(() => this.receive(refused ? ["CLOSED", id, reason] : ["EOSE", id]), @@ -65,6 +71,7 @@ async function harness( const base = `http://127.0.0.1:${server.address().port}`; const controllers = []; return { + key, sockets, requests, base, @@ -154,6 +161,123 @@ test("real HTTP accepts the 1022-channel body and rejects invalid/oversized/orig } }); +test.each([null, 1])( + "maximum channel interests survive observer startup and toggles (initial %s) through the real broker/browser stream", + async (initialObserver) => { + const h = await harness(); + const nativeFetch = globalThis.fetch; + let traffic; + try { + const fetcher = vi.fn((input, init) => + nativeFetch(input, { + ...init, + headers: { + ...init?.headers, + ...(init?.method === "POST" ? { Origin: h.base } : {}), + }, + }), + ); + vi.stubGlobal("fetch", fetcher); + const transport = await connectBrokerTransport(h.base); + const states = [], + received = []; + let snapshot; + traffic = transport.subscribe({ + receive(events) { + received.push(...events); + }, + established() {}, + denied() {}, + state(value) { + snapshot = value; + states.push(value); + }, + }); + const ids = Array.from( + { length: 1024 }, + (_, i) => `channel-${String(i).padStart(4, "0")}`, + ); + traffic.observe(initialObserver); + traffic.update(ids); + const streamPosts = () => + fetcher.mock.calls.filter(([url]) => String(url).endsWith("/stream")) + .length; + let sockets, posts; + for (const [phase, observer] of [ + initialObserver, + initialObserver === null ? 1 : null, + initialObserver, + ].entries()) { + traffic.observe(observer); + const enabled = observer !== null; + await until( + () => + snapshot?.status === "connected" && + snapshot.routes.length === 1026 + Number(enabled) && + snapshot.routes.some( + (r) => r.channelId === ids[0] && r.status === "live", + ) && + (!enabled || + snapshot.routes.some( + (r) => r.id === "observer" && r.status === "live", + )), + ); + expect( + snapshot.routes + .filter((r) => r.channelId) + .map((r) => r.channelId) + .sort(), + ).toEqual(ids); + expect( + snapshot.routes + .filter((r) => r.status === "limited") + .map((r) => r.channelId), + ).toEqual(ids.slice(enabled ? 1021 : 1022)); + // Status retains every interest, but only 1024 routes may have a wire. + expect( + snapshot.routes.filter((r) => r.status !== "limited"), + ).toHaveLength(1024); + expect(snapshot.routes.some((r) => r.id === "observer")).toBe(enabled); + sockets ??= h.sockets.length; + posts ??= streamPosts(); + expect(h.sockets).toHaveLength(sockets); + expect(streamPosts()).toBe(posts); + + const socket = h.sockets.at(-1); + for (const [kind, tags] of [ + [9, [["h", ids[0]]]], + [0, []], + [44100, [["p", getPublicKey(h.key)]]], + ]) { + const route = h.requests.find( + (r) => r.socket === socket && r.filter.kinds.includes(kind), + ); + expect(route).toBeDefined(); + const event = finalizeEvent( + { + kind, + tags, + created_at: Math.floor(Date.now() / 1000), + content: `ordinary traffic in observer phase ${phase}`, + }, + h.key, + ); + await socket.receive(["EVENT", route.id, event]); + await until(() => received.some((r) => r.id === event.id)); + } + expect(received).toHaveLength((phase + 1) * 3); + expect( + states.filter((s) => s.status === "retrying" || s.status === "error"), + ).toEqual([]); + } + } finally { + traffic?.dispose(); + vi.unstubAllGlobals(); + await h.close(); + } + }, +); + test.each([ "rate-limited: quota exceeded; retry in 0s", "temporary: fixture read unavailable", @@ -373,3 +497,123 @@ test("priority control cannot allocate interests or bypass owner, origin, commun await h.close(); } }); + +test("real signed/encrypted WS → host decode → SSE → session activity; demand and clear fence without replacing chat", async () => { + const h = await harness(); + const nativeFetch = globalThis.fetch; + let owner, release; + try { + vi.stubGlobal("fetch", (input, init) => + nativeFetch(input, { + ...init, + headers: { + ...init?.headers, + ...(init?.method === "POST" ? { Origin: h.base } : {}), + }, + }), + ); + const transport = await connectBrokerTransport(h.base); + expect(transport.agentActivity).toBe(true); + owner = createRelaySession(transport, { prepared: true }); + release = owner.session.agentActivity.activate(); + await until( + () => owner.session.agentActivity.snapshot().status === "listening", + ); + const routes = () => + h.requests.filter((r) => r.filter.kinds.includes(24200)); + const first = routes().at(-1); + const socketCount = h.sockets.length; + const globals = h.requests.filter( + (r) => !r.filter.kinds.includes(24200), + ).length; + const agent = generateSecretKey(), + sender = getPublicKey(agent), + viewer = getPublicKey(h.key); + const encrypt = ( + raw, + tags = [ + ["p", viewer], + ["agent", sender], + ["frame", "telemetry"], + ], + ) => + finalizeEvent( + { + kind: 24200, + created_at: Math.floor(Date.now() / 1000), + tags, + content: nip44.v2.encrypt( + JSON.stringify(raw), + nip44.v2.utils.getConversationKey(agent, viewer), + ), + }, + agent, + ); + const raw = { + kind: "turn_started", + seq: 1, + timestamp: new Date().toISOString(), + channelId: null, + sessionId: null, + turnId: "synthetic-turn", + payload: { text: "inert " }, + }; + const event = encrypt(raw); + const view = owner.session.observe([{ kinds: [24200], limit: 1 }]); + await first.socket.receive(["EVENT", first.id, event]); + await until( + () => owner.session.agentActivity.snapshot().records.length === 1, + ); + expect(owner.session.agentActivity.snapshot().records[0].plaintext).toBe( + JSON.stringify(raw), + ); + expect(owner.session.agentActivity.snapshot().turns[0].state).toBe( + "working", + ); + expect(view.snapshot().events).toEqual([]); + // Wrong direction is signed/encrypted but must never become telemetry. + await first.socket.receive([ + "EVENT", + first.id, + encrypt(raw, [ + ["p", viewer], + ["agent", sender], + ["frame", "control"], + ]), + ]); + await delay(20); + expect(owner.session.agentActivity.snapshot().records).toHaveLength(1); + await owner.clearCache(); + expect(owner.session.agentActivity.snapshot().records).toHaveLength(0); + await until(() => routes().length === 2); + await first.socket.receive(["EVENT", first.id, event]); + await delay(20); + expect(owner.session.agentActivity.snapshot().records).toHaveLength(0); + const second = routes().at(-1); + await until( + () => owner.session.agentActivity.snapshot().status === "listening", + ); + await second.socket.receive([ + "EVENT", + second.id, + encrypt({ ...raw, kind: "turn_completed" }), + ]); + await until( + () => owner.session.agentActivity.snapshot().records.length === 1, + ); + expect(owner.session.agentActivity.snapshot().turns[0].state).toBe("ended"); + release(); + expect(owner.session.agentActivity.snapshot().records).toHaveLength(0); + await delay(30); + expect(h.sockets).toHaveLength(socketCount); + expect( + h.requests.filter((r) => !r.filter.kinds.includes(24200)), + ).toHaveLength(globals); + view.dispose(); + } finally { + release?.(); + owner?.dispose(); + vi.unstubAllGlobals(); + await h.close(); + } +}); diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index 9ec90c45..ce22a2e9 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -1,3 +1,5 @@ +import { decodeAgentObserver } from "./agent-observer.mjs"; +import { observerGeneration } from "../src/features/agents/observer.ts"; import { decodeReadState, signReadState, @@ -511,24 +513,29 @@ export function relayBrokerPlugin({ readState: true, agentLibrary: true, live: true, + agentActivity: true, }); if ( - ["/api/relay/stream-retry", "/api/relay/stream-priority"].includes( - route, - ) && + [ + "/api/relay/stream-retry", + "/api/relay/stream-priority", + "/api/relay/stream-observer", + ].includes(route) && req.method === "POST" ) { const prioritizing = route === "/api/relay/stream-priority"; + const observing = route === "/api/relay/stream-observer"; let raw = ""; for await (const part of req) { raw += part; if (Buffer.byteLength(raw) > (prioritizing ? 9000 : 256)) return json(res, 413, { error: "Live control too large" }); } - let streamId, priority; + let streamId, priority, observer; try { const body = JSON.parse(raw); streamId = body.streamId; + if (observing) observer = observerGeneration(body.observer); if (prioritizing) { liveChannels(body.channels); if (body.channels.length > 64) @@ -549,6 +556,7 @@ export function relayBrokerPlugin({ error: "Live stream no longer available", }); if (prioritizing) stream.traffic.prioritize(priority); + else if (observing) stream.traffic.observe(observer); else stream.traffic.retry(); return json(res, 200, { accepted: true }); } @@ -559,10 +567,11 @@ export function relayBrokerPlugin({ if (Buffer.byteLength(raw) > 150000) return json(res, 413, { error: "Live interests too large" }); } - let channels, priority; + let channels, priority, observer; try { const body = JSON.parse(raw); channels = liveChannels(body.channels); + observer = observerGeneration(body.observer ?? null); liveChannels(body.priority ?? []); if (body.priority?.length > 64) throw new Error("Priority capacity reached"); @@ -606,6 +615,17 @@ export function relayBrokerPlugin({ receive: (events) => { for (const event of events) write("", event); }, + telemetry: (event, generation) => { + if (res.destroyed) return; + try { + write("observer", { + frame: decodeAgentObserver(event, key, viewer), + generation, + }); + } catch { + // Rejected telemetry cannot break chat or leak payloads in logs. + } + }, state: (state) => write("state", state), established: (channelId) => write("established", { channelId }), denied: (channelId, reason) => @@ -615,6 +635,7 @@ export function relayBrokerPlugin({ principal.live, ); principal.streams++; + traffic.observe(observer); traffic.prioritize(priority); traffic.update(channels); const keepAlive = setInterval( diff --git a/docs/agents.md b/docs/agents.md index 35aa90fb..2f0e6426 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -161,3 +161,97 @@ macOS after dependency pruning; no native compilation was run. Broader scan/native/package checks and independent compatibility-adapter review remain deferred until an agreed integration batch. Do not gate ordinary visual feedback on them. Broader agent architecture proposals are outside the V1 scope. + + +## Raw Agent Activity plugin + +**Agent Activity** is an independently toggleable bundled panel with a top-bar +launcher. It shows the exact JSON plaintext received from owner-only kind-24200 +telemetry, in keyboard-accessible code disclosures. An agent enters the selector +after its first retained frame; exact public keys distinguish namesakes. A profile +**View activity** action can preselect an exact identity and originating channel +before any frames arrive. Profile names are optional shared lookups, never ownership +evidence. The action is not an agent/ownership badge and may show a waiting state +for identities with no published owner-visible telemetry. + +The **Channel** selector filters both raw envelopes and working-turn counts, or +shows all channels including unscoped records. Envelope and batch-child channel +metadata are indexed without rewriting the raw JSON: a matching envelope is shown +whole and may contain other contexts. Channel scope is not thread scope. No +additional relay directory scan, activity subscription, or thread inference is used. +Channels owns the contextual right-hand slot and closes it on channel/session or +contribution changes; close returns focus to the original conversation control. + +The plugin's activation leases `session.agentActivity`; closing the panel does +not stop capture. Disabling it releases demand and clears RAM. The shared live +connection carries one dedicated `#p=viewer` observer route, with no `#h`, history +limit, or replay: `since` is stamped at actual dispatch and retry. It reserves one +of the shared subscription slots. Successful toggles/access clears replace only +that route, not the socket or chat globals. An uncertain control failure can +reconnect the shared stream through its existing bounded recovery path. + +`dev/agent-observer.mjs` performs signature, exact telemetry tag, recipient/key, +freshness and size validation before host-only NIP-44 decryption. The browser +receives a purpose-bound DTO, not keys or a general decrypt API. The relay's +admission establishes agent ownership; a name, local library entry, or successful +decryption alone does not. Observer records never enter ordinary history, +message/unread reconciliation, or disk caches. + +Retention is session-owned RAM: at most 200 envelopes / 2 MiB plaintext and 512 +turn states, with visible trimming. Disable, cache/access reset and session +replacement clear it; generation fences reject prior in-flight deliveries. A raw +batch with a recognized denied channel is discarded as a whole. + +Working is fresh per-turn evidence, not process status. Batch children fold +individually; `session_resolved` is activity, while `turn_completed`, `turn_error` +and `agent_panic` end the agent/turn pair even with a null session ID. Silence +beyond 30 seconds or disconnect makes work unknown, not stopped. Terminal state +retains a monotonic evidence timestamp through clock rollback and bounded eviction. +There is no agent-global sequence gate: producer sequences reset, skip and interleave. + +### Try with an existing owner account + +Use the [README's public-pin/Keychain setup](../README.md#relay-channels) and run +`bin/just web` (or `bin/just desktop`, not both). Open http://localhost:1430, choose +the agent's community and click **Agent Activity**. Keep the existing Buzz runner +active, with telemetry publication enabled on the agent, then give it work. This +app does not start agents or turn publishing on. No records may mean publishing +is off, no new traffic, or an interrupted feed—not that an agent is idle. + +For a contextual view, click the identity's avatar/mention in the channel, then +**View activity**. It preselects that exact key and channel; **Channel → All channels** +broadens the view. The global heartbeat launcher still opens an unscoped selector. +Select an observed agent, expand raw frames, close/reopen the panel, and toggle +**Your profile → Settings → Plugins → Agent Activity** off/on. Re-enable starts +empty. The feed is live-only, best-effort telemetry: the producer coalesces/batches +and may elide oversized content. It is not a complete ACP transcript or archive. +The development broker supports this slice; packaged/native signed transport +without that broker reports unavailable. No composer indicator, runtime controller, +recording export or old transcript renderer is included. + +### Evidence and remaining acceptance + +`dev/agent-observer.test.mjs`, `dev/relay-broker-live.test.mjs`, and the activity/live +service tests cover signed/encrypted WS → host decode → SSE → actual session, +route generations, no chat reconciliation, terminal retention and stale controls. +`tests/browser/agent-activity.spec.mjs` covers the actual plugin, raw HTML +nonexecution, keyboard disclosures, agent selection, disable/re-enable and +light/dark layouts at 1280 and 390 pixels in Chromium and WebKit. Live retry and +plugin-launcher regression journeys also pass with the additional observer route. +These automated checks use only ephemeral identities and synthetic upstream +telemetry. The owner reported a successful live activity try on 2026-09-12 before +the mainline merge; this is feedback evidence, not an independently captured trace. + +The full `just scan` passed at `1183b2624485dc1e6a12e86cece22eaf7513591c` +after merging main's Markdown and Terminal changes: 35 Node integration tests, +1,054 Vitest tests, 16 plugin-manager Rust tests, 274 Chromium/WebKit browser +checks (including measurements), 14 design-browser checks, 9 native Rust tests, +formatting, types, builds and Clippy. Independent source review found no remaining +merge-integration blocker. Channel-opening fixtures kept optional profiles held; +warm click-to-visible samples were 16.8–19.2ms in Chromium and 50–67ms in WebKit, +with no new head read, below the unchanged 100ms budget. These are local Apple +Silicon fixture measurements, not a live-network SLA. + +Packaged/native activity without the development broker remains unsupported; +attended native/package acceptance and cross-platform CI are separate from these +local results. diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 34934e4a..70c2b641 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -75,9 +75,15 @@ contribution when its Cordis scope ends. A page calls `panels.resolve(target)` and renders `PanelView` with the resulting contribution, the target string, and a close callback. The first active matcher -wins; a throwing matcher is skipped. Ordinary link panels receive `{ target, close }`; -channel-launched panels may also receive the public context described below. A -plugin that needs shared data declares `relay` in its +wins; a throwing matcher is skipped. Panels receive `{ target, close }` plus +optional host context. A conversation host may supply +`context: { channelId, canOpen, open }` for contextual panel-to-panel actions. +`canOpen` is advisory active-target availability; `open` re-resolves at click time +and returns false after the originating opening, channel, session or host +presentation retires. This is not a global navigation API or an access grant. +Channel-header launchers use the separate public `channelContext` metadata +contract described below; launcher/fallback panels need not have either context. +A plugin that needs shared data declares `relay` in its injection list and passes those capabilities to its components using a closure, just as the bundled Channels page does. The conversation preview adds only the two demonstrated component surfaces. diff --git a/docs/profiles.md b/docs/profiles.md index 5cd22d10..0965bbc3 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -4,6 +4,10 @@ The bundled `buzz.profiles` plugin supplies a minimal, read-only panel for any public identity, human or agent. It uses the current session's shared profile directory. Agents retains agent-specific configuration/operations; this slice adds no ownership/running badge, editor, agent-library lookup or execution API. +When Agent Activity is enabled and the host supplies conversation context, **View +activity** opens its raw panel for this exact identity and originating channel. +This action is offered for any public identity: it does not infer that the identity +is an owned/running agent. Missing telemetry is explained by the activity panel. ## Boundaries @@ -35,7 +39,7 @@ adds no ownership/running badge, editor, agent-library lookup or execution API. ## UI and iteration -Avatar, name, about and exact copyable npub only. Shared design-system Avatar and +Avatar, name, about, exact copyable npub, and an optional contextual activity action. Shared design-system Avatar and Button use the host-loaded styles directly. The profile content marks its `data-buzz-ui` boundary and uses shared heading/body/mono roles; its stylesheet owns layout, not component overrides. No new theme owner, second global reset or diff --git a/public/agent-activity.svg b/public/agent-activity.svg new file mode 100644 index 00000000..41f9d18d --- /dev/null +++ b/public/agent-activity.svg @@ -0,0 +1 @@ +Agent Activity diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index ed4fa6db..158681af 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -67,6 +67,31 @@ test("the app runtime exposes ready bundled pages and removes them on disable", ), ); + const activity = services.panels + .snapshot() + .find((panel) => panel.pluginId === "buzz.agent-activity"); + assert.equal(activity.title, "Agent Activity"); + assert.equal(activity.launcher.icon, "/agent-activity.svg"); + assert.match( + renderToStaticMarkup(createElement(activity.component)), + /Connect to a community/, + ); + await services.plugins.change("disable", "buzz.agent-activity"); + assert.equal( + services.panels + .snapshot() + .some((panel) => panel.pluginId === "buzz.agent-activity"), + false, + ); + await services.plugins.change("enable", "buzz.agent-activity"); + await vi.waitFor(() => + assert.ok( + services.panels + .snapshot() + .some((panel) => panel.pluginId === "buzz.agent-activity"), + ), + ); + const firstBestie = services.panels .snapshot() .find((panel) => panel.pluginId === "buzz.bestie"); diff --git a/src/bundled/agent-activity/ActivityPanel.tsx b/src/bundled/agent-activity/ActivityPanel.tsx new file mode 100644 index 00000000..a97a646d --- /dev/null +++ b/src/bundled/agent-activity/ActivityPanel.tsx @@ -0,0 +1,248 @@ +import { + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { + activitySelection, + type ActivitySelection, +} from "../../features/agents/activity-target"; +import { Accordion } from "../../shared/design-system/ui/Accordion"; +import { Select } from "../../shared/design-system/ui/Select"; +import { Button } from "../../shared/design-system/ui/Button"; +import { selectProfiles } from "../../features/relay/profile-selection"; +import { useRelayConnection } from "../../features/relay/react"; +import type { RelayData } from "../../features/relay/service"; +import type { RelaySession } from "../../features/relay/session"; + +export function ActivityPanel({ + relay, + target = "", +}: { + relay: RelayData; + target?: string; +}) { + const connection = useRelayConnection(relay); + if (connection.status !== "ready") + return ( +

+ Connect to a community to view agent activity. +

+ ); + return ( + + ); +} +export function ActivityDetails({ + session, + selection, +}: { + session: RelaySession; + selection?: ActivitySelection | undefined; +}) { + const activity = session.agentActivity; + const snapshot = useSyncExternalStore( + activity.subscribe, + activity.snapshot, + activity.snapshot, + ); + const agents = useMemo( + () => [...new Set(snapshot.records.map((row) => row.agent))], + [snapshot.records], + ); + const [selected, select] = useState(selection?.agent ?? ""); + const [channelId, selectChannel] = useState(selection?.channelId ?? ""); + const agentChoices = useMemo( + () => [...new Set([...agents, ...(selected ? [selected] : [])])], + [agents, selected], + ); + const profiles = useMemo( + () => selectProfiles(session.profiles, agentChoices), + [session.profiles, agentChoices], + ); + // Reuse already loaded channel labels; opening activity does not scan a directory. + const channels = useSyncExternalStore( + session.channels.subscribeList, + session.channels.list, + session.channels.list, + ).channels; + const identities = useSyncExternalStore( + profiles.subscribe, + profiles.snapshot, + profiles.snapshot, + ); + const agent = selected || agents[0] || ""; + const [expanded, expand] = useState([]); + const agentRecords = snapshot.records.filter((row) => row.agent === agent); + const records = agentRecords.filter( + (row) => !channelId || row.channelIds.includes(channelId), + ); + const channelChoices = [ + ...new Set([ + ...agentRecords.flatMap((row) => row.channelIds), + ...(channelId ? [channelId] : []), + ]), + ]; + const region = useRef(null); + useEffect(() => { + region.current?.focus(); + }, []); + // Keep expansion intent as bounded as the underlying RAM journal. + useEffect(() => { + const ids = new Set(snapshot.records.map((row) => row.id)); + expand((previous) => + previous.every((id) => ids.has(id)) + ? previous + : previous.filter((id) => ids.has(id)), + ); + }, [snapshot.records]); + const turns = snapshot.turns.filter( + (turn) => + turn.agent === agent && (!channelId || turn.channelId === channelId), + ); + const working = turns.filter((turn) => turn.state === "working").length; + const unknown = turns.filter((turn) => turn.state === "unknown").length; + return ( +
+

Agent activity

+

+ Live, owner-only telemetry received while this plugin is enabled. This + is not a complete ACP recording; publication must be enabled on the + agent. +

+ {snapshot.status === "unavailable" ? ( +

+ This host cannot decode agent activity. Live activity currently + requires the development broker. +

+ ) : ( + <> +

+ Feed: {snapshot.status}. Quiet or disconnected means unknown, not + stopped. +

+ {snapshot.status === "interrupted" && ( + + )} + {!agents.length && !selected ? ( +

+ Waiting for live records. Select an agent after its first frame + arrives; there is no history backfill. +

+ ) : ( + <> + ({ + value: id, + label: `${channels.find((channel) => channel.id === id)?.name ?? "Channel"} · ${id}`, + })), + ], + }, + ]} + onValueChange={(id) => { + selectChannel(id); + expand([]); + }} + /> + {channelId && ( +

+ Channel-wide, not thread-specific. Matching envelopes are + shown intact; a batch may also contain other contexts. +

+ )} +

+ {working + ? `${working} observed working turn(s).` + : "No fresh working evidence."}{" "} + {unknown + ? `${unknown} turn(s) have unknown current state.` + : ""} +

+

+ Working evidence expires after 30 seconds without a fresh turn + record. Completed means ended, not necessarily succeeded. +

+ {!records.length && ( +

+ Waiting for live records for this identity + {channelId ? " in this channel" : ""}. Only owner-visible + agent telemetry appears; there is no history backfill. +

+ )} + ({ + value: row.id, + title: `${row.kind} · ${new Date(row.receivedAt).toLocaleTimeString()}`, + content: ( +
+

+ Event {row.id} +

+
+                        {row.plaintext}
+                      
+
+ ), + }))} + /> + + )} + {snapshot.trimmed > 0 && ( +

+ Retention limited: {snapshot.trimmed} older records or turn states + discarded. +

+ )} +

+ RAM only: up to 200 envelopes / 2 MiB and 512 turn states. Cleared + on disable, cache/access reset, or session replacement. +

+ + )} +
+ ); +} diff --git a/src/bundled/agent-activity/index.tsx b/src/bundled/agent-activity/index.tsx new file mode 100644 index 00000000..9f0ff2a6 --- /dev/null +++ b/src/bundled/agent-activity/index.tsx @@ -0,0 +1,34 @@ +import type { PluginModule } from "../../plugins/api"; +import type { RelaySession } from "../../features/relay/session"; +import { activitySelection } from "../../features/agents/activity-target"; +import { ActivityPanel } from "./ActivityPanel"; +export const inject = ["panels", "relay"]; +export const apply: PluginModule["apply"] = (ctx) => { + const relay = ctx.relay; + ctx.effect(() => { + let session: RelaySession | undefined; + let release: (() => void) | undefined; + const bind = () => { + const connection = relay.snapshot(); + const next = + connection.status === "ready" ? connection.session : undefined; + if (next === session) return; + release?.(); + session = next; + release = next?.agentActivity.activate(); + }; + const stop = relay.subscribe(bind); + bind(); + return () => { + stop(); + release?.(); + }; + }); + ctx.panels.register({ + id: "activity", + title: "Agent Activity", + matches: (target) => !!activitySelection(target), + launcher: { icon: "/agent-activity.svg", target: "" }, + component: ({ target }) => , + }); +}; diff --git a/src/bundled/agent-activity/manifest.json b/src/bundled/agent-activity/manifest.json new file mode 100644 index 00000000..553a996c --- /dev/null +++ b/src/bundled/agent-activity/manifest.json @@ -0,0 +1 @@ +{ "id": "buzz.agent-activity", "name": "Agent Activity", "apiVersion": 1 } diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 9f53f5f2..b511e599 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -7,6 +7,7 @@ import type { ConversationExtensions } from "../../features/conversation/contrac import { useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -28,7 +29,7 @@ import { useChannelWindow, useRelayConnection, } from "../../features/relay/react"; -import type { Panels } from "../../features/panels/service"; +import type { Panels, RegisteredPanel } from "../../features/panels/service"; import { PanelCard } from "../../features/panels/PanelCard"; import { PanelFrame } from "../../features/panels/PanelFrame"; import { OutboxStatus } from "./OutboxStatus"; @@ -112,6 +113,7 @@ export function ChannelsPage({ key={`${session.scope ?? "disconnected"}:${session.generation}`} scope={session.scope ?? "disconnected"} queries={session.session} + relay={relay} navigation={sessionNavigation} navigator={navigator} viewer={session.viewer} @@ -126,6 +128,7 @@ export function ChannelsPage({ function ChannelWorkspace({ extensions, queries, + relay, panels, scope, companion, @@ -140,6 +143,7 @@ function ChannelWorkspace({ navigator?: Navigation | undefined; viewer?: string | undefined; queries: RelaySession; + relay: RelayData; panels: Panels; }) { const list = useChannelList(queries.channels); @@ -215,6 +219,34 @@ function ChannelWorkspace({ useEffect(() => { if (thread && !showingThread) setThread(undefined); }, [thread, showingThread]); + type Opening = { channelId: string; panel: RegisteredPanel; target: string }; + const [opened, setOpened] = useState(); + const opening = useRef(undefined); + const open = useCallback((next: Opening | undefined) => { + // Retire callbacks synchronously, before React commits the next opening. + opening.current = next; + setOpened(next); + }, []); + const panel = + opened && + opened.channelId === current?.id && + available.includes(opened.panel) + ? opened.panel + : undefined; + const mounted = useRef(false); + const channel = useRef(current?.id); + useLayoutEffect(() => { + channel.current = current?.id; + }, [current?.id]); + useLayoutEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + useEffect(() => { + if (opened && !panel) open(undefined); + }, [opened, panel, open]); const openThread = useCallback( (messageId: string) => { if (!current) return; @@ -225,35 +257,19 @@ function ChannelWorkspace({ setThread({ channelId: current.id, messageId }); open(undefined); }, - [current], + [current, open], ); const closeThread = useCallback(() => { setThread(undefined); if (threadTrigger.current?.isConnected) threadTrigger.current.focus(); }, []); - const [opened, open] = useState<{ - channelId: string; - key: string; - revision: string; - target: string; - }>(); - const panel = - opened?.channelId === current?.id - ? available.find( - (panel) => - panel.key === opened?.key && panel.revision === opened.revision, - ) - : undefined; - useEffect(() => { - if (opened && !panel) open(undefined); - }, [opened, panel]); const panelTrigger = useRef(null); const close = useCallback(() => { open(undefined); if (panelTrigger.current?.isConnected) panelTrigger.current.focus({ preventScroll: true }); else if (threadTrigger.current?.isConnected) threadTrigger.current.focus(); - }, []); + }, [open]); // Availability follows active contributions; dispatch still re-resolves at click time. const canOpenLink = useCallback( (target: string) => @@ -277,16 +293,44 @@ function ChannelWorkspace({ setThread(undefined); open({ channelId: current.id, - key: candidate.key, - revision: candidate.revision, + panel: candidate, target: url, }); return true; } return false; }, - [panels, current], + [panels, current, open], ); + const panelActive = () => { + const connection = relay.snapshot(); + return !!( + mounted.current && + opened && + panel && + opening.current === opened && + channel.current === opened.channelId && + panels.snapshot().includes(panel) && + connection.status === "ready" && + connection.session === queries && + !navigation?.signal.aborted + ); + }; + const panelContext = + opened && panel + ? { + channelId: opened.channelId, + canOpen: (target: string) => !!panels.resolve(target), + open: (target: string) => { + if (!panelActive()) return false; + const next = panels.resolve(target); + if (!next) return false; + // Keep the original conversation trigger for close/focus restoration. + open({ channelId: opened.channelId, panel: next, target }); + return true; + }, + } + : undefined; const drawerContext = useMemo( () => current && viewer @@ -529,6 +573,7 @@ function ChannelWorkspace({ key="target" panel={panel} target={opened.target} + context={panelContext} close={close} closeLabel="Close channel panel" /> diff --git a/src/bundled/index.ts b/src/bundled/index.ts index 78cf15c0..42a731cd 100644 --- a/src/bundled/index.ts +++ b/src/bundled/index.ts @@ -1,3 +1,5 @@ +import activityManifest from "./agent-activity/manifest.json"; +import * as activity from "./agent-activity"; import terminalManifest from "./terminal/manifest.json"; import * as terminal from "./terminal"; import profilesManifest from "./profiles/manifest.json"; @@ -19,6 +21,7 @@ import * as projects from "./projects"; import type { BundledPlugin } from "../plugins/manager"; export const bundledPlugins: readonly BundledPlugin[] = [ + { manifest: { ...activityManifest, apiVersion: 1 }, module: activity }, { manifest: { ...terminalManifest, apiVersion: 1 }, module: terminal }, { manifest: { ...profilesManifest, apiVersion: 1 }, module: profiles }, { manifest: { ...mentionsManifest, apiVersion: 1 }, module: mentions }, diff --git a/src/bundled/profiles/ProfilePanel.tsx b/src/bundled/profiles/ProfilePanel.tsx index 584bdd31..4259775a 100644 --- a/src/bundled/profiles/ProfilePanel.tsx +++ b/src/bundled/profiles/ProfilePanel.tsx @@ -8,6 +8,7 @@ import { import { IconCopy } from "@tabler/icons-react"; import { Avatar } from "../../shared/design-system/ui/Avatar"; import { Button } from "../../shared/design-system/ui/Button"; +import { activityTarget } from "../../features/agents/activity-target"; import type { PanelProps } from "../../features/panels/service"; import { profileKey, profileTarget } from "../../features/profiles/target"; import { selectProfiles } from "../../features/relay/profile-selection"; @@ -19,6 +20,7 @@ import styles from "./Profiles.module.css"; export function ProfilePanel({ relay, target, + context, }: PanelProps & { relay: RelayData }) { const connection = useRelayConnection(relay); const pubkey = profileKey(target); @@ -30,15 +32,18 @@ export function ProfilePanel({ key={`${connection.scope}:${connection.generation}:${pubkey}`} session={connection.session} pubkey={pubkey} + context={context} /> ); } function ProfileDetails({ session, pubkey, + context, }: { session: RelaySession; pubkey: string; + context: PanelProps["context"]; }) { const selection = useMemo( () => selectProfiles(session.profiles, [pubkey]), @@ -77,6 +82,7 @@ function ProfileDetails({ }, [session, pubkey, attempt]); const npub = profileTarget(pubkey)?.slice(6) ?? pubkey; const name = profile?.name ?? "Unknown profile"; + const activity = activityTarget(pubkey, context?.channelId); return (
{name} {profile?.about &&

{profile.about}

} + {context?.canOpen(activity) && ( +
+ +

+ Owner-only agent telemetry in this channel, if published. +

+
+ )}

Public key

diff --git a/src/features/agents/activity-target.test.ts b/src/features/agents/activity-target.test.ts new file mode 100644 index 00000000..5580282b --- /dev/null +++ b/src/features/agents/activity-target.test.ts @@ -0,0 +1,24 @@ +import { expect, it } from "vitest"; +import { activityTarget, activitySelection } from "./activity-target"; +const agent = "a".repeat(64); +it("round-trips an exact agent and optional channel without guessing a thread", () => { + expect(activitySelection(activityTarget(agent))).toEqual({ agent }); + expect(activitySelection(activityTarget(agent, "a/b & c"))).toEqual({ + agent, + channelId: "a/b & c", + }); +}); +it("rejects other targets, ambiguous keys and malformed selection", () => { + for (const target of [ + "", + "nostr:npub1abc", + activityTarget("Carl", "alpha"), + `${activityTarget(agent)}&agent=${agent}`, + `${activityTarget(agent)}&channel=`, + `${activityTarget(agent)}&channel=a&channel=b`, + `${activityTarget(agent)}#fragment`, + `${activityTarget(agent)}&thread=one`, + activityTarget(agent, "x".repeat(257)), + ]) + expect(activitySelection(target)).toBeUndefined(); +}); diff --git a/src/features/agents/activity-target.ts b/src/features/agents/activity-target.ts new file mode 100644 index 00000000..9a6023b4 --- /dev/null +++ b/src/features/agents/activity-target.ts @@ -0,0 +1,33 @@ +/** Internal panel target, not an OS deep link or an access grant. */ +export type ActivitySelection = Readonly<{ agent: string; channelId?: string }>; +export function activityTarget(agent: string, channelId?: string): string { + const query = new URLSearchParams({ agent }); + if (channelId) query.set("channel", channelId); + return `buzz:agent-activity?${query}`; +} +export function activitySelection( + target: string, +): ActivitySelection | undefined { + try { + const url = new URL(target); + const params = url.searchParams; + const agent = params.get("agent"); + const channelId = params.get("channel"); + if ( + url.protocol !== "buzz:" || + url.pathname !== "agent-activity" || + url.host || + url.hash || + !agent || + !/^[0-9a-f]{64}$/.test(agent) || + params.getAll("agent").length !== 1 || + params.getAll("channel").length > 1 || + [...params.keys()].some((key) => key !== "agent" && key !== "channel") || + (channelId !== null && (!channelId || channelId.length > 256)) + ) + return; + return { agent, ...(channelId !== null ? { channelId } : {}) }; + } catch { + return; + } +} diff --git a/src/features/agents/activity.test.ts b/src/features/agents/activity.test.ts new file mode 100644 index 00000000..300c4069 --- /dev/null +++ b/src/features/agents/activity.test.ts @@ -0,0 +1,217 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { + ACTIVITY_BYTE_LIMIT, + ACTIVITY_RECORD_LIMIT, + ACTIVITY_TURN_LIMIT, + createAgentActivity, +} from "./activity"; +import type { LiveSnapshot } from "../relay/live"; +const agent = "a".repeat(64); +const connected: LiveSnapshot = { + status: "connected", + routes: [{ id: "observer", status: "live", replay: "unknown" }], +}; +function fixture() { + vi.useFakeTimers(); + vi.setSystemTime(1800000000000); + const observe = vi.fn(); + let denied = false; + const activity = createAgentActivity( + true, + observe, + (channel) => !(denied && channel === "a"), + ); + const release = activity.queries.activate(); + activity.state(connected); + let serial = 0; + const generation = () => observe.mock.lastCall?.[0] as number; + const make = (raw: unknown) => ({ + id: (++serial).toString(16).padStart(64, "0"), + agent, + createdAt: Math.floor(Date.now() / 1000), + plaintext: JSON.stringify(raw), + }); + const item = (kind: string, turnId = "one", extra = {}) => ({ + kind, + turnId, + channelId: "a", + sessionId: null, + seq: 1, + timestamp: new Date().toISOString(), + payload: {}, + ...extra, + }); + const send = (raw: unknown) => activity.receive(make(raw), generation()); + return { + activity, + release, + observe, + make, + send, + item, + generation, + snapshot: activity.queries.snapshot, + deny: () => { + denied = true; + activity.clear(); + }, + }; +} +afterEach(() => vi.useRealTimers()); +it("folds each mixed-turn batch child; resolution is not completion; null-session completion ends only its turn", () => { + const f = fixture(); + f.send(f.item("turn_started")); + f.send(f.item("session_resolved", "one", { sessionId: "S" })); + expect(f.snapshot().turns[0]?.state).toBe("working"); + f.send( + f.item("batch", "two", { + payload: { + events: [f.item("turn_completed"), f.item("turn_started", "two")], + }, + }), + ); + expect(f.snapshot().turns.map((turn) => [turn.turnId, turn.state])).toEqual([ + ["one", "ended"], + ["two", "working"], + ]); + f.send(f.item("turn_liveness", "one", { seq: 0 })); + expect(f.snapshot().turns[0]?.state).toBe("ended"); + f.send(f.item("acp_read", "three", { channelId: "b", seq: 0 })); + expect(f.snapshot().turns[2]?.state).toBe("working"); + f.release(); + expect(vi.getTimerCount()).toBe(0); +}); +it("late attachment learns from liveness; silence and disconnect mean unknown, not ended; future/unknown data stays raw", async () => { + const f = fixture(); + f.send(f.item("turn_liveness")); + await vi.advanceTimersByTimeAsync(31000); + expect(f.snapshot().turns[0]?.state).toBe("unknown"); + f.send(f.item("turn_liveness")); + expect(f.snapshot().turns[0]?.state).toBe("working"); + f.activity.state({ status: "retrying", routes: [] }); + f.activity.state(connected); + expect(f.snapshot().turns[0]?.state).toBe("unknown"); + f.send(f.item("mystery", "new")); + f.send( + f.item("turn_started", "future", { + timestamp: new Date(Date.now() + 60000).toISOString(), + }), + ); + expect(f.snapshot().turns).toHaveLength(1); + expect(f.snapshot().records).toHaveLength(4); + f.activity.dispose(); +}); +it("clears on disable/access/cache/dispose and fences late frames by generation; batches cannot leak denied children", () => { + const f = fixture(); + const initial = f.generation(); + f.send(f.item("acp_read")); + f.activity.clear(); + f.activity.receive(f.make(f.item("acp_read")), initial); + expect(f.snapshot().records).toHaveLength(0); + f.send(f.item("acp_read")); + expect(f.snapshot().records).toHaveLength(1); + f.deny(); + f.send( + f.item("batch", "two", { + channelId: "b", + payload: { events: [f.item("acp_read")] }, + }), + ); + expect(f.snapshot().records).toHaveLength(0); + f.release(); + expect(f.snapshot().status).toBe("disabled"); + f.activity.receive(f.make(f.item("acp_read")), f.generation()); + expect(f.snapshot().records).toHaveLength(0); + f.activity.dispose(); + expect(f.snapshot().status).toBe("unavailable"); +}); +it("keeps terminal evidence monotonic across clock rollback, eviction and delayed liveness", () => { + const f = fixture(); + const liveness = f.item("turn_liveness", "retired", { + timestamp: new Date(Date.now() - 5000).toISOString(), + }); + f.send(liveness); + f.send( + f.item("turn_completed", "retired", { + timestamp: new Date(Date.now() - 15000).toISOString(), + }), + ); + expect(f.snapshot().turns[0]?.state).toBe("ended"); + for (let i = 0; i < ACTIVITY_TURN_LIMIT; i++) + f.send( + f.item("turn_liveness", String(i), { + timestamp: new Date(Date.now() - 10000).toISOString(), + }), + ); + f.send(liveness); + expect( + f.snapshot().turns.find((turn) => turn.turnId === "retired")?.state, + ).toBe("ended"); + // Newer evidence eventually evicts the terminal entry, but not its watermark. + for (let i = 0; i < ACTIVITY_TURN_LIMIT; i++) + f.send(f.item("turn_liveness", `new-${i}`)); + expect(f.snapshot().turns.some((turn) => turn.turnId === "retired")).toBe( + false, + ); + f.send(liveness); + expect(f.snapshot().turns.some((turn) => turn.turnId === "retired")).toBe( + false, + ); + f.activity.dispose(); +}); +it("bounds records, UTF-8 bytes, turn state and immutable snapshots; dedups IDs, not process-local sequence", () => { + const f = fixture(); + const first = f.make(f.item("acp_read")); + f.activity.receive(first, f.generation()); + const old = f.snapshot(); + f.activity.receive(first, f.generation()); + expect(f.snapshot()).toBe(old); + for (let i = 0; i < ACTIVITY_RECORD_LIMIT + 1; i++) + f.send(f.item("unknown", String(i))); + expect(f.snapshot().records).toHaveLength(ACTIVITY_RECORD_LIMIT); + expect(old.records).toHaveLength(1); + for (let i = 0; i < 40; i++) + f.send(f.item("unknown", String(i), { payload: "é".repeat(30000) })); + expect( + f + .snapshot() + .records.reduce( + (size, row) => size + new TextEncoder().encode(row.plaintext).length, + 0, + ), + ).toBeLessThanOrEqual(ACTIVITY_BYTE_LIMIT); + f.activity.clear(); + for (let i = 0; i < ACTIVITY_TURN_LIMIT + 10; i++) { + vi.setSystemTime(Date.now() + 1); + f.send(f.item("turn_completed", String(i))); + } + expect(f.snapshot().turns).toHaveLength(ACTIVITY_TURN_LIMIT); + expect(f.snapshot().trimmed).toBeGreaterThan(0); + f.send( + f.item("acp_read", "0", { + timestamp: new Date(1800000000000).toISOString(), + }), + ); + expect(f.snapshot().turns.some((turn) => turn.turnId === "0")).toBe(false); + f.activity.dispose(); +}); + +it("indexes every recognized channel in a raw batch without rewriting or assigning unscoped records", () => { + const f = fixture(); + const raw = f.item("batch", "last", { + channelId: "b", + payload: { + events: [ + f.item("acp_read", "first"), + f.item("acp_read", "last", { channelId: "b" }), + ], + }, + }); + f.send(raw); + f.send(f.item("acp_read", "unscoped", { channelId: null })); + expect(f.snapshot().records[0]?.channelIds).toEqual(["b", "a"]); + expect(f.snapshot().records[0]?.plaintext).toBe(JSON.stringify(raw)); + expect(Object.isFrozen(f.snapshot().records[0]?.channelIds)).toBe(true); + expect(f.snapshot().records[1]?.channelIds).toEqual([]); + f.activity.dispose(); +}); diff --git a/src/features/agents/activity.ts b/src/features/agents/activity.ts new file mode 100644 index 00000000..6105727d --- /dev/null +++ b/src/features/agents/activity.ts @@ -0,0 +1,274 @@ +import type { LiveSnapshot } from "../relay/live"; +import { observerFrame, type ObserverFrame } from "./observer"; + +export const ACTIVITY_RECORD_LIMIT = 200; +export const ACTIVITY_BYTE_LIMIT = 2 * 1024 * 1024; +export const ACTIVITY_TURN_LIMIT = 512; +export const ACTIVITY_FRESH_MS = 30_000; +type RawRecord = ObserverFrame & + Readonly<{ + receivedAt: number; + kind: string; + /** Recognized envelope/child channel metadata; plaintext stays unmodified. */ + channelIds: readonly string[]; + }>; +export type ActivityTurn = Readonly<{ + agent: string; + turnId: string; + channelId: string | null; + timestamp: number; + state: "working" | "unknown" | "ended"; +}>; +type Snapshot = Readonly<{ + status: + | "unavailable" + | "disabled" + | "connecting" + | "listening" + | "interrupted"; + records: readonly RawRecord[]; + turns: readonly ActivityTurn[]; + trimmed: number; +}>; +type Turn = Omit & { ended: boolean; epoch: number }; +const object = (value: unknown): Record | undefined => + value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +const text = (value: unknown): value is string => + typeof value === "string" && value.length > 0 && value.length <= 256; +const starts = new Set([ + "turn_started", + "turn_liveness", + "acp_read", + "acp_write", + "session_resolved", +]); +const ends = new Set(["turn_completed", "turn_error", "agent_panic"]); + +/** Session-owned RAM only. Plugin activation owns demand, not sockets or keys. */ +export function createAgentActivity( + available: boolean, + observe: (generation: number | null) => void, + canAccess: (channel: string) => boolean, + notify = (listener: () => void) => listener(), +) { + let closed = false, + leases = 0, + generation = 0, + epoch = 0; + let status: Snapshot["status"] = available ? "disabled" : "unavailable"; + let records: RawRecord[] = [], + bytes = 0, + trimmed = 0, + evidenceFloor = 0; + let timer: ReturnType | undefined; + const turns = new Map(); + const listeners = new Set<() => void>(); + let snapshot: Snapshot = Object.freeze({ + status, + records: [], + turns: [], + trimmed, + }); + function publish() { + const now = Date.now(); + const visible = [...turns.values()].map( + (turn): ActivityTurn => + Object.freeze({ + agent: turn.agent, + turnId: turn.turnId, + channelId: turn.channelId, + timestamp: turn.timestamp, + state: turn.ended + ? "ended" + : status === "listening" && + turn.epoch === epoch && + now - turn.timestamp <= ACTIVITY_FRESH_MS && + turn.timestamp <= now + 5000 + ? "working" + : "unknown", + }), + ); + if ( + snapshot.status === status && + snapshot.records === records && + snapshot.trimmed === trimmed && + JSON.stringify(snapshot.turns) === JSON.stringify(visible) + ) + return; + snapshot = Object.freeze({ + status, + records: Object.freeze(records), + turns: Object.freeze(visible), + trimmed, + }); + for (const listener of listeners) notify(listener); + } + function reset() { + records = []; + bytes = 0; + trimmed = 0; + evidenceFloor = 0; + turns.clear(); + epoch++; + } + function restart() { + generation++; + reset(); + if (available && !closed && leases) { + status = "connecting"; + observe(generation); + } else status = closed || !available ? "unavailable" : "disabled"; + publish(); + } + function fold(agent: string, value: unknown) { + const item = object(value); + if ( + !item || + !text(item.kind) || + !text(item.turnId) || + !(item.channelId === null || text(item.channelId)) || + typeof item.timestamp !== "string" + ) + return; + if (item.channelId && !canAccess(item.channelId)) return; + if (!starts.has(item.kind) && !ends.has(item.kind)) return; + const timestamp = Date.parse(item.timestamp); + if (!Number.isFinite(timestamp) || timestamp > Date.now() + 5000) return; + const key = `${agent}:${item.turnId}`; + const previous = turns.get(key); + // Terminal evidence wins even when a delayed earlier heartbeat arrives later. + if ( + previous?.ended || + (previous && previous.timestamp > timestamp && !ends.has(item.kind)) + ) + return; + if (!previous && timestamp <= evidenceFloor) return; + turns.set(key, { + agent, + turnId: item.turnId, + channelId: item.channelId, + // Completion may precede liveness on a rolled-back producer clock. Keep + // the highest observed time so eventual eviction cannot weaken the fence. + timestamp: Math.max(timestamp, previous?.timestamp ?? timestamp), + ended: ends.has(item.kind), + epoch, + }); + if (turns.size > ACTIVITY_TURN_LIMIT) { + const oldest = [...turns].sort( + (a, b) => a[1].timestamp - b[1].timestamp, + )[0]; + if (oldest) { + turns.delete(oldest[0]); + // Forgetting a tombstone must not let older evidence resurrect that turn. + evidenceFloor = Math.max(evidenceFloor, oldest[1].timestamp); + trimmed++; + } + } + } + return { + queries: Object.freeze({ + snapshot: () => snapshot, + subscribe(listener: () => void) { + if (closed) return () => {}; + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + activate() { + if (closed || !available) return () => {}; + if (++leases === 1) { + restart(); + timer = setInterval(publish, 1000); + } + let released = false; + return () => { + if (released || closed) return; + released = true; + if (--leases === 0) { + observe(null); + clearInterval(timer); + restart(); + } + }; + }, + }), + receive(input: ObserverFrame, current: number) { + if (closed || !available || !leases || current !== generation) return; + let frame: ObserverFrame, raw: unknown; + try { + frame = observerFrame(input); + raw = JSON.parse(frame.plaintext); + } catch { + return; + } + if (records.some((record) => record.id === frame.id)) return; + const envelope = object(raw); + const children = + envelope?.kind === "batch" + ? object(envelope.payload)?.events + : undefined; + const items = Array.isArray(children) ? children : [raw]; + // A denied child cannot leak through an otherwise visible raw batch. + if ( + [raw, ...items].some((value) => { + const item = object(value); + return text(item?.channelId) && !canAccess(item.channelId); + }) + ) + return; + for (const item of items) fold(frame.agent, item); + const record = Object.freeze({ + ...frame, + receivedAt: Date.now(), + kind: text(envelope?.kind) ? envelope.kind : "unknown", + channelIds: Object.freeze([ + ...new Set( + [raw, ...items].flatMap((value) => { + const channelId = object(value)?.channelId; + return text(channelId) ? [channelId] : []; + }), + ), + ]), + }); + records = [...records, record]; + bytes += new TextEncoder().encode(frame.plaintext).length; + while ( + records.length > ACTIVITY_RECORD_LIMIT || + bytes > ACTIVITY_BYTE_LIMIT + ) { + const first = records.shift(); + if (first) bytes -= new TextEncoder().encode(first.plaintext).length; + trimmed++; + } + publish(); + }, + state(live: LiveSnapshot) { + if (closed || !available || !leases) return; + const route = live.routes.find((route) => route.id === "observer"); + const next = + live.status === "connected" && route?.status === "live" + ? "listening" + : live.status === "error" || + live.status === "retrying" || + route?.status === "error" + ? "interrupted" + : "connecting"; + if (status === "listening" && next !== status) epoch++; + status = next; + publish(); + }, + clear: restart, + dispose() { + if (closed) return; + closed = true; + leases = 0; + observe(null); + clearInterval(timer); + restart(); + listeners.clear(); + }, + }; +} diff --git a/src/features/agents/observer.ts b/src/features/agents/observer.ts new file mode 100644 index 00000000..118c11e6 --- /dev/null +++ b/src/features/agents/observer.ts @@ -0,0 +1,42 @@ +/** Host-projected telemetry. Never a signed RelayEvent or a general decrypt API. */ +export const OBSERVER_KIND = 24200; +export const OBSERVER_PLAINTEXT_BYTES = 65535; +export type ObserverFrame = Readonly<{ + id: string; + agent: string; + createdAt: number; + plaintext: string; +}>; +export function observerFrame(value: unknown): ObserverFrame { + const frame = value as ObserverFrame | null; + if ( + !frame || + typeof frame !== "object" || + Array.isArray(frame) || + typeof frame.id !== "string" || + typeof frame.agent !== "string" || + !/^[0-9a-f]{64}$/.test(frame.id) || + !/^[0-9a-f]{64}$/.test(frame.agent) || + !Number.isSafeInteger(frame.createdAt) || + frame.createdAt < 0 || + typeof frame.plaintext !== "string" || + new TextEncoder().encode(frame.plaintext).length > OBSERVER_PLAINTEXT_BYTES + ) + throw new Error("Invalid observer frame"); + // Validate JSON here; unknown fields and event kinds remain inert raw evidence. + JSON.parse(frame.plaintext); + return Object.freeze({ + id: frame.id, + agent: frame.agent, + createdAt: frame.createdAt, + plaintext: frame.plaintext, + }); +} +export function observerGeneration(value: unknown): number | null { + if ( + value === null || + (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) + ) + return value; + throw new Error("Invalid observer generation"); +} diff --git a/src/features/panels/service.ts b/src/features/panels/service.ts index bb78e542..02456111 100644 --- a/src/features/panels/service.ts +++ b/src/features/panels/service.ts @@ -6,6 +6,13 @@ import { type Contribution, } from "../../plugins/contributions"; +export type PanelContext = Readonly<{ + /** Originating conversation, not a claim of thread-level scope or access. */ + channelId: string; + canOpen(target: string): boolean; + /** Replace this panel through its host. False after this opening is retired. */ + open(target: string): boolean; +}>; /** Public presentation context, not authentication or a live selection service. */ export type ChannelPanelContext = Readonly<{ scope: string; @@ -28,6 +35,7 @@ export type PanelProps = { channelContext?: ChannelPanelContext | undefined; close(): void; target: string; + context?: PanelContext | undefined; }; export type Panel = Readonly<{ id: string; diff --git a/src/features/relay/broker-live.test.ts b/src/features/relay/broker-live.test.ts index 5fd2440f..a0293ae7 100644 --- a/src/features/relay/broker-live.test.ts +++ b/src/features/relay/broker-live.test.ts @@ -38,7 +38,7 @@ function fixture() { live: true, }), ); - if (url.endsWith("/stream-retry")) { + if (url.endsWith("/stream-retry") || url.endsWith("/stream-observer")) { const d = deferred(); controls.push(d); signals.push(init.signal as AbortSignal); @@ -67,10 +67,13 @@ function fixture() { }), ); } - function publish(index: number) { + function publish( + index: number, + snapshot: unknown = { status: "connected", routes: [] }, + ) { required(bodyControllers[index]).enqueue( new TextEncoder().encode( - 'event: state\ndata: {"status":"connected","routes":[]}\n\n', + `event: state\ndata: ${JSON.stringify(snapshot)}\n\n`, ), ); } @@ -91,6 +94,33 @@ function fixture() { }, }; } +it("rejects status snapshots beyond channel interests plus both globals and observer", async () => { + vi.useFakeTimers(); + const f = fixture(); + const t = await connectBrokerTransport(); + const owner = required(t.subscribe)(f.callbacks); + try { + f.accept(0); + await tick(); + f.publish(0, { + status: "connected", + routes: Array.from({ length: 1028 }, (_, i) => ({ + id: `route-${i}`, + status: "pending", + replay: "unknown", + })), + }); + await tick(); + expect(f.snapshots.at(-1)).toEqual({ + status: "retrying", + routes: [], + error: "Invalid live broker status", + }); + } finally { + owner.dispose(); + } +}); + it("pre-header clicks preserve in-progress POST; duplicate controls coalesce", async () => { vi.useFakeTimers(); const f = fixture(); @@ -161,3 +191,39 @@ for (const finish of ["replacement", "dispose"] as const) owner.dispose(); } }); + +it("late observer 404 from a retired stream cannot interrupt its replacement", async () => { + vi.useFakeTimers(); + const f = fixture(); + const t = await connectBrokerTransport(); + const owner = required(t.subscribe)(f.callbacks); + try { + f.accept(0); + await tick(); + f.publish(0); + await tick(); + required(owner.observe)(1); + await tick(); + expect(f.controls).toHaveLength(1); + owner.update(["a"]); + f.accept(1); + await tick(); + f.publish(1); + await tick(); + expect(required(f.signals[0]).aborted).toBe(true); + const before = f.snapshots.length; + required(f.controls[0]).resolve(new Response(null, { status: 404 })); + await tick(); + await vi.advanceTimersByTimeAsync(1000); + expect(f.snapshots).toHaveLength(before); + expect(f.headers).toHaveLength(2); + required(owner.observe)(2); + await tick(); + expect(f.controls).toHaveLength(2); + expect(required(f.signals[1]).aborted).toBe(false); + required(f.controls[1]).resolve(new Response(null, { status: 200 })); + await tick(); + } finally { + owner.dispose(); + } +}); diff --git a/src/features/relay/broker-live.ts b/src/features/relay/broker-live.ts index cac64c77..29441b9c 100644 --- a/src/features/relay/broker-live.ts +++ b/src/features/relay/broker-live.ts @@ -1,3 +1,4 @@ +import { observerFrame, observerGeneration } from "../agents/observer"; import { eventDto } from "./events"; import { liveChannels, @@ -19,6 +20,8 @@ export function subscribeBrokerTraffic( let channels: string[] = []; let priority: string[] = []; let priorityPending = false; + let observer: number | null = null; + let observerPending = false; let controller: AbortController | undefined; let streamId: string | undefined; let controlPending = false; @@ -38,6 +41,7 @@ export function subscribeBrokerTraffic( streamId = undefined; controlPending = false; priorityPending = false; + observerPending = false; receiving = true; controller?.abort(); clearTimeout(retryTimer); @@ -56,13 +60,14 @@ export function subscribeBrokerTraffic( }; pulse(); const startingPriority = JSON.stringify(priority); + const startingObserver = observer; void (async () => { try { const response = await fetch(`${endpoint}/stream`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ channels, priority }), + body: JSON.stringify({ channels, priority, observer }), signal: owned.signal, }); if (!valid()) return; @@ -85,6 +90,7 @@ export function subscribeBrokerTraffic( throw new Error("Invalid live broker control identity"); streamId = identity ?? undefined; if (startingPriority !== JSON.stringify(priority)) sendPriority(); + if (startingObserver !== observer) sendObserver(); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; @@ -114,7 +120,14 @@ export function subscribeBrokerTraffic( const data: unknown = JSON.parse(lines.join("\n")); if (!valid()) return; if (kind === "message") callbacks.receive([eventDto(data)]); - else if (kind === "state") { + else if (kind === "observer") { + const record = data as { + frame?: unknown; + generation?: unknown; + }; + if (observer !== null && record.generation === observer) + callbacks.observer?.(observerFrame(record.frame), observer); + } else if (kind === "state") { const snapshot = liveSnapshot(data); publish(snapshot); } else if (kind === "established") { @@ -190,8 +203,46 @@ export function subscribeBrokerTraffic( if (sent !== JSON.stringify(priority)) sendPriority(); }); } + function sendObserver() { + if (closed || !streamId || observerPending) return; + const current = generation; + const sent = observer; + observerPending = true; + void fetch(`${endpoint}/stream-observer`, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ streamId, observer: sent }), + signal: AbortSignal.any([ + controller?.signal ?? new AbortController().signal, + AbortSignal.timeout(5000), + ]), + }) + .then((response) => { + if (!response.ok) + throw new Error("Activity subscription control failed"); + }) + .catch(() => { + if (!closed && current === generation) { + // Unknown control outcome: fence this stream; normal bounded reconnect + // will capture the latest desired generation (never reset chat on toggle). + controller?.abort(new Error("Activity subscription interrupted")); + } + }) + .finally(() => { + if (current !== generation) return; + observerPending = false; + if (sent !== observer) sendObserver(); + }); + } start(); return { + observe(value) { + const next = observerGeneration(value); + if (closed || observer === next) return; + observer = next; // Fence old SSE frames synchronously, before the POST completes. + sendObserver(); + }, prioritize(input) { liveChannels(input); const next = [...new Set(input)].slice(0, 64); @@ -268,7 +319,8 @@ function liveSnapshot(value: unknown): LiveSnapshot { snapshot.status, ) || !Array.isArray(snapshot.routes) || - snapshot.routes.length > 1026 || + // Keep limited channels visible alongside both globals and the optional observer. + snapshot.routes.length > 1027 || (snapshot.error !== undefined && typeof snapshot.error !== "string") ) throw new Error("Invalid live broker status"); diff --git a/src/features/relay/live.test.ts b/src/features/relay/live.test.ts index ff1520df..ce1621a1 100644 --- a/src/features/relay/live.test.ts +++ b/src/features/relay/live.test.ts @@ -574,3 +574,54 @@ it("requests community emoji on the existing profile route and delivers verified expect(h.callbacks.receive).toHaveBeenCalledWith([event]); h.owner.dispose(); }); + +it("observer route is optional, live-only at dispatch/retry, separately fenced and never ordinary replay", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1800000000000); + const h = setup([]); + const telemetry = vi.fn(); + Object.assign(h.callbacks, { telemetry }); + await h.first.auth(); + await vi.advanceTimersByTimeAsync(500); + const globals = h.first.requests().map((request) => request[1]); + h.owner.observe?.(1); + await vi.advanceTimersByTimeAsync(250); + const first = h.first.requests().at(-1); + assert.exists(first); + expect(first[2]).toEqual({ + kinds: [24200], + "#p": [h.key.pubkey], + since: Math.floor(Date.now() / 1000), + }); + const event = signed(h.key, { + kind: 24200, + content: "opaque", + tags: [], + created_at: Math.floor(Date.now() / 1000), + }); + await h.first.receive(["EVENT", first[1], event]); + expect(telemetry).toHaveBeenCalledWith(event, 1); + expect(h.callbacks.receive).not.toHaveBeenCalled(); + await h.first.receive(["EOSE", first[1]]); + expect(h.callbacks.established).not.toHaveBeenCalled(); + await h.first.receive(["CLOSED", first[1], "temporary: unavailable"]); + await vi.advanceTimersByTimeAsync(3000); + h.owner.retry(); + const retried = h.first.requests().at(-1); + assert.exists(retried); + expect(retried[2].since).toBeGreaterThan(first[2].since); + h.owner.observe?.(2); + await vi.advanceTimersByTimeAsync(250); + await h.first.receive(["EVENT", first[1], event]); + await h.first.receive(["EVENT", retried[1], event]); + expect(telemetry).toHaveBeenCalledTimes(1); + h.owner.observe?.(null); + expect( + h.first.sent + .filter((entry) => entry[0] === "CLOSE") + .some((entry) => globals.includes(entry[1] as string)), + ).toBe(false); + expect(h.sockets).toHaveLength(1); + h.owner.dispose(); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/src/features/relay/live.ts b/src/features/relay/live.ts index e4a6c2ab..49f67fbc 100644 --- a/src/features/relay/live.ts +++ b/src/features/relay/live.ts @@ -1,3 +1,8 @@ +import { + OBSERVER_KIND, + observerGeneration, + type ObserverFrame, +} from "../agents/observer.ts"; import type { EventTemplate, VerifiedEvent } from "nostr-tools"; import { eventDto } from "./events.ts"; import { EMOJI_SET } from "./emoji.ts"; @@ -40,6 +45,10 @@ export type LiveSnapshot = Readonly<{ }>; export type LiveCallbacks = { receive(events: readonly VerifiedEvent[]): void; + /** Host-only encrypted telemetry route; never ordinary history reconciliation. */ + telemetry?(event: VerifiedEvent, generation: number): void; + /** Decoded host DTO on the browser transport. */ + observer?(frame: ObserverFrame, generation: number): void; state(snapshot: LiveSnapshot): void; established(channelId?: string): void; denied(channelId: string, reason: string): void; @@ -48,6 +57,7 @@ export type LiveSubscription = { update(channels: readonly string[]): void; /** Host demand only: reorder existing pending routes, never grant new interests. */ prioritize?(channels: readonly string[]): void; + observe?(generation: number | null): void; retry(): void; dispose(): void; }; @@ -102,6 +112,7 @@ export function subscribeRelayTraffic( let connectionError: string | undefined; let interests: string[] = []; let priority: string[] = []; + let observer: number | null = null; const routes = new Map(); const wires = new Map(); const notify = () => { @@ -139,6 +150,7 @@ export function subscribeRelayTraffic( const wanted = new Set([ "profiles", "membership", + ...(observer !== null ? ["observer"] : []), ...interests.map((id) => `channel:${id}`), ]); for (const route of routes.values()) @@ -162,7 +174,9 @@ export function subscribeRelayTraffic( ...interests, ]), ]; - const admitted = new Set(ranked.slice(0, LIVE_CHANNEL_CAPACITY)); + const admitted = new Set( + ranked.slice(0, LIVE_CHANNEL_CAPACITY - (observer !== null ? 1 : 0)), + ); for (const route of routes.values()) if (route.channelId) { if (!admitted.has(route.channelId)) { @@ -257,18 +271,22 @@ export function subscribeRelayTraffic( fail(route, "Live subscription setup timed out; retry available"); }, 10000); active++; + if (route.id === "observer") route.since = Math.floor(Date.now() / 1000); const scope = route.channelId ? { kinds: CHANNEL_KINDS, "#h": [route.channelId] } : route.id === "profiles" ? { kinds: [0] } - : { kinds: [44100, 44101], "#p": [viewer] }; + : route.id === "observer" + ? { kinds: [OBSERVER_KIND], "#p": [viewer] } + : { kinds: [44100, 44101], "#p": [viewer] }; send([ "REQ", wire, { ...scope, + // Live-only on every actual dispatch, including paced retries. since: route.since, - limit: LIVE_REPLAY_LIMIT, + ...(route.id === "observer" ? {} : { limit: LIVE_REPLAY_LIMIT }), }, ...(route.id === "membership" ? [ @@ -416,7 +434,15 @@ export function subscribeRelayTraffic( return; } if (route.status === "pending") route.count++; - callbacks.receive([incoming]); + if (route.id === "observer") { + if ( + observer !== null && + incoming.kind === OBSERVER_KIND && + incoming.created_at >= route.since + ) + callbacks.telemetry?.(incoming, observer); + } else if (incoming.kind !== OBSERVER_KIND) + callbacks.receive([incoming]); } else if (data[0] === "EOSE" && route.status === "pending") { clearTimeout(route.deadline); route.status = "live"; @@ -424,7 +450,7 @@ export function subscribeRelayTraffic( route.replay = route.count >= LIVE_REPLAY_LIMIT ? "limited" : "unknown"; notify(); if (!valid() || wires.get(route.wire ?? "") !== route) return; - callbacks.established(route.channelId); + if (route.id !== "observer") callbacks.established(route.channelId); if (valid()) pump(); } else if (data[0] === "CLOSED") { fail( @@ -440,6 +466,14 @@ export function subscribeRelayTraffic( } connect(); return { + observe(value) { + const next = observerGeneration(value); + if (closed || observer === next) return; + observer = next; + const route = routes.get("observer"); + if (route) remove(route); // Fence the old wire before enabling a new generation. + sync(); + }, prioritize(input) { liveChannels(input); // Same bounded ID validation, but preserve demand order. priority = [...new Set(input)].slice(0, 64); diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index abbac089..f17b3f68 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -4,6 +4,8 @@ import { type ReadOptions, type RelayReader, } from "./reader"; +import { createAgentActivity } from "../agents/activity"; +import { OBSERVER_KIND } from "../agents/observer"; import { createAgentLibrary } from "../agents/library"; import { createIdentityArchives } from "./identity-archives"; import { @@ -170,6 +172,7 @@ export function createRelaySession( profiles.clear(); emoji.clear(); agentLibrary.clear(); + activity.clear(); archives.clear(); for (const purge of views.values()) purge(); commit(); @@ -234,7 +237,9 @@ export function createRelaySession( events.some((event) => [39000, 39002].includes(event.kind)) ) channels.acceptDiscovery(events); - const visible = events.filter(visibility(events)); + const visible = events + .filter((event) => event.kind !== OBSERVER_KIND) + .filter(visibility(events)); const epoch = accessEpoch; profiling.measure( "events.reconcile", @@ -287,6 +292,12 @@ export function createRelaySession( const profiles = createProfileDirectory(verified, localViews, notify); const emoji = createEmojiDirectory(verified, notify); const agentLibrary = createAgentLibrary(transport?.readAgentLibrary, notify); + const activity = createAgentActivity( + !!transport?.agentActivity && !!transport.subscribe, + (generation) => traffic?.observe?.(generation), + (channel) => canAccess(channel), + notify, + ); const archives = createIdentityArchives( requests.reader, transport?.archiveAuthority, @@ -614,6 +625,7 @@ export function createRelaySession( profiles: profiles.queries, emoji: emoji.queries, agentLibrary: agentLibrary.queries, + agentActivity: activity.queries, archives: archives.queries, media: (url: string) => transport?.media(url), /** A plugin may request writes from this same interface when the host supports them. */ @@ -866,6 +878,7 @@ export function createRelaySession( } } traffic = transport?.subscribe?.({ + observer: (frame, generation) => activity.receive(frame, generation), receive(events) { if (closed) return; // Signed membership notifications are hints, not roster authority. Schedule @@ -885,6 +898,7 @@ export function createRelaySession( }, state(snapshot) { if (closed) return; + activity.state(snapshot); if ( snapshot.status !== "connected" && liveSnapshot.status === "connected" @@ -947,6 +961,7 @@ export function createRelaySession( async clearCache() { accessEpoch++; cacheClearEpoch++; + activity.clear(); sidebarPreferences.clear(); // New windows must not yield to or receive errors from retired owners. catchups.clear(); @@ -965,6 +980,7 @@ export function createRelaySession( dispose() { closed = true; lifetime.abort(); + activity.dispose(); sidebarPreferences.dispose(); stopInterests(); traffic?.dispose(); diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts index d7b0738a..fb80d9f1 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -34,6 +34,8 @@ export interface RelayWriter { publish(event: RelayEvent, signal: AbortSignal): Promise; } export interface ReadTransport { + /** Purpose-bound observer decoding on the shared host live stream. */ + readonly agentActivity?: boolean; /** Host-projected local library; display only, never relay authority. */ readonly readAgentLibrary?: AgentLibraryReader; /** Host-only decoder of the viewer's two signed sidebar preference coordinates. */ @@ -145,6 +147,7 @@ export async function connectBrokerTransport( live?: boolean; sidebarPreferences?: boolean; agentLibrary?: boolean; + agentActivity?: boolean; readState?: boolean; readStateCommunity?: string; }; @@ -162,6 +165,7 @@ export async function connectBrokerTransport( ); return { profiling, + agentActivity: session.agentActivity === true && session.live === true, ...(session.live ? { subscribe: (callbacks: LiveCallbacks) => diff --git a/tests/browser/agent-activity.spec.mjs b/tests/browser/agent-activity.spec.mjs new file mode 100644 index 00000000..c2b67716 --- /dev/null +++ b/tests/browser/agent-activity.spec.mjs @@ -0,0 +1,229 @@ +import { test, expect } from "./fixture.mjs"; +import { open } from "./timeline.mjs"; +import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools"; +test.use({ productionBroker: true, developmentReact: true }); + +test("activity launcher consumes real encrypted telemetry, escapes raw text, selects agents, and releases on disable", async ({ + page, + app, +}) => { + await page.goto(app.origin); + const launcher = page.getByRole("button", { + name: "Agent Activity", + exact: true, + }); + await expect(launcher).toBeVisible(); + await expect.poll(() => app.relay.hasRoute("primary", "observer")).toBe(true); + await launcher.click(); + const panel = page.getByRole("region", { + name: "Agent activity", + exact: true, + }); + await expect(panel.getByText(/Waiting for live records/)).toBeVisible(); + const first = generateSecretKey(), + second = generateSecretKey(); + const raw = { + kind: "turn_liveness", + seq: 1, + timestamp: new Date().toISOString(), + channelId: "alpha", + sessionId: "S", + turnId: "one", + payload: { text: '' }, + }; + const result = app.observer(raw, first); + const disclosure = panel.getByRole("button", { name: /turn_liveness/ }); + await expect(disclosure).toBeVisible(); + await expect(panel.getByText("1 observed working turn(s).")).toBeVisible(); + await disclosure.focus(); + await disclosure.press("Enter"); + await expect(panel.locator("pre code")).toHaveText(result.plaintext); + expect(await page.evaluate(() => window.telemetryExecuted)).toBeUndefined(); + await disclosure.press("Space"); + await expect(panel.locator("pre code")).not.toBeVisible(); + app.observer({ ...raw, kind: "turn_completed", sessionId: null }, first); + await expect(panel.getByText("No fresh working evidence.")).toBeVisible(); + app.observer({ ...raw, turnId: "two", kind: "acp_read" }, second); + await panel.getByRole("combobox", { name: "Agent" }).click(); + await page + .getByRole("option", { + name: new RegExp(getPublicKey(second).slice(0, 12)), + }) + .click(); + await expect(panel.getByRole("button", { name: /acp_read/ })).toBeVisible(); + await expect( + panel.getByRole("button", { name: /turn_completed/ }), + ).toHaveCount(0); + const sockets = app.relay.sockets.length; + await launcher.click(); + await launcher.click(); + await expect( + panel.getByRole("button", { name: /turn_liveness/ }), + ).toBeVisible(); + await page.getByRole("button", { name: "Your profile", exact: true }).click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("button", { name: "Plugins", exact: true }).click(); + const toggle = page.getByRole("switch", { + name: "Enable Agent Activity", + exact: true, + }); + await toggle.click(); + await expect(launcher).toHaveCount(0); + await expect + .poll(() => app.relay.hasRoute("primary", "observer")) + .toBe(false); + expect(app.relay.sockets).toHaveLength(sockets); + await toggle.click(); + await expect(launcher).toBeVisible(); + await launcher.click(); + await expect(panel.getByText(/Waiting for live records/)).toBeVisible(); +}); + +for (const mode of ["light", "dark"]) { + test(`activity raw disclosure fits wide and narrow layouts in ${mode}`, async ({ + page, + app, + }, testInfo) => { + await page.addInitScript((mode) => { + localStorage.setItem("buzz-appearance.v1", mode); + }, mode); + await page.goto(app.origin); + await page + .getByRole("button", { name: "Agent Activity", exact: true }) + .click(); + await expect + .poll(() => app.relay.hasRoute("primary", "observer")) + .toBe(true); + app.observer( + { + kind: "acp_read", + turnId: "layout", + channelId: "alpha", + sessionId: null, + timestamp: new Date().toISOString(), + payload: { text: "A long literal raw record. ".repeat(40) }, + }, + generateSecretKey(), + ); + const panel = page.getByRole("region", { + name: "Agent activity", + exact: true, + }); + await panel.getByRole("button", { name: /acp_read/ }).click(); + for (const width of [1280, 390]) { + await page.setViewportSize({ width, height: 844 }); + await expect(panel.locator("pre code")).toBeVisible(); + await expect(page.locator("html")).toHaveAttribute( + "data-color-mode", + mode, + ); + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBe(width); + await page.screenshot({ + path: testInfo.outputPath(`activity-${mode}-${width}.png`), + }); + } + }); +} + +test("profile activity opens the exact agent and originating channel before its first frame", async ({ + page, + app, +}) => { + await open(page, app); + await expect.poll(() => app.relay.hasRoute("primary", "observer")).toBe(true); + const agentKey = generateSecretKey(); + const agent = getPublicKey(agentKey); + const message = finalizeEvent( + { + kind: 9, + tags: [["h", "alpha"]], + content: "Contextual agent entry", + created_at: Math.floor(Date.now() / 1000), + }, + agentKey, + ); + app.relay.publish("primary", message); + const avatar = page + .locator(`[data-message-id="${message.id}"]`) + .getByRole("button", { name: /profile/ }); + await avatar.click(); + const profile = page.getByRole("complementary", { + name: "Profile", + exact: true, + }); + await profile + .getByRole("button", { name: "View activity", exact: true }) + .click(); + await expect(profile).toHaveCount(0); + const panel = page.getByRole("region", { + name: "Agent activity", + exact: true, + }); + await expect(panel.locator("code").first()).toHaveText(agent); + await expect( + panel.getByRole("combobox", { name: "Channel", exact: true }), + ).toHaveText(/Alpha.*alpha/); + await expect( + panel.getByText( + /Waiting for live records for this identity in this channel/, + ), + ).toBeVisible(); + const item = (kind, channelId, turnId) => ({ + kind, + channelId, + turnId, + sessionId: null, + timestamp: new Date().toISOString(), + }); + app.observer(item("acp_read", "alpha", "other-agent"), generateSecretKey()); + app.observer(item("acp_write", "beta", "other-channel"), agentKey); + app.observer(item("session_resolved", null, "unscoped"), agentKey); + const expected = app.observer( + item("turn_liveness", "alpha", "wanted"), + agentKey, + ); + const row = panel.getByRole("button", { name: /turn_liveness/ }); + await expect(row).toBeVisible(); + await expect(panel.getByText("1 observed working turn(s).")).toBeVisible(); + await expect( + panel.getByRole("button", { name: /acp_read|acp_write|session_resolved/ }), + ).toHaveCount(0); + await row.click(); + await expect(panel.locator("pre code")).toHaveText(expected.plaintext); + await panel.getByRole("combobox", { name: "Channel", exact: true }).click(); + await page + .getByRole("option", { + name: "All channels (including unscoped records)", + exact: true, + }) + .click(); + await expect(panel.getByRole("button", { name: /acp_write/ })).toBeVisible(); + await expect( + panel.getByRole("button", { name: /session_resolved/ }), + ).toBeVisible(); + await expect(panel.getByRole("button", { name: /acp_read/ })).toHaveCount(0); + await panel.press("Escape"); + await expect(avatar).toBeFocused(); + await avatar.click(); + await profile + .getByRole("button", { name: "View activity", exact: true }) + .click(); + await page.locator('[data-channel-id="beta"]').click(); + await expect(panel).toHaveCount(0); + // Disable removes both registration and profile affordance, not the profile itself. + await page.getByRole("button", { name: "Your profile", exact: true }).click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("button", { name: "Plugins", exact: true }).click(); + await page + .getByRole("switch", { name: "Enable Agent Activity", exact: true }) + .click(); + await page.getByRole("button", { name: "Messages", exact: true }).click(); + await page.locator('[data-channel-id="alpha"]').click(); + await avatar.click(); + await expect(profile).toBeVisible(); + await expect( + profile.getByRole("button", { name: "View activity", exact: true }), + ).toHaveCount(0); +}); diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 309ae0dc..dd983781 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -283,6 +283,9 @@ export const test = base.extend({ measurements: [], }; const pending = []; + const retiredStreams = new Set(); + const observerFailures = []; + const consoleLocations = new Map(); const send = (response, body, status = 200) => { response.writeHead(status, { "Content-Type": "application/json" }); response.end(JSON.stringify(body)); @@ -562,12 +565,16 @@ export const test = base.extend({ async configurePreviewServer(server) { if (relay) { report.brokerRequests = []; - server.middlewares.use((req, _res, next) => { + server.middlewares.use((req, res, next) => { if (req.url?.startsWith("/api/relay/")) report.brokerRequests.push({ url: req.url, at: performance.now(), }); + if (req.url?.endsWith("/stream")) + res.once("close", () => { + retiredStreams.add(res.getHeader("x-buzz-live-id")); + }); next(); }); const broker = relayBrokerPlugin({ @@ -606,8 +613,26 @@ export const test = base.extend({ }); page.on("pageerror", (error) => report.errors.push(error.message)); page.on("console", (message) => { - if (message.type() === "error") + if (message.type() === "error") { + consoleLocations.set( + report.consoleErrors.length, + message.location().url, + ); report.consoleErrors.push(message.text()); + } + }); + page.on("response", (response) => { + if ( + response.url().endsWith("/stream-observer") && + response.status() === 404 + ) { + const { streamId } = response.request().postDataJSON(); + observerFailures.push({ + streamId, + url: response.url(), + retired: retiredStreams.has(streamId), + }); + } }); await page.addInitScript( ({ viewer }) => { @@ -635,6 +660,26 @@ export const test = base.extend({ participants, viewer, relay, + observer(raw, agentKey, community = "primary") { + const agent = getPublicKey(agentKey); + const plaintext = JSON.stringify(raw); + const event = sign( + 24200, + [ + ["p", viewer], + ["agent", agent], + ["frame", "telemetry"], + ], + nip44.v2.encrypt( + plaintext, + nip44.v2.utils.getConversationKey(agentKey, viewer), + ), + agentKey, + Math.floor(Date.now() / 1000), + ); + relay.observer(community, event); + return { event, plaintext, agent }; + }, // Change only modeled relay state. The app must consume the next real // roster response; this does not call client purge/recovery internals. hideChannel(id) { @@ -708,9 +753,29 @@ export const test = base.extend({ }, }); expect(report.unexpected).toEqual([]); + // Aborted startup streams can race an already-dispatched observer control. + // Permit only 404s whose exact stream was already closed by the real host; + // a current/unknown stream failure still fails, and all errors stay recorded. + report.retiredObserverControls = [...observerFailures]; + expect(observerFailures.every((failure) => failure.retired)).toBe(true); + const retiredConsole = (message, index) => { + if ( + !/^Failed to load resource: the server responded with a status of 404/.test( + message, + ) + ) + return false; + const match = observerFailures.findIndex( + (failure) => failure.url === consoleLocations.get(index), + ); + if (match < 0) return false; + observerFailures.splice(match, 1); + return true; + }; expect( report.consoleErrors.filter( - (message) => + (message, index) => + !retiredConsole(message, index) && !( expectedPageFailure && message.includes("Fixture page render failure") diff --git a/tests/browser/layout.spec.mjs b/tests/browser/layout.spec.mjs index a841a51b..f34b0e9c 100644 --- a/tests/browser/layout.spec.mjs +++ b/tests/browser/layout.spec.mjs @@ -422,13 +422,46 @@ test("Bestie owns the launcher and the reusable companion card across pages and [800, 600], [480, 400], [390, 844], + [390, 400], ]) { await page.setViewportSize({ width, height }); await shellFits(page, width); await expect(button(page, "Close Bestie panel")).toBeInViewport(); } await button(page, "Close Bestie panel").click(); - await expect(enabled).toBeInViewport(); + await expect(bestie).toHaveCount(0); + await expect(launch).toHaveAttribute("aria-expanded", "false"); + await expect(launch).toBeFocused(); + + // Plugin catalogs can outgrow the viewport. Closing restores the launcher, + // not a Settings row: reach the toggle with real input, not scrollIntoView. + const settingsPage = page.getByRole("main").locator(".overflow-y-auto"); + await expect(enabled).not.toBeInViewport(); + const viewport = await box(settingsPage); + await page.mouse.move( + viewport.x + viewport.width / 2, + viewport.y + viewport.height / 2, + ); + for (let gesture = 0; gesture < 4; gesture++) { + const toggle = await box(enabled); + if ( + toggle.y >= viewport.y && + toggle.y + toggle.height <= viewport.y + viewport.height + ) + break; + const before = await settingsPage.evaluate((el) => el.scrollTop); + await page.mouse.wheel(0, viewport.height * 0.75); + await expect + .poll(() => settingsPage.evaluate((el) => el.scrollTop), { + message: "Settings wheel input makes progress toward the plugin toggle", + }) + .toBeGreaterThan(before); + } + await expect(enabled).toBeInViewport({ ratio: 1 }); + await enabled.click(); + await expect(enabled).toHaveAttribute("aria-checked", "false"); + await expect(enabled).toBeFocused(); + await expect(launch).toHaveCount(0); }); readingTest( diff --git a/tests/browser/live.spec.mjs b/tests/browser/live.spec.mjs index 0a78dc40..2108b5b4 100644 --- a/tests/browser/live.spec.mjs +++ b/tests/browser/live.spec.mjs @@ -171,8 +171,13 @@ test("Live retry recovers an empty paused roster without restarting healthy glob page.getByRole("button", { name: "Alpha", exact: true }), ).toHaveCount(0); const globals = () => - app.relay.requests.filter(({ filter }) => !filter["#h"]); + app.relay.requests.filter( + ({ filter }) => !filter["#h"] && !filter.kinds.includes(24200), + ); + const observer = () => + app.relay.requests.filter(({ filter }) => filter.kinds.includes(24200)); await expect.poll(() => globals().length).toBe(2); + await expect.poll(() => observer().length).toBe(1); const sockets = app.relay.sockets.length; const rosters = () => app.report.queries.filter(({ filter }) => filter.kinds?.includes(39002)); @@ -191,4 +196,10 @@ test("Live retry recovers an empty paused roster without restarting healthy glob expect(rosters()).toHaveLength(calls + 1); expect(app.relay.sockets).toHaveLength(sockets); expect(globals()).toHaveLength(2); + // The first authoritative (empty) roster resets activity's access generation. + // Only its live-only route is renewed; healthy chat globals stay untouched. + await expect.poll(() => observer().length).toBe(2); + expect(observer()[1].filter.since).toBeGreaterThanOrEqual( + observer()[0].filter.since, + ); }); diff --git a/tests/browser/policy-relay.mjs b/tests/browser/policy-relay.mjs index 8d296a40..537f25b6 100644 --- a/tests/browser/policy-relay.mjs +++ b/tests/browser/policy-relay.mjs @@ -36,7 +36,9 @@ export function policyRelay({ ? "profiles" : filter.kinds.includes(44100) ? "membership" - : undefined); + : filter.kinds.includes(24200) + ? "observer" + : undefined); report.wireFrames = []; let emptyRoster = false; let heldContent = false; @@ -310,6 +312,14 @@ export function policyRelay({ } if (filter.kinds.includes(44100)) expect(filter["#p"]).toEqual([viewer]); + if (filter.kinds.includes(24200)) { + expect(filter["#p"]).toEqual([viewer]); + expect(filter["#h"]).toBeUndefined(); + expect(filter.limit).toBeUndefined(); + expect(filter.since).toBeGreaterThanOrEqual( + Math.floor(Date.now() / 1000) - 1, + ); + } this.routes.set(id, filter); const route = routeOf(filter); if (heldEose.has(route)) @@ -337,6 +347,22 @@ export function policyRelay({ [...s.routes.values()].some((f) => routeOf(f) === channel), ); }, + observer(community, event) { + let deliveries = 0; + for (const socket of sockets) { + if (socket.readyState !== 1 || socket.community !== community) continue; + for (const [id, filter] of socket.routes) { + if (!filter.kinds.includes(24200) || !filter["#p"]?.includes(viewer)) + continue; + emit(socket, ["EVENT", id, event]); + deliveries++; + } + } + expect( + deliveries, + "observer must traverse the production owner-only route", + ).toBeGreaterThan(0); + }, publish(community, event) { let deliveries = 0; for (const socket of sockets) { diff --git a/tests/browser/profiles.spec.mjs b/tests/browser/profiles.spec.mjs index 81d434eb..8a9ea388 100644 --- a/tests/browser/profiles.spec.mjs +++ b/tests/browser/profiles.spec.mjs @@ -158,3 +158,75 @@ test("profile plumbing: exact avatar/mention targets, thread enrichment, lifecyc await server.close(); } }); + +test("contextual panel callbacks retire with opening, channel, contribution and session", async ({ + page, +}) => { + const server = await createServer({ + root: fileURLToPath(new URL("../../", import.meta.url)), + configFile: false, + envFile: false, + plugins: [react()], + logLevel: "error", + server: { host: "127.0.0.1", port: 0, strictPort: false }, + }); + await server.listen(); + try { + await page.goto( + `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/profiles.html?context-probe`, + ); + const avatar = page.getByRole("button", { + name: "View Viewer profile", + exact: true, + }); + const panel = page.getByRole("complementary", { + name: "Context probe", + exact: true, + }); + const capture = async () => { + await avatar.click(); + await expect(panel).toBeVisible(); + await page.evaluate(() => { + window.oldPanelContext = window.profilesFixture.contexts.at(-1); + }); + }; + const invoke = () => + page.evaluate(() => + window.oldPanelContext.open(window.profilesFixture.targets.mic), + ); + await capture(); + expect(await page.evaluate(() => window.oldPanelContext.channelId)).toBe( + "one", + ); + expect(await invoke()).toBe(true); + // A second synchronous use of the old opening must not replace its successor. + expect(await invoke()).toBe(false); + await panel.getByRole("button", { name: "Close channel panel" }).click(); + await capture(); + await page.locator('[data-channel-id="two"]').click(); + await expect(panel).toHaveCount(0); + expect(await invoke()).toBe(false); + await page.locator('[data-channel-id="one"]').click(); + await capture(); + await page.evaluate(() => + window.profilesFixture.change("disable", "context.probe"), + ); + expect(await invoke()).toBe(false); + await expect(panel).toHaveCount(0); + await page.evaluate(() => + window.profilesFixture.change("enable", "context.probe"), + ); + await expect(avatar).toBeVisible(); + expect(await invoke()).toBe(false); + await capture(); + await page.evaluate(() => window.profilesFixture.replace()); + expect(await invoke()).toBe(false); + await expect(panel).toHaveCount(0); + await capture(); + await page.evaluate(() => window.profilesFixture.disconnect()); + expect(await invoke()).toBe(false); + await expect(panel).toHaveCount(0); + } finally { + await server.close(); + } +}); diff --git a/tests/fixtures/profiles.tsx b/tests/fixtures/profiles.tsx index c8cb81ce..d5f6b18f 100644 --- a/tests/fixtures/profiles.tsx +++ b/tests/fixtures/profiles.tsx @@ -1,11 +1,15 @@ // Real ChannelsPage, thread reader, shared directory, panel registry and plugin lifecycle. // Only the transport is synthetic. No dev broker, saved identity or live relay. -import { StrictMode, useState } from "react"; +import { StrictMode, useLayoutEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import { Context } from "@deepseek-ai/cordis"; import { createPluginManager } from "../../src/plugins/manager"; import { bundledPlugins } from "../../src/bundled"; -import { PanelsService } from "../../src/features/panels/service"; +import { + PanelsService, + type PanelContext, + type PanelProps, +} from "../../src/features/panels/service"; import { ChannelsPage } from "../../src/bundled/channels/ChannelsPage"; import { createRelaySession } from "../../src/features/relay/session"; import type { @@ -122,15 +126,51 @@ const relay: RelayData = { }; const context = new Context(); context.provide("relay", relay); +const contexts: PanelContext[] = []; +function ContextProbe({ context }: PanelProps) { + useLayoutEffect(() => { + if (context && contexts.at(-1) !== context) contexts.push(context); + }, [context]); + return

Context probe

; +} +const probing = new URLSearchParams(location.search).has("context-probe"); const manager = createPluginManager(context, { - bundled: bundledPlugins.filter( - ({ manifest }) => manifest.id === "buzz.profiles", - ), + bundled: probing + ? [ + { + manifest: { + id: "context.probe", + name: "Context probe", + apiVersion: 1, + }, + module: { + inject: ["panels"], + apply(ctx) { + ctx.panels.register({ + id: "probe", + title: "Context probe", + matches: (target) => target.startsWith("nostr:"), + component: ContextProbe, + }); + }, + }, + }, + ] + : bundledPlugins.filter(({ manifest }) => manifest.id === "buzz.profiles"), }); const panels = new PanelsService(context); Object.assign(window, { profilesFixture: { report, + contexts, + targets: { + viewer: profileTarget(viewer.pubkey), + mic: profileTarget(mic.pubkey), + }, + disconnect() { + snapshot = { ...snapshot, status: "disconnected" }; + for (const listener of listeners) listener(); + }, npubs: Object.fromEntries( Object.entries({ viewer, mic, pinky, missing }).map(([name, key]) => [ name,