From 6a06268183afdc454a1f7035c3a637c9c5c5033f Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 20 May 2026 13:30:32 +0200 Subject: [PATCH 1/9] feat: add Rozenite plugin protocol types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constants and message type definitions for the Rozenite CDP binding protocol — binding payload envelope, app↔agent message union types, and AgentTool shape. --- .../src/plugins/rozenite/protocol.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 packages/agent-cdp/src/plugins/rozenite/protocol.ts diff --git a/packages/agent-cdp/src/plugins/rozenite/protocol.ts b/packages/agent-cdp/src/plugins/rozenite/protocol.ts new file mode 100644 index 0000000..bd7dc01 --- /dev/null +++ b/packages/agent-cdp/src/plugins/rozenite/protocol.ts @@ -0,0 +1,25 @@ +export const RUNTIME_GLOBAL = "__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__"; +export const DOMAIN_NAME = "rozenite"; +export const POLL_INTERVAL_MS = 500; +export const POLL_TIMEOUT_MS = 30_000; + +export interface AgentTool { + name: string; + description: string; + inputSchema: object; +} + +export type AppToAgentMessage = + | { type: "register-tool"; tools: AgentTool[] } + | { type: "unregister-tool"; toolNames: string[] } + | { type: "tool-result"; callId: string; success: true; result: unknown } + | { type: "tool-result"; callId: string; success: false; error: string }; + +export type AgentToAppMessage = + | { type: "agent-session-ready" } + | { type: "tool-call"; callId: string; toolName: string; arguments: unknown }; + +export interface BindingPayload { + domain: string; + message: unknown; +} \ No newline at end of file From b97f3a2e400099b3630647cae24d79092f27a10a Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 20 May 2026 13:32:58 +0200 Subject: [PATCH 2/9] feat: add Rozenite tool registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-memory registry for dynamically registered Rozenite tools — supports register by domain (qualifies names as domain.toolName), unregister by qualified name, and clear on disconnect. --- .../src/plugins/rozenite/tool-registry.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 packages/agent-cdp/src/plugins/rozenite/tool-registry.ts diff --git a/packages/agent-cdp/src/plugins/rozenite/tool-registry.ts b/packages/agent-cdp/src/plugins/rozenite/tool-registry.ts new file mode 100644 index 0000000..53bdfad --- /dev/null +++ b/packages/agent-cdp/src/plugins/rozenite/tool-registry.ts @@ -0,0 +1,38 @@ +import type { AgentTool } from "./protocol.js"; + +export interface RegisteredTool extends AgentTool { + qualifiedName: string; +} + +export class RozeniteToolRegistry { + private readonly tools = new Map(); + + register(domain: string, tools: AgentTool[]): void { + for (const tool of tools) { + const qualifiedName = `${domain}.${tool.name}`; + this.tools.set(qualifiedName, { ...tool, qualifiedName }); + } + } + + unregister(toolNames: string[]): void { + for (const name of toolNames) { + this.tools.delete(name); + } + } + + clear(): void { + this.tools.clear(); + } + + list(): RegisteredTool[] { + return [...this.tools.values()]; + } + + get(qualifiedName: string): RegisteredTool | undefined { + return this.tools.get(qualifiedName); + } + + get size(): number { + return this.tools.size; + } +} \ No newline at end of file From 1540db7d76f970ff0bddf37028cb6e5e7f07fb3f Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 20 May 2026 13:34:44 +0200 Subject: [PATCH 3/9] feat: add Rozenite bootstrap helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Async poll → bind → domain-init sequence for establishing the Rozenite CDP binding. Polls for __FUSEBOX_REACT_DEVTOOLS_DISPATCHER__ at 500ms intervals, times out after 30s with a descriptive error, and supports AbortSignal cancellation for clean target-cleared teardown. --- .../src/plugins/rozenite/bootstrap.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 packages/agent-cdp/src/plugins/rozenite/bootstrap.ts diff --git a/packages/agent-cdp/src/plugins/rozenite/bootstrap.ts b/packages/agent-cdp/src/plugins/rozenite/bootstrap.ts new file mode 100644 index 0000000..d0b9a66 --- /dev/null +++ b/packages/agent-cdp/src/plugins/rozenite/bootstrap.ts @@ -0,0 +1,53 @@ +import type { AgentPluginTargetSession } from "../../plugin.js"; +import { DOMAIN_NAME, POLL_INTERVAL_MS, POLL_TIMEOUT_MS, RUNTIME_GLOBAL } from "./protocol.js"; + +export interface BootstrapResult { + bindingName: string; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function bootstrapRozenite( + session: AgentPluginTargetSession, + signal: AbortSignal +): Promise { + const deadline = Date.now() + POLL_TIMEOUT_MS; + + while (Date.now() < deadline) { + if (signal.aborted) throw new Error("aborted"); + + const evalResult = (await session.send("Runtime.evaluate", { + expression: `typeof ${RUNTIME_GLOBAL} !== 'undefined'`, + returnByValue: true, + })) as { result: { value: unknown } }; + + if (evalResult.result.value === true) break; + + await delay(POLL_INTERVAL_MS); + } + + if (signal.aborted) throw new Error("aborted"); + + if (Date.now() >= deadline) { + throw new Error( + `${RUNTIME_GLOBAL} not found after ${POLL_TIMEOUT_MS / 1000}s — is Rozenite integrated in this app?` + ); + } + + const bindingResult = (await session.send("Runtime.evaluate", { + expression: `${RUNTIME_GLOBAL}.BINDING_NAME`, + returnByValue: true, + })) as { result: { value: unknown } }; + + const bindingName = String(bindingResult.result.value); + + await session.send("Runtime.addBinding", { name: bindingName }); + + await session.send("Runtime.evaluate", { + expression: `${RUNTIME_GLOBAL}.initializeDomain('${DOMAIN_NAME}')`, + }); + + return { bindingName }; +} \ No newline at end of file From eb3faa95fc3f15c51433d998a56108b7df2ba011 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 20 May 2026 13:46:25 +0200 Subject: [PATCH 4/9] feat: add RozenitePlugin with state machine, bootstrap, and commands - Add alwaysExecutable flag to AgentPluginCommand so status-style commands can run regardless of plugin state; honour it in dispatch - Implement RozenitePlugin: fire-and-forget bootstrap, onDisconnected wiring, tool registry lifecycle, in-flight call rejection on disconnect, and four commands: status, tools, tool-schema, call --- packages/agent-cdp/src/plugin-orchestrator.ts | 14 +- packages/agent-cdp/src/plugin.ts | 1 + .../agent-cdp/src/plugins/rozenite/index.ts | 203 ++++++++++++++++++ 3 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 packages/agent-cdp/src/plugins/rozenite/index.ts diff --git a/packages/agent-cdp/src/plugin-orchestrator.ts b/packages/agent-cdp/src/plugin-orchestrator.ts index d150a08..c1e6eec 100644 --- a/packages/agent-cdp/src/plugin-orchestrator.ts +++ b/packages/agent-cdp/src/plugin-orchestrator.ts @@ -68,17 +68,19 @@ export class PluginOrchestrator { return { ok: false, error: `Unknown plugin '${pluginId}'` }; } - const state = plugin.getState(); - const stateError = this.getStateError(pluginId, state); - if (stateError) { - return { ok: false, error: stateError }; - } - const cmd = plugin.commands.find((c) => c.name === command); if (!cmd) { return { ok: false, error: `Unknown command '${command}' for plugin '${pluginId}'` }; } + if (!cmd.alwaysExecutable) { + const state = plugin.getState(); + const stateError = this.getStateError(pluginId, state); + if (stateError) { + return { ok: false, error: stateError }; + } + } + const context = this.buildCommandContext(plugin); try { const data = await cmd.execute(context, input); diff --git a/packages/agent-cdp/src/plugin.ts b/packages/agent-cdp/src/plugin.ts index fe6a795..e7bfd97 100644 --- a/packages/agent-cdp/src/plugin.ts +++ b/packages/agent-cdp/src/plugin.ts @@ -41,6 +41,7 @@ export interface AgentPluginCommand { readonly name: string; readonly summary: string; readonly description?: string; + readonly alwaysExecutable?: boolean; execute(context: AgentPluginCommandContext, input?: unknown): Promise; } diff --git a/packages/agent-cdp/src/plugins/rozenite/index.ts b/packages/agent-cdp/src/plugins/rozenite/index.ts new file mode 100644 index 0000000..7749608 --- /dev/null +++ b/packages/agent-cdp/src/plugins/rozenite/index.ts @@ -0,0 +1,203 @@ +import crypto from "node:crypto"; + +import type { TargetDescriptor } from "@agent-cdp/protocol"; + +import type { + AgentPlugin, + AgentPluginCommand, + AgentPluginDetachContext, + AgentPluginState, + AgentPluginTargetContext, + AgentPluginTargetSession, +} from "../../plugin.js"; +import { bootstrapRozenite } from "./bootstrap.js"; +import { + DOMAIN_NAME, + RUNTIME_GLOBAL, + type AgentToAppMessage, + type AppToAgentMessage, + type BindingPayload, +} from "./protocol.js"; +import { RozeniteToolRegistry } from "./tool-registry.js"; + +export class RozenitePlugin implements AgentPlugin { + readonly id = "rozenite"; + readonly displayName = "Rozenite"; + readonly description = "Rozenite React Native devtools bridge"; + readonly commands: readonly AgentPluginCommand[]; + + private state: AgentPluginState = { kind: "idle" }; + private readonly registry = new RozeniteToolRegistry(); + private abortController: AbortController | null = null; + private readonly pendingCalls = new Map void; + reject: (reason: Error) => void; + }>(); + + constructor() { + this.commands = this.buildCommands(); + } + + getState(): AgentPluginState { + return this.state; + } + + supportsTarget(target: TargetDescriptor): boolean { + return target.kind === "react-native"; + } + + async onTargetSelected(ctx: AgentPluginTargetContext): Promise { + this.state = { kind: "waiting-for-runtime", reason: `Waiting for ${RUNTIME_GLOBAL}` }; + this.registry.clear(); + this.abortController = new AbortController(); + ctx.session.onDisconnected(() => this.handleDisconnect()); + void this.runBootstrap(ctx.session); + } + + async onTargetReconnected(ctx: AgentPluginTargetContext): Promise { + return this.onTargetSelected(ctx); + } + + async onTargetCleared(_ctx: AgentPluginDetachContext): Promise { + this.teardown(new Error("Target cleared")); + this.state = { kind: "idle" }; + } + + private handleDisconnect(): void { + this.teardown(new Error("Target disconnected")); + this.state = { kind: "idle" }; + } + + private teardown(error: Error): void { + this.abortController?.abort(); + this.abortController = null; + this.registry.clear(); + for (const pending of this.pendingCalls.values()) { + pending.reject(error); + } + this.pendingCalls.clear(); + } + + private async runBootstrap(session: AgentPluginTargetSession): Promise { + try { + const { bindingName } = await bootstrapRozenite(session, this.abortController!.signal); + + session.onEvent((event) => { + if (event.method !== "Runtime.bindingCalled") return; + const params = event.params as { name?: string; payload?: string }; + if (params.name !== bindingName) return; + try { + const envelope = JSON.parse(params.payload ?? "") as BindingPayload; + if (envelope.domain !== DOMAIN_NAME) return; + this.handleMessage(envelope.message as AppToAgentMessage); + } catch {} + }); + + await this.sendToApp(session, { type: "agent-session-ready" }); + this.state = { kind: "ready" }; + } catch (err) { + if ((err as Error).message !== "aborted") { + this.state = { kind: "error", reason: (err as Error).message }; + } + } + } + + private handleMessage(msg: AppToAgentMessage): void { + switch (msg.type) { + case "register-tool": + this.registry.register("app", msg.tools); + break; + case "unregister-tool": + this.registry.unregister(msg.toolNames); + break; + case "tool-result": { + const pending = this.pendingCalls.get(msg.callId); + if (!pending) return; + this.pendingCalls.delete(msg.callId); + if (msg.success) { + pending.resolve({ success: true, result: msg.result }); + } else { + pending.resolve({ success: false, error: msg.error }); + } + break; + } + } + } + + private async sendToApp(session: AgentPluginTargetSession, message: AgentToAppMessage): Promise { + const payload = JSON.stringify(JSON.stringify(message)); + await session.send("Runtime.evaluate", { + expression: `${RUNTIME_GLOBAL}.sendMessage('${DOMAIN_NAME}', ${payload})`, + }); + } + + private buildCommands(): AgentPluginCommand[] { + return [ + { + name: "status", + summary: "Show Rozenite plugin state and registered tool count", + alwaysExecutable: true, + execute: async (ctx) => { + const state = ctx.getState(); + return { + state: state.kind, + ...(state.kind === "error" ? { error: state.reason } : {}), + toolCount: this.registry.size, + target: ctx.session?.target ?? null, + }; + }, + }, + { + name: "tools", + summary: "List registered Rozenite tools", + execute: async () => { + return this.registry.list().map((t) => ({ + name: t.qualifiedName, + description: t.description, + })); + }, + }, + { + name: "tool-schema", + summary: "Show input schema for a Rozenite tool", + execute: async (_ctx, input) => { + const { name } = input as { name: string }; + const tool = this.registry.get(name); + if (!tool) throw new Error(`Tool '${name}' not found`); + return tool.inputSchema; + }, + }, + { + name: "call", + summary: "Call a Rozenite tool", + execute: async (ctx, input) => { + const { name, arguments: args } = input as { name: string; arguments?: unknown }; + const tool = this.registry.get(name); + if (!tool) throw new Error(`Tool '${name}' not found`); + + const callId = crypto.randomUUID(); + return new Promise((resolve, reject) => { + this.pendingCalls.set(callId, { resolve, reject }); + + void this.sendToApp(ctx.session!, { + type: "tool-call", + callId, + toolName: name, + arguments: args ?? null, + }).catch((err: unknown) => { + this.pendingCalls.delete(callId); + reject(err instanceof Error ? err : new Error(String(err))); + }); + + setTimeout(() => { + if (this.pendingCalls.has(callId)) { + this.pendingCalls.delete(callId); + reject(new Error(`Tool call '${name}' timed out after 60s`)); + } + }, 60_000); + }); + }, + }, + ]; + } +} \ No newline at end of file From d72f309dbff6da8741b9c9a0598a18e6d0b50d59 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 20 May 2026 13:53:16 +0200 Subject: [PATCH 5/9] feat: wire RozenitePlugin into daemon and CLI Add rozenite CLI subcommand family (status, tools, tool-schema, call) and register RozenitePlugin in the PluginOrchestrator alongside the runtime bridge plugin. --- packages/agent-cdp/src/cli/index.ts | 2 + packages/agent-cdp/src/daemon.ts | 4 +- .../agent-cdp/src/plugins/rozenite/cli.ts | 68 +++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 packages/agent-cdp/src/plugins/rozenite/cli.ts diff --git a/packages/agent-cdp/src/cli/index.ts b/packages/agent-cdp/src/cli/index.ts index 214571e..9399aec 100644 --- a/packages/agent-cdp/src/cli/index.ts +++ b/packages/agent-cdp/src/cli/index.ts @@ -5,11 +5,13 @@ import { usage } from "./help.js"; import type { AgentPluginRegistration } from "./program.js"; import { createProgram } from "./program.js"; import { registerCliCommands as registerRuntimeBridgeCliCommands } from "../plugins/runtime-bridge/index.js"; +import { registerRozeniteCliCommands } from "../plugins/rozenite/cli.js"; export { ensureTargetSelected, MULTIPLE_TARGETS_AVAILABLE_MESSAGE, usage }; const BUILT_IN_PLUGINS: AgentPluginRegistration[] = [ { registerCliCommands: registerRuntimeBridgeCliCommands }, + { registerCliCommands: registerRozeniteCliCommands }, ]; function shouldPrintHelp(argv: string[]): boolean { diff --git a/packages/agent-cdp/src/daemon.ts b/packages/agent-cdp/src/daemon.ts index 278bc24..e7ca139 100644 --- a/packages/agent-cdp/src/daemon.ts +++ b/packages/agent-cdp/src/daemon.ts @@ -16,6 +16,7 @@ import { JsProfiler } from "./js-profiler/index.js"; import { NetworkManager } from "./network/index.js"; import { PluginOrchestrator } from "./plugin-orchestrator.js"; import { AgentRuntimeBridgePlugin } from "./plugins/runtime-bridge/index.js"; +import { RozenitePlugin } from "./plugins/rozenite/index.js"; import { createTargetProviders } from "./providers.js"; import { RuntimeManager } from "./runtime/index.js"; import { SessionManager } from "./session-manager.js"; @@ -52,7 +53,8 @@ class Daemon { constructor() { const bridgePlugin = new AgentRuntimeBridgePlugin((cmd) => this.commandDispatcher.dispatch(cmd)); - this.orchestrator = new PluginOrchestrator([bridgePlugin]); + const rozenitePlugin = new RozenitePlugin(); + this.orchestrator = new PluginOrchestrator([bridgePlugin, rozenitePlugin]); this.commandDispatcher = new AgentCdpCommandDispatcher({ startedAt: this.startedAt, diff --git a/packages/agent-cdp/src/plugins/rozenite/cli.ts b/packages/agent-cdp/src/plugins/rozenite/cli.ts new file mode 100644 index 0000000..48436c1 --- /dev/null +++ b/packages/agent-cdp/src/plugins/rozenite/cli.ts @@ -0,0 +1,68 @@ +import type { Command } from "commander"; + +import type { CliDeps } from "../../cli/context.js"; +import { unwrapResponse } from "../../cli/shared.js"; + +function printJson(data: unknown): void { + console.log(JSON.stringify(data, null, 2)); +} + +export function registerRozeniteCliCommands(program: Command, deps: CliDeps): void { + const rozenite = program.command("rozenite").description("Rozenite React Native devtools bridge"); + + rozenite + .command("status") + .description("Show Rozenite plugin state and registered tool count") + .action(async () => { + const data = unwrapResponse( + await deps.sendCommand({ type: "plugin-command", pluginId: "rozenite", command: "status" }), + "Failed to get Rozenite status" + ); + printJson(data); + }); + + rozenite + .command("tools") + .description("List registered Rozenite tools") + .action(async () => { + const data = unwrapResponse( + await deps.sendCommand({ type: "plugin-command", pluginId: "rozenite", command: "tools" }), + "Failed to list Rozenite tools" + ); + printJson(data); + }); + + rozenite + .command("tool-schema ") + .description("Show input schema for a Rozenite tool") + .action(async (name: string) => { + const data = unwrapResponse( + await deps.sendCommand({ + type: "plugin-command", + pluginId: "rozenite", + command: "tool-schema", + input: { name }, + }), + `Failed to get schema for tool '${name}'` + ); + printJson(data); + }); + + rozenite + .command("call ") + .description("Call a Rozenite tool") + .option("--input ", "Tool input as JSON string") + .action(async (name: string, options: { input?: string }) => { + const args = options.input !== undefined ? (JSON.parse(options.input) as unknown) : undefined; + const data = unwrapResponse( + await deps.sendCommand({ + type: "plugin-command", + pluginId: "rozenite", + command: "call", + input: { name, arguments: args }, + }), + `Failed to call tool '${name}'` + ); + printJson(data); + }); +} \ No newline at end of file From a1d0949390ab2474f6a2d7902753b750308496f0 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Wed, 20 May 2026 14:10:33 +0200 Subject: [PATCH 6/9] test: add RozenitePlugin test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 18 tests covering bootstrap lifecycle, tool registry, and all four commands — including alwaysExecutable status, tool-level vs transport failure distinction, and disconnect rejection of in-flight calls. --- .../src/__tests__/rozenite-plugin.test.ts | 443 ++++++++++++++++++ 1 file changed, 443 insertions(+) create mode 100644 packages/agent-cdp/src/__tests__/rozenite-plugin.test.ts diff --git a/packages/agent-cdp/src/__tests__/rozenite-plugin.test.ts b/packages/agent-cdp/src/__tests__/rozenite-plugin.test.ts new file mode 100644 index 0000000..1bf0c51 --- /dev/null +++ b/packages/agent-cdp/src/__tests__/rozenite-plugin.test.ts @@ -0,0 +1,443 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AgentPluginCommandContext, AgentPluginTargetContext, AgentPluginTargetSession } from "../plugin.js"; +import { PluginOrchestrator } from "../plugin-orchestrator.js"; +import { RozenitePlugin } from "../plugins/rozenite/index.js"; +import { DOMAIN_NAME, RUNTIME_GLOBAL } from "../plugins/rozenite/protocol.js"; +import type { CdpEventMessage, IpcResponse, TargetDescriptor } from "../types.js"; + +// --------------------------------------------------------------------------- +// Fake session +// --------------------------------------------------------------------------- + +class FakeRozeniteSession implements AgentPluginTargetSession { + private eventListener: ((message: CdpEventMessage) => void) | null = null; + private readonly disconnectListeners: ((error?: Error) => void)[] = []; + readonly sent: Array<{ method: string; params?: Record }> = []; + + readonly target: TargetDescriptor = { + id: "rn:test:page-1", + rawId: "page-1", + title: "Example", + kind: "react-native", + description: "Test page", + webSocketDebuggerUrl: "ws://example.test/devtools/page/1", + sourceUrl: "http://example.test", + }; + + private readonly bindingName: string; + + constructor(bindingName = "test_binding") { + this.bindingName = bindingName; + } + + isConnected(): boolean { + return true; + } + + send(method: string, params?: Record): Promise { + this.sent.push({ method, params }); + + if (method === "Runtime.evaluate") { + const expr = String(params?.expression ?? ""); + if (expr.includes(`typeof ${RUNTIME_GLOBAL}`)) { + return Promise.resolve({ result: { value: true } }); + } + if (expr.includes("BINDING_NAME")) { + return Promise.resolve({ result: { value: this.bindingName } }); + } + } + return Promise.resolve(undefined); + } + + onEvent(listener: (message: CdpEventMessage) => void): () => void { + this.eventListener = listener; + return () => { + this.eventListener = null; + }; + } + + onDisconnected(listener: (error?: Error) => void): () => void { + this.disconnectListeners.push(listener); + return () => { + const index = this.disconnectListeners.indexOf(listener); + if (index !== -1) this.disconnectListeners.splice(index, 1); + }; + } + + emitEvent(message: CdpEventMessage): void { + this.eventListener?.(message); + } + + emitDisconnect(error?: Error): void { + for (const listener of this.disconnectListeners) { + listener(error); + } + } + + emitBinding(payload: unknown): void { + this.emitEvent({ + method: "Runtime.bindingCalled", + params: { name: this.bindingName, payload: JSON.stringify({ domain: DOMAIN_NAME, message: payload }) }, + }); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeTargetContext(session: FakeRozeniteSession, pluginId = "rozenite"): AgentPluginTargetContext { + return { pluginId, session }; +} + +function makeCommandContext(plugin: RozenitePlugin, session: FakeRozeniteSession): AgentPluginCommandContext { + return { + pluginId: "rozenite", + session, + getState: () => plugin.getState(), + }; +} + +async function runCommand( + plugin: RozenitePlugin, + name: string, + session: FakeRozeniteSession, + input?: unknown +): Promise { + const cmd = (plugin.commands as ReturnType[]).find((c) => c?.name === name); + if (!cmd) return { ok: false, error: `Unknown command '${name}'` }; + const ctx = makeCommandContext(plugin, session); + try { + const data = await cmd.execute(ctx, input); + return { ok: true, data }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +async function flushBootstrap(): Promise { + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } +} + +const RN_TARGET: TargetDescriptor = { + id: "rn:test:page-1", + rawId: "page-1", + title: "Example", + kind: "react-native", + description: "", + webSocketDebuggerUrl: "ws://example.test/1", + sourceUrl: "http://example.test", +}; + +const CHROME_TARGET: TargetDescriptor = { + ...RN_TARGET, + id: "chrome:test:page-1", + kind: "chrome", +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("RozenitePlugin", () => { + describe("supportsTarget", () => { + it("returns true for react-native targets", () => { + const plugin = new RozenitePlugin(); + expect(plugin.supportsTarget(RN_TARGET)).toBe(true); + }); + + it("returns false for chrome targets", () => { + const plugin = new RozenitePlugin(); + expect(plugin.supportsTarget(CHROME_TARGET)).toBe(false); + }); + }); + + describe("bootstrap", () => { + it("transitions to ready after successful bootstrap", async () => { + const plugin = new RozenitePlugin(); + const session = new FakeRozeniteSession(); + + expect(plugin.getState()).toEqual({ kind: "idle" }); + await plugin.onTargetSelected(makeTargetContext(session)); + expect(plugin.getState()).toEqual({ kind: "waiting-for-runtime", reason: expect.stringContaining(RUNTIME_GLOBAL) }); + + await flushBootstrap(); + + expect(plugin.getState()).toEqual({ kind: "ready" }); + }); + + it("sends agent-session-ready after bootstrap completes", async () => { + const plugin = new RozenitePlugin(); + const session = new FakeRozeniteSession(); + + await plugin.onTargetSelected(makeTargetContext(session)); + await flushBootstrap(); + + const sendMessages = session.sent.filter( + (s) => s.method === "Runtime.evaluate" && String(s.params?.expression).includes("sendMessage") + ); + expect(sendMessages).toHaveLength(1); + expect(String(sendMessages[0].params?.expression)).toContain("agent-session-ready"); + }); + + it("transitions to error state when dispatcher global times out", async () => { + vi.useFakeTimers(); + const plugin = new RozenitePlugin(); + const session = new FakeRozeniteSession(); + + vi.spyOn(session, "send").mockImplementation((method, params) => { + session.sent.push({ method, params }); + if (method === "Runtime.evaluate" && String(params?.expression).includes(`typeof ${RUNTIME_GLOBAL}`)) { + return Promise.resolve({ result: { value: false } }); + } + return Promise.resolve(undefined); + }); + + await plugin.onTargetSelected(makeTargetContext(session)); + await vi.advanceTimersByTimeAsync(31_000); + + expect(plugin.getState()).toEqual({ kind: "error", reason: expect.stringContaining(RUNTIME_GLOBAL) }); + vi.useRealTimers(); + }); + + it("does not transition to error when target is cleared during bootstrap", async () => { + vi.useFakeTimers(); + const plugin = new RozenitePlugin(); + const session = new FakeRozeniteSession(); + + vi.spyOn(session, "send").mockImplementation((method, params) => { + session.sent.push({ method, params }); + if (method === "Runtime.evaluate" && String(params?.expression).includes(`typeof ${RUNTIME_GLOBAL}`)) { + return Promise.resolve({ result: { value: false } }); + } + return Promise.resolve(undefined); + }); + + await plugin.onTargetSelected(makeTargetContext(session)); + await plugin.onTargetCleared({ pluginId: "rozenite", target: RN_TARGET, reason: "target-cleared" }); + await vi.advanceTimersByTimeAsync(31_000); + + expect(plugin.getState()).toEqual({ kind: "idle" }); + vi.useRealTimers(); + }); + + it("transitions back to idle after onTargetCleared", async () => { + const plugin = new RozenitePlugin(); + const session = new FakeRozeniteSession(); + + await plugin.onTargetSelected(makeTargetContext(session)); + await flushBootstrap(); + expect(plugin.getState()).toEqual({ kind: "ready" }); + + await plugin.onTargetCleared({ pluginId: "rozenite", target: RN_TARGET, reason: "target-cleared" }); + expect(plugin.getState()).toEqual({ kind: "idle" }); + }); + }); + + describe("tool registry", () => { + it("registers tools from register-tool messages with qualified names", async () => { + const plugin = new RozenitePlugin(); + const session = new FakeRozeniteSession(); + const orchestrator = new PluginOrchestrator([plugin]); + + await plugin.onTargetSelected(makeTargetContext(session)); + await flushBootstrap(); + + session.emitBinding({ + type: "register-tool", + tools: [{ name: "myTool", description: "A tool", inputSchema: {} }], + }); + + const result = await runCommand(plugin, "tools", session); + expect(result).toEqual({ + ok: true, + data: [{ name: "app.myTool", description: "A tool" }], + }); + + // orchestrator referenced to avoid unused variable lint + expect(orchestrator).toBeDefined(); + }); + + it("removes tools from unregister-tool messages", async () => { + const plugin = new RozenitePlugin(); + const session = new FakeRozeniteSession(); + + await plugin.onTargetSelected(makeTargetContext(session)); + await flushBootstrap(); + + session.emitBinding({ + type: "register-tool", + tools: [ + { name: "toolA", description: "A", inputSchema: {} }, + { name: "toolB", description: "B", inputSchema: {} }, + ], + }); + session.emitBinding({ type: "unregister-tool", toolNames: ["app.toolA"] }); + + const result = await runCommand(plugin, "tools", session); + expect(result).toEqual({ + ok: true, + data: [{ name: "app.toolB", description: "B" }], + }); + }); + + it("clears registry on disconnect", async () => { + const plugin = new RozenitePlugin(); + const session = new FakeRozeniteSession(); + + await plugin.onTargetSelected(makeTargetContext(session)); + await flushBootstrap(); + + session.emitBinding({ + type: "register-tool", + tools: [{ name: "myTool", description: "A tool", inputSchema: {} }], + }); + + session.emitDisconnect(); + + const result = await runCommand(plugin, "status", session); + expect(result.ok).toBe(true); + expect((result.data as { toolCount: number }).toolCount).toBe(0); + }); + }); + + describe("commands", () => { + let plugin: RozenitePlugin; + let session: FakeRozeniteSession; + + beforeEach(async () => { + plugin = new RozenitePlugin(); + session = new FakeRozeniteSession(); + await plugin.onTargetSelected(makeTargetContext(session)); + await flushBootstrap(); + }); + + afterEach(async () => { + await plugin.onTargetCleared({ pluginId: "rozenite", target: RN_TARGET, reason: "target-cleared" }); + }); + + it("status returns state, toolCount, and target", async () => { + const result = await runCommand(plugin, "status", session); + expect(result).toEqual({ + ok: true, + data: { state: "ready", toolCount: 0, target: session.target }, + }); + }); + + it("status works in waiting-for-runtime state (alwaysExecutable)", async () => { + const plugin2 = new RozenitePlugin(); + const session2 = new FakeRozeniteSession(); + const orchestrator = new PluginOrchestrator([plugin2]); + + void plugin2.onTargetSelected(makeTargetContext(session2)); + + // Use orchestrator.dispatch — status has alwaysExecutable so it bypasses state check + const result = await orchestrator.dispatch("rozenite", "status"); + expect(result.ok).toBe(true); + expect((result.data as { state: string }).state).toBe("waiting-for-runtime"); + + await plugin2.onTargetCleared({ pluginId: "rozenite", target: RN_TARGET, reason: "target-cleared" }); + }); + + it("tool-schema returns inputSchema for a registered tool", async () => { + session.emitBinding({ + type: "register-tool", + tools: [{ name: "myTool", description: "A tool", inputSchema: { type: "object" } }], + }); + + const result = await runCommand(plugin, "tool-schema", session, { name: "app.myTool" }); + expect(result).toEqual({ ok: true, data: { type: "object" } }); + }); + + it("tool-schema returns error for unknown tool", async () => { + const result = await runCommand(plugin, "tool-schema", session, { name: "app.unknown" }); + expect(result).toEqual({ ok: false, error: expect.stringContaining("app.unknown") }); + }); + + it("call succeeds and returns tool result", async () => { + session.emitBinding({ + type: "register-tool", + tools: [{ name: "myTool", description: "A tool", inputSchema: {} }], + }); + + let capturedCallId: string | undefined; + const originalSend = session.send.bind(session); + vi.spyOn(session, "send").mockImplementation((method, params) => { + if (method === "Runtime.evaluate") { + const expr = String(params?.expression ?? ""); + if (expr.includes("tool-call")) { + // payload is double-JSON-encoded so quotes appear as \" in the expression value + const match = /\\"callId\\":\\"([a-f0-9-]+)/.exec(expr); + if (match) capturedCallId = match[1]; + } + } + return originalSend(method, params); + }); + + const callPromise = runCommand(plugin, "call", session, { name: "app.myTool", arguments: { x: 1 } }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(capturedCallId).toBeDefined(); + session.emitBinding({ type: "tool-result", callId: capturedCallId!, success: true, result: { value: 42 } }); + + const result = await callPromise; + expect(result).toEqual({ ok: true, data: { success: true, result: { value: 42 } } }); + }); + + it("call returns success:false data for tool-level failure (not an IPC error)", async () => { + session.emitBinding({ + type: "register-tool", + tools: [{ name: "myTool", description: "A tool", inputSchema: {} }], + }); + + let capturedCallId: string | undefined; + const originalSend = session.send.bind(session); + vi.spyOn(session, "send").mockImplementation((method, params) => { + if (method === "Runtime.evaluate") { + const expr = String(params?.expression ?? ""); + if (expr.includes("tool-call")) { + const match = /\\"callId\\":\\"([a-f0-9-]+)/.exec(expr); + if (match) capturedCallId = match[1]; + } + } + return originalSend(method, params); + }); + + const callPromise = runCommand(plugin, "call", session, { name: "app.myTool" }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(capturedCallId).toBeDefined(); + session.emitBinding({ type: "tool-result", callId: capturedCallId!, success: false, error: "something went wrong" }); + + const result = await callPromise; + expect(result).toEqual({ ok: true, data: { success: false, error: "something went wrong" } }); + }); + + it("call rejects with IPC error on disconnect", async () => { + session.emitBinding({ + type: "register-tool", + tools: [{ name: "myTool", description: "A tool", inputSchema: {} }], + }); + + const callPromise = runCommand(plugin, "call", session, { name: "app.myTool" }); + + await Promise.resolve(); + session.emitDisconnect(); + + const result = await callPromise; + expect(result).toEqual({ ok: false, error: expect.stringContaining("disconnected") }); + }); + + it("call returns error for unknown tool", async () => { + const result = await runCommand(plugin, "call", session, { name: "app.unknown" }); + expect(result).toEqual({ ok: false, error: expect.stringContaining("app.unknown") }); + }); + }); +}); \ No newline at end of file From 538f24a61854354e897327a01b69896f58a02ec8 Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Thu, 21 May 2026 10:19:27 +0200 Subject: [PATCH 7/9] feat: rewrite Rozenite plugin to use HTTP API and add playground integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CDP binding approach (Runtime.addBinding + Runtime.bindingCalled) does not work in Hermes/Fusebox New Architecture — binding events are routed to the DevTools frontend only, not to debugging sessions. Switch to @rozenite/metro's HTTP agent API (/rozenite/agent/*) which is designed for this use case. Remove bootstrap.ts and tool-registry.ts; the HTTP session owns tool state. CDP session disconnects no longer tear down the Rozenite session since the HTTP session is independent of the CDP transport. Add playground integration: metro.config.js wraps Expo config with withRozenite, use-rozenite-bridge.ts registers three test tools (echo, getTimestamp, getPlaygroundInfo), and index.tsx renders a Rozenite status section when the Fusebox dispatcher global is present. --- .../src/__tests__/rozenite-plugin.test.ts | 467 ++++++++---------- .../src/plugins/rozenite/bootstrap.ts | 53 -- .../agent-cdp/src/plugins/rozenite/index.ts | 194 ++++---- .../src/plugins/rozenite/protocol.ts | 34 +- .../src/plugins/rozenite/tool-registry.ts | 38 -- playground/metro.config.js | 8 + playground/package.json | 2 + playground/src/app/index.tsx | 35 ++ playground/src/hooks/use-rozenite-bridge.ts | 68 +++ pnpm-lock.yaml | 354 +++++++++++++ 10 files changed, 783 insertions(+), 470 deletions(-) delete mode 100644 packages/agent-cdp/src/plugins/rozenite/bootstrap.ts delete mode 100644 packages/agent-cdp/src/plugins/rozenite/tool-registry.ts create mode 100644 playground/metro.config.js create mode 100644 playground/src/hooks/use-rozenite-bridge.ts diff --git a/packages/agent-cdp/src/__tests__/rozenite-plugin.test.ts b/packages/agent-cdp/src/__tests__/rozenite-plugin.test.ts index 1bf0c51..118aa03 100644 --- a/packages/agent-cdp/src/__tests__/rozenite-plugin.test.ts +++ b/packages/agent-cdp/src/__tests__/rozenite-plugin.test.ts @@ -3,95 +3,124 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentPluginCommandContext, AgentPluginTargetContext, AgentPluginTargetSession } from "../plugin.js"; import { PluginOrchestrator } from "../plugin-orchestrator.js"; import { RozenitePlugin } from "../plugins/rozenite/index.js"; -import { DOMAIN_NAME, RUNTIME_GLOBAL } from "../plugins/rozenite/protocol.js"; -import type { CdpEventMessage, IpcResponse, TargetDescriptor } from "../types.js"; +import { ROZENITE_AGENT_BASE } from "../plugins/rozenite/protocol.js"; +import type { IpcResponse, TargetDescriptor } from "../types.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const METRO_BASE = "http://localhost:8081"; +const DEVICE_ID = "test-device-id"; +const SESSION_URL = `${METRO_BASE}${ROZENITE_AGENT_BASE}/sessions`; +const SESSION_ID_URL = `${SESSION_URL}/${DEVICE_ID}`; +const SESSION_TOOLS_URL = `${SESSION_ID_URL}/tools`; +const SESSION_CALL_URL = `${SESSION_ID_URL}/call-tool`; + +const SESSION_INFO = { + id: DEVICE_ID, + deviceId: DEVICE_ID, + deviceName: "Test Device", + status: "connected", + toolCount: 0, + createdAt: 0, + lastActivityAt: 0, +}; + +// --------------------------------------------------------------------------- +// Targets +// --------------------------------------------------------------------------- + +const RN_TARGET: TargetDescriptor = { + id: "react-native:bG9jYWxob3N0OjgwODE:page-1", + rawId: "page-1", + title: "Example", + kind: "react-native", + description: "", + webSocketDebuggerUrl: "ws://localhost:8081/devtools/page/page-1", + sourceUrl: METRO_BASE, + reactNative: { logicalDeviceId: DEVICE_ID, capabilities: {} }, +}; + +const CHROME_TARGET: TargetDescriptor = { + ...RN_TARGET, + id: "chrome:bG9jYWxob3N0OjgwODE:page-1", + kind: "chrome", +}; // --------------------------------------------------------------------------- // Fake session // --------------------------------------------------------------------------- -class FakeRozeniteSession implements AgentPluginTargetSession { - private eventListener: ((message: CdpEventMessage) => void) | null = null; +class FakeSession implements AgentPluginTargetSession { private readonly disconnectListeners: ((error?: Error) => void)[] = []; - readonly sent: Array<{ method: string; params?: Record }> = []; - - readonly target: TargetDescriptor = { - id: "rn:test:page-1", - rawId: "page-1", - title: "Example", - kind: "react-native", - description: "Test page", - webSocketDebuggerUrl: "ws://example.test/devtools/page/1", - sourceUrl: "http://example.test", - }; + readonly target: TargetDescriptor; - private readonly bindingName: string; - - constructor(bindingName = "test_binding") { - this.bindingName = bindingName; + constructor(target: TargetDescriptor = RN_TARGET) { + this.target = target; } isConnected(): boolean { return true; } - send(method: string, params?: Record): Promise { - this.sent.push({ method, params }); - - if (method === "Runtime.evaluate") { - const expr = String(params?.expression ?? ""); - if (expr.includes(`typeof ${RUNTIME_GLOBAL}`)) { - return Promise.resolve({ result: { value: true } }); - } - if (expr.includes("BINDING_NAME")) { - return Promise.resolve({ result: { value: this.bindingName } }); - } - } + send(): Promise { return Promise.resolve(undefined); } - onEvent(listener: (message: CdpEventMessage) => void): () => void { - this.eventListener = listener; - return () => { - this.eventListener = null; - }; + onEvent(): () => void { + return () => {}; } onDisconnected(listener: (error?: Error) => void): () => void { this.disconnectListeners.push(listener); return () => { - const index = this.disconnectListeners.indexOf(listener); - if (index !== -1) this.disconnectListeners.splice(index, 1); + const i = this.disconnectListeners.indexOf(listener); + if (i >= 0) this.disconnectListeners.splice(i, 1); }; } - emitEvent(message: CdpEventMessage): void { - this.eventListener?.(message); - } - emitDisconnect(error?: Error): void { for (const listener of this.disconnectListeners) { listener(error); } } +} - emitBinding(payload: unknown): void { - this.emitEvent({ - method: "Runtime.bindingCalled", - params: { name: this.bindingName, payload: JSON.stringify({ domain: DOMAIN_NAME, message: payload }) }, - }); - } +// --------------------------------------------------------------------------- +// Fetch mock helpers +// --------------------------------------------------------------------------- + +type FetchResponse = Record; + +function makeFetch(responses: Record) { + return vi.fn(async (url: string, init?: RequestInit) => { + const method = (init?.method ?? "GET").toUpperCase(); + const key = `${method} ${url}`; + const body = responses[key] ?? responses[url] ?? { ok: false, error: { message: "Not mocked" } }; + return { + ok: true, + json: async () => body, + }; + }); +} + +function makeConnectFetch(extra: Record = {}) { + return makeFetch({ + [`POST ${SESSION_URL}`]: { ok: true, result: { session: SESSION_INFO } }, + ...extra, + }); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -function makeTargetContext(session: FakeRozeniteSession, pluginId = "rozenite"): AgentPluginTargetContext { - return { pluginId, session }; +function makeTargetContext(session: FakeSession): AgentPluginTargetContext { + return { pluginId: "rozenite", session }; } -function makeCommandContext(plugin: RozenitePlugin, session: FakeRozeniteSession): AgentPluginCommandContext { +function makeCommandContext(plugin: RozenitePlugin, session: FakeSession | null): AgentPluginCommandContext { return { pluginId: "rozenite", session, @@ -102,7 +131,7 @@ function makeCommandContext(plugin: RozenitePlugin, session: FakeRozeniteSession async function runCommand( plugin: RozenitePlugin, name: string, - session: FakeRozeniteSession, + session: FakeSession | null, input?: unknown ): Promise { const cmd = (plugin.commands as ReturnType[]).find((c) => c?.name === name); @@ -116,33 +145,21 @@ async function runCommand( } } -async function flushBootstrap(): Promise { - for (let i = 0; i < 10; i++) { +async function flushConnect(): Promise { + for (let i = 0; i < 5; i++) { await Promise.resolve(); } } -const RN_TARGET: TargetDescriptor = { - id: "rn:test:page-1", - rawId: "page-1", - title: "Example", - kind: "react-native", - description: "", - webSocketDebuggerUrl: "ws://example.test/1", - sourceUrl: "http://example.test", -}; - -const CHROME_TARGET: TargetDescriptor = { - ...RN_TARGET, - id: "chrome:test:page-1", - kind: "chrome", -}; - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("RozenitePlugin", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + describe("supportsTarget", () => { it("returns true for react-native targets", () => { const plugin = new RozenitePlugin(); @@ -155,289 +172,229 @@ describe("RozenitePlugin", () => { }); }); - describe("bootstrap", () => { - it("transitions to ready after successful bootstrap", async () => { + describe("connect", () => { + it("transitions to ready after successful session creation", async () => { + vi.stubGlobal("fetch", makeConnectFetch()); const plugin = new RozenitePlugin(); - const session = new FakeRozeniteSession(); + const session = new FakeSession(); expect(plugin.getState()).toEqual({ kind: "idle" }); await plugin.onTargetSelected(makeTargetContext(session)); - expect(plugin.getState()).toEqual({ kind: "waiting-for-runtime", reason: expect.stringContaining(RUNTIME_GLOBAL) }); + expect(plugin.getState()).toEqual({ kind: "waiting-for-runtime", reason: expect.any(String) }); - await flushBootstrap(); + await flushConnect(); expect(plugin.getState()).toEqual({ kind: "ready" }); }); - it("sends agent-session-ready after bootstrap completes", async () => { + it("sends deviceId in the POST body", async () => { + const mockFetch = makeConnectFetch(); + vi.stubGlobal("fetch", mockFetch); const plugin = new RozenitePlugin(); - const session = new FakeRozeniteSession(); - await plugin.onTargetSelected(makeTargetContext(session)); - await flushBootstrap(); + await plugin.onTargetSelected(makeTargetContext(new FakeSession())); + await flushConnect(); - const sendMessages = session.sent.filter( - (s) => s.method === "Runtime.evaluate" && String(s.params?.expression).includes("sendMessage") + const postCall = mockFetch.mock.calls.find( + ([url, init]) => url === SESSION_URL && (init as RequestInit)?.method === "POST" ); - expect(sendMessages).toHaveLength(1); - expect(String(sendMessages[0].params?.expression)).toContain("agent-session-ready"); + expect(postCall).toBeDefined(); + expect(JSON.parse((postCall![1] as RequestInit).body as string)).toEqual({ deviceId: DEVICE_ID }); }); - it("transitions to error state when dispatcher global times out", async () => { - vi.useFakeTimers(); + it("transitions to error when session creation fails", async () => { + vi.stubGlobal( + "fetch", + makeFetch({ [`POST ${SESSION_URL}`]: { ok: false, error: { message: "Rozenite not enabled" } } }) + ); const plugin = new RozenitePlugin(); - const session = new FakeRozeniteSession(); - - vi.spyOn(session, "send").mockImplementation((method, params) => { - session.sent.push({ method, params }); - if (method === "Runtime.evaluate" && String(params?.expression).includes(`typeof ${RUNTIME_GLOBAL}`)) { - return Promise.resolve({ result: { value: false } }); - } - return Promise.resolve(undefined); - }); - await plugin.onTargetSelected(makeTargetContext(session)); - await vi.advanceTimersByTimeAsync(31_000); + await plugin.onTargetSelected(makeTargetContext(new FakeSession())); + await flushConnect(); - expect(plugin.getState()).toEqual({ kind: "error", reason: expect.stringContaining(RUNTIME_GLOBAL) }); - vi.useRealTimers(); + expect(plugin.getState()).toEqual({ kind: "error", reason: "Rozenite not enabled" }); + }); + + it("transitions to error when fetch throws (Metro unreachable)", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("fetch failed"); + }) + ); + const plugin = new RozenitePlugin(); + + await plugin.onTargetSelected(makeTargetContext(new FakeSession())); + await flushConnect(); + + expect(plugin.getState()).toEqual({ kind: "error", reason: "fetch failed" }); }); - it("does not transition to error when target is cleared during bootstrap", async () => { + it("does not transition to error when target is cleared during connect", async () => { vi.useFakeTimers(); + // Never resolves + vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); const plugin = new RozenitePlugin(); - const session = new FakeRozeniteSession(); - - vi.spyOn(session, "send").mockImplementation((method, params) => { - session.sent.push({ method, params }); - if (method === "Runtime.evaluate" && String(params?.expression).includes(`typeof ${RUNTIME_GLOBAL}`)) { - return Promise.resolve({ result: { value: false } }); - } - return Promise.resolve(undefined); - }); + const session = new FakeSession(); await plugin.onTargetSelected(makeTargetContext(session)); await plugin.onTargetCleared({ pluginId: "rozenite", target: RN_TARGET, reason: "target-cleared" }); - await vi.advanceTimersByTimeAsync(31_000); expect(plugin.getState()).toEqual({ kind: "idle" }); vi.useRealTimers(); }); it("transitions back to idle after onTargetCleared", async () => { + vi.stubGlobal("fetch", makeConnectFetch({ [`DELETE ${SESSION_ID_URL}`]: { ok: true, result: { stopped: true } } })); const plugin = new RozenitePlugin(); - const session = new FakeRozeniteSession(); - await plugin.onTargetSelected(makeTargetContext(session)); - await flushBootstrap(); + await plugin.onTargetSelected(makeTargetContext(new FakeSession())); + await flushConnect(); expect(plugin.getState()).toEqual({ kind: "ready" }); await plugin.onTargetCleared({ pluginId: "rozenite", target: RN_TARGET, reason: "target-cleared" }); expect(plugin.getState()).toEqual({ kind: "idle" }); }); - }); - - describe("tool registry", () => { - it("registers tools from register-tool messages with qualified names", async () => { - const plugin = new RozenitePlugin(); - const session = new FakeRozeniteSession(); - const orchestrator = new PluginOrchestrator([plugin]); - - await plugin.onTargetSelected(makeTargetContext(session)); - await flushBootstrap(); - - session.emitBinding({ - type: "register-tool", - tools: [{ name: "myTool", description: "A tool", inputSchema: {} }], - }); - const result = await runCommand(plugin, "tools", session); - expect(result).toEqual({ - ok: true, - data: [{ name: "app.myTool", description: "A tool" }], + it("DELETEs the session on onTargetCleared", async () => { + const mockFetch = makeConnectFetch({ + [`DELETE ${SESSION_ID_URL}`]: { ok: true, result: { stopped: true } }, }); - - // orchestrator referenced to avoid unused variable lint - expect(orchestrator).toBeDefined(); - }); - - it("removes tools from unregister-tool messages", async () => { + vi.stubGlobal("fetch", mockFetch); const plugin = new RozenitePlugin(); - const session = new FakeRozeniteSession(); - - await plugin.onTargetSelected(makeTargetContext(session)); - await flushBootstrap(); - session.emitBinding({ - type: "register-tool", - tools: [ - { name: "toolA", description: "A", inputSchema: {} }, - { name: "toolB", description: "B", inputSchema: {} }, - ], - }); - session.emitBinding({ type: "unregister-tool", toolNames: ["app.toolA"] }); + await plugin.onTargetSelected(makeTargetContext(new FakeSession())); + await flushConnect(); + await plugin.onTargetCleared({ pluginId: "rozenite", target: RN_TARGET, reason: "target-cleared" }); - const result = await runCommand(plugin, "tools", session); - expect(result).toEqual({ - ok: true, - data: [{ name: "app.toolB", description: "B" }], - }); + const deleteCall = mockFetch.mock.calls.find( + ([url, init]) => url === SESSION_ID_URL && (init as RequestInit)?.method === "DELETE" + ); + expect(deleteCall).toBeDefined(); }); - it("clears registry on disconnect", async () => { + it("stays ready when CDP session disconnects (HTTP session is independent)", async () => { + vi.stubGlobal("fetch", makeConnectFetch()); const plugin = new RozenitePlugin(); - const session = new FakeRozeniteSession(); + const session = new FakeSession(); await plugin.onTargetSelected(makeTargetContext(session)); - await flushBootstrap(); - - session.emitBinding({ - type: "register-tool", - tools: [{ name: "myTool", description: "A tool", inputSchema: {} }], - }); + await flushConnect(); + expect(plugin.getState()).toEqual({ kind: "ready" }); session.emitDisconnect(); - - const result = await runCommand(plugin, "status", session); - expect(result.ok).toBe(true); - expect((result.data as { toolCount: number }).toolCount).toBe(0); + expect(plugin.getState()).toEqual({ kind: "ready" }); }); }); describe("commands", () => { let plugin: RozenitePlugin; - let session: FakeRozeniteSession; + let session: FakeSession; beforeEach(async () => { + vi.stubGlobal( + "fetch", + makeConnectFetch({ + [SESSION_ID_URL]: { + ok: true, + result: { session: { ...SESSION_INFO, toolCount: 3 } }, + }, + [SESSION_TOOLS_URL]: { + ok: true, + result: { + tools: [ + { name: "echo", description: "Echoes text", inputSchema: { type: "object" } }, + { name: "getTimestamp", description: "Returns timestamp", inputSchema: { type: "object", properties: {} } }, + ], + }, + }, + [`POST ${SESSION_CALL_URL}`]: { + ok: true, + result: { result: { value: 42 } }, + }, + [`DELETE ${SESSION_ID_URL}`]: { ok: true, result: { stopped: true } }, + }) + ); plugin = new RozenitePlugin(); - session = new FakeRozeniteSession(); + session = new FakeSession(); await plugin.onTargetSelected(makeTargetContext(session)); - await flushBootstrap(); + await flushConnect(); }); afterEach(async () => { await plugin.onTargetCleared({ pluginId: "rozenite", target: RN_TARGET, reason: "target-cleared" }); + vi.unstubAllGlobals(); }); it("status returns state, toolCount, and target", async () => { const result = await runCommand(plugin, "status", session); expect(result).toEqual({ ok: true, - data: { state: "ready", toolCount: 0, target: session.target }, + data: { state: "ready", toolCount: 3, target: RN_TARGET }, }); }); it("status works in waiting-for-runtime state (alwaysExecutable)", async () => { + vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); const plugin2 = new RozenitePlugin(); - const session2 = new FakeRozeniteSession(); + const session2 = new FakeSession(); const orchestrator = new PluginOrchestrator([plugin2]); void plugin2.onTargetSelected(makeTargetContext(session2)); - // Use orchestrator.dispatch — status has alwaysExecutable so it bypasses state check const result = await orchestrator.dispatch("rozenite", "status"); expect(result.ok).toBe(true); expect((result.data as { state: string }).state).toBe("waiting-for-runtime"); + expect((result.data as { toolCount: number }).toolCount).toBe(0); await plugin2.onTargetCleared({ pluginId: "rozenite", target: RN_TARGET, reason: "target-cleared" }); }); - it("tool-schema returns inputSchema for a registered tool", async () => { - session.emitBinding({ - type: "register-tool", - tools: [{ name: "myTool", description: "A tool", inputSchema: { type: "object" } }], + it("tools returns list of tool names and descriptions", async () => { + const result = await runCommand(plugin, "tools", session); + expect(result).toEqual({ + ok: true, + data: [ + { name: "echo", description: "Echoes text" }, + { name: "getTimestamp", description: "Returns timestamp" }, + ], }); + }); - const result = await runCommand(plugin, "tool-schema", session, { name: "app.myTool" }); + it("tool-schema returns inputSchema for a registered tool", async () => { + const result = await runCommand(plugin, "tool-schema", session, { name: "echo" }); expect(result).toEqual({ ok: true, data: { type: "object" } }); }); it("tool-schema returns error for unknown tool", async () => { - const result = await runCommand(plugin, "tool-schema", session, { name: "app.unknown" }); - expect(result).toEqual({ ok: false, error: expect.stringContaining("app.unknown") }); - }); - - it("call succeeds and returns tool result", async () => { - session.emitBinding({ - type: "register-tool", - tools: [{ name: "myTool", description: "A tool", inputSchema: {} }], - }); - - let capturedCallId: string | undefined; - const originalSend = session.send.bind(session); - vi.spyOn(session, "send").mockImplementation((method, params) => { - if (method === "Runtime.evaluate") { - const expr = String(params?.expression ?? ""); - if (expr.includes("tool-call")) { - // payload is double-JSON-encoded so quotes appear as \" in the expression value - const match = /\\"callId\\":\\"([a-f0-9-]+)/.exec(expr); - if (match) capturedCallId = match[1]; - } - } - return originalSend(method, params); - }); - - const callPromise = runCommand(plugin, "call", session, { name: "app.myTool", arguments: { x: 1 } }); - - await Promise.resolve(); - await Promise.resolve(); - - expect(capturedCallId).toBeDefined(); - session.emitBinding({ type: "tool-result", callId: capturedCallId!, success: true, result: { value: 42 } }); - - const result = await callPromise; - expect(result).toEqual({ ok: true, data: { success: true, result: { value: 42 } } }); + const result = await runCommand(plugin, "tool-schema", session, { name: "unknown" }); + expect(result).toEqual({ ok: false, error: expect.stringContaining("unknown") }); }); - it("call returns success:false data for tool-level failure (not an IPC error)", async () => { - session.emitBinding({ - type: "register-tool", - tools: [{ name: "myTool", description: "A tool", inputSchema: {} }], - }); - - let capturedCallId: string | undefined; - const originalSend = session.send.bind(session); - vi.spyOn(session, "send").mockImplementation((method, params) => { - if (method === "Runtime.evaluate") { - const expr = String(params?.expression ?? ""); - if (expr.includes("tool-call")) { - const match = /\\"callId\\":\\"([a-f0-9-]+)/.exec(expr); - if (match) capturedCallId = match[1]; - } - } - return originalSend(method, params); - }); - - const callPromise = runCommand(plugin, "call", session, { name: "app.myTool" }); - - await Promise.resolve(); - await Promise.resolve(); - - expect(capturedCallId).toBeDefined(); - session.emitBinding({ type: "tool-result", callId: capturedCallId!, success: false, error: "something went wrong" }); - - const result = await callPromise; - expect(result).toEqual({ ok: true, data: { success: false, error: "something went wrong" } }); + it("call returns tool result", async () => { + const result = await runCommand(plugin, "call", session, { name: "echo", arguments: { text: "hi" } }); + expect(result).toEqual({ ok: true, data: { value: 42 } }); }); - it("call rejects with IPC error on disconnect", async () => { - session.emitBinding({ - type: "register-tool", - tools: [{ name: "myTool", description: "A tool", inputSchema: {} }], - }); - - const callPromise = runCommand(plugin, "call", session, { name: "app.myTool" }); - - await Promise.resolve(); - session.emitDisconnect(); + it("call returns error when Rozenite reports failure", async () => { + vi.stubGlobal( + "fetch", + makeConnectFetch({ + [`POST ${SESSION_CALL_URL}`]: { ok: false, error: { message: "Tool threw: something went wrong" } }, + }) + ); + const plugin2 = new RozenitePlugin(); + await plugin2.onTargetSelected(makeTargetContext(new FakeSession())); + await flushConnect(); - const result = await callPromise; - expect(result).toEqual({ ok: false, error: expect.stringContaining("disconnected") }); + const result = await runCommand(plugin2, "call", new FakeSession(), { name: "echo" }); + expect(result).toEqual({ ok: false, error: expect.stringContaining("something went wrong") }); }); - it("call returns error for unknown tool", async () => { - const result = await runCommand(plugin, "call", session, { name: "app.unknown" }); - expect(result).toEqual({ ok: false, error: expect.stringContaining("app.unknown") }); + it("call returns error for no active session", async () => { + const freshPlugin = new RozenitePlugin(); + const result = await runCommand(freshPlugin, "call", null, { name: "echo" }); + expect(result).toEqual({ ok: false, error: expect.stringContaining("No active Rozenite session") }); }); }); }); \ No newline at end of file diff --git a/packages/agent-cdp/src/plugins/rozenite/bootstrap.ts b/packages/agent-cdp/src/plugins/rozenite/bootstrap.ts deleted file mode 100644 index d0b9a66..0000000 --- a/packages/agent-cdp/src/plugins/rozenite/bootstrap.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { AgentPluginTargetSession } from "../../plugin.js"; -import { DOMAIN_NAME, POLL_INTERVAL_MS, POLL_TIMEOUT_MS, RUNTIME_GLOBAL } from "./protocol.js"; - -export interface BootstrapResult { - bindingName: string; -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export async function bootstrapRozenite( - session: AgentPluginTargetSession, - signal: AbortSignal -): Promise { - const deadline = Date.now() + POLL_TIMEOUT_MS; - - while (Date.now() < deadline) { - if (signal.aborted) throw new Error("aborted"); - - const evalResult = (await session.send("Runtime.evaluate", { - expression: `typeof ${RUNTIME_GLOBAL} !== 'undefined'`, - returnByValue: true, - })) as { result: { value: unknown } }; - - if (evalResult.result.value === true) break; - - await delay(POLL_INTERVAL_MS); - } - - if (signal.aborted) throw new Error("aborted"); - - if (Date.now() >= deadline) { - throw new Error( - `${RUNTIME_GLOBAL} not found after ${POLL_TIMEOUT_MS / 1000}s — is Rozenite integrated in this app?` - ); - } - - const bindingResult = (await session.send("Runtime.evaluate", { - expression: `${RUNTIME_GLOBAL}.BINDING_NAME`, - returnByValue: true, - })) as { result: { value: unknown } }; - - const bindingName = String(bindingResult.result.value); - - await session.send("Runtime.addBinding", { name: bindingName }); - - await session.send("Runtime.evaluate", { - expression: `${RUNTIME_GLOBAL}.initializeDomain('${DOMAIN_NAME}')`, - }); - - return { bindingName }; -} \ No newline at end of file diff --git a/packages/agent-cdp/src/plugins/rozenite/index.ts b/packages/agent-cdp/src/plugins/rozenite/index.ts index 7749608..eb1f374 100644 --- a/packages/agent-cdp/src/plugins/rozenite/index.ts +++ b/packages/agent-cdp/src/plugins/rozenite/index.ts @@ -1,5 +1,3 @@ -import crypto from "node:crypto"; - import type { TargetDescriptor } from "@agent-cdp/protocol"; import type { @@ -8,31 +6,24 @@ import type { AgentPluginDetachContext, AgentPluginState, AgentPluginTargetContext, - AgentPluginTargetSession, } from "../../plugin.js"; -import { bootstrapRozenite } from "./bootstrap.js"; import { - DOMAIN_NAME, - RUNTIME_GLOBAL, - type AgentToAppMessage, - type AppToAgentMessage, - type BindingPayload, + ROZENITE_AGENT_BASE, + type RozeniteApiResponse, + type RozeniteApiTool, + type RozeniteSessionInfo, } from "./protocol.js"; -import { RozeniteToolRegistry } from "./tool-registry.js"; export class RozenitePlugin implements AgentPlugin { readonly id = "rozenite"; readonly displayName = "Rozenite"; - readonly description = "Rozenite React Native devtools bridge"; + readonly description = "Rozenite React Native agent bridge"; readonly commands: readonly AgentPluginCommand[]; private state: AgentPluginState = { kind: "idle" }; - private readonly registry = new RozeniteToolRegistry(); + private sessionId: string | null = null; + private metroBaseUrl: string | null = null; private abortController: AbortController | null = null; - private readonly pendingCalls = new Map void; - reject: (reason: Error) => void; - }>(); constructor() { this.commands = this.buildCommands(); @@ -47,11 +38,11 @@ export class RozenitePlugin implements AgentPlugin { } async onTargetSelected(ctx: AgentPluginTargetContext): Promise { - this.state = { kind: "waiting-for-runtime", reason: `Waiting for ${RUNTIME_GLOBAL}` }; - this.registry.clear(); + this.state = { kind: "waiting-for-runtime", reason: "Connecting to Rozenite HTTP agent..." }; + this.sessionId = null; + this.metroBaseUrl = null; this.abortController = new AbortController(); - ctx.session.onDisconnected(() => this.handleDisconnect()); - void this.runBootstrap(ctx.session); + void this.connect(ctx.session.target); } async onTargetReconnected(ctx: AgentPluginTargetContext): Promise { @@ -59,78 +50,61 @@ export class RozenitePlugin implements AgentPlugin { } async onTargetCleared(_ctx: AgentPluginDetachContext): Promise { - this.teardown(new Error("Target cleared")); + await this.teardown(); this.state = { kind: "idle" }; } - private handleDisconnect(): void { - this.teardown(new Error("Target disconnected")); - this.state = { kind: "idle" }; - } - - private teardown(error: Error): void { - this.abortController?.abort(); + private async teardown(): Promise { + const ctrl = this.abortController; this.abortController = null; - this.registry.clear(); - for (const pending of this.pendingCalls.values()) { - pending.reject(error); + ctrl?.abort(); + + const { sessionId, metroBaseUrl } = this; + this.sessionId = null; + this.metroBaseUrl = null; + + if (sessionId && metroBaseUrl) { + void fetch(`${metroBaseUrl}${ROZENITE_AGENT_BASE}/sessions/${sessionId}`, { + method: "DELETE", + }).catch(() => {}); } - this.pendingCalls.clear(); } - private async runBootstrap(session: AgentPluginTargetSession): Promise { + private async connect(target: TargetDescriptor): Promise { + const metroBaseUrl = target.sourceUrl; + const deviceId = target.reactNative?.logicalDeviceId; + const signal = this.abortController?.signal; + try { - const { bindingName } = await bootstrapRozenite(session, this.abortController!.signal); - - session.onEvent((event) => { - if (event.method !== "Runtime.bindingCalled") return; - const params = event.params as { name?: string; payload?: string }; - if (params.name !== bindingName) return; - try { - const envelope = JSON.parse(params.payload ?? "") as BindingPayload; - if (envelope.domain !== DOMAIN_NAME) return; - this.handleMessage(envelope.message as AppToAgentMessage); - } catch {} + const body: Record = {}; + if (deviceId) body.deviceId = deviceId; + + const response = await fetch(`${metroBaseUrl}${ROZENITE_AGENT_BASE}/sessions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, }); - await this.sendToApp(session, { type: "agent-session-ready" }); - this.state = { kind: "ready" }; - } catch (err) { - if ((err as Error).message !== "aborted") { - this.state = { kind: "error", reason: (err as Error).message }; + const json = (await response.json()) as RozeniteApiResponse<{ session: RozeniteSessionInfo }>; + + if (signal?.aborted) return; + + if (!json.ok) { + throw new Error(json.error?.message ?? "Failed to create Rozenite session"); } - } - } - private handleMessage(msg: AppToAgentMessage): void { - switch (msg.type) { - case "register-tool": - this.registry.register("app", msg.tools); - break; - case "unregister-tool": - this.registry.unregister(msg.toolNames); - break; - case "tool-result": { - const pending = this.pendingCalls.get(msg.callId); - if (!pending) return; - this.pendingCalls.delete(msg.callId); - if (msg.success) { - pending.resolve({ success: true, result: msg.result }); - } else { - pending.resolve({ success: false, error: msg.error }); - } - break; + this.metroBaseUrl = metroBaseUrl; + this.sessionId = json.result!.session.id; + this.state = { kind: "ready" }; + } catch (err) { + const error = err as Error; + if (error.name !== "AbortError" && !signal?.aborted) { + this.state = { kind: "error", reason: error.message }; } } } - private async sendToApp(session: AgentPluginTargetSession, message: AgentToAppMessage): Promise { - const payload = JSON.stringify(JSON.stringify(message)); - await session.send("Runtime.evaluate", { - expression: `${RUNTIME_GLOBAL}.sendMessage('${DOMAIN_NAME}', ${payload})`, - }); - } - private buildCommands(): AgentPluginCommand[] { return [ { @@ -139,10 +113,20 @@ export class RozenitePlugin implements AgentPlugin { alwaysExecutable: true, execute: async (ctx) => { const state = ctx.getState(); + let toolCount = 0; + if (state.kind === "ready" && this.sessionId && this.metroBaseUrl) { + try { + const resp = await fetch( + `${this.metroBaseUrl}${ROZENITE_AGENT_BASE}/sessions/${this.sessionId}` + ); + const json = (await resp.json()) as RozeniteApiResponse<{ session: RozeniteSessionInfo }>; + if (json.ok && json.result) toolCount = json.result.session.toolCount; + } catch {} + } return { state: state.kind, ...(state.kind === "error" ? { error: state.reason } : {}), - toolCount: this.registry.size, + toolCount, target: ctx.session?.target ?? null, }; }, @@ -151,10 +135,12 @@ export class RozenitePlugin implements AgentPlugin { name: "tools", summary: "List registered Rozenite tools", execute: async () => { - return this.registry.list().map((t) => ({ - name: t.qualifiedName, - description: t.description, - })); + const { sessionId, metroBaseUrl } = this; + if (!sessionId || !metroBaseUrl) throw new Error("No active Rozenite session"); + const resp = await fetch(`${metroBaseUrl}${ROZENITE_AGENT_BASE}/sessions/${sessionId}/tools`); + const json = (await resp.json()) as RozeniteApiResponse<{ tools: RozeniteApiTool[] }>; + if (!json.ok) throw new Error(json.error?.message ?? "Failed to list tools"); + return (json.result?.tools ?? []).map((t) => ({ name: t.name, description: t.description })); }, }, { @@ -162,7 +148,12 @@ export class RozenitePlugin implements AgentPlugin { summary: "Show input schema for a Rozenite tool", execute: async (_ctx, input) => { const { name } = input as { name: string }; - const tool = this.registry.get(name); + const { sessionId, metroBaseUrl } = this; + if (!sessionId || !metroBaseUrl) throw new Error("No active Rozenite session"); + const resp = await fetch(`${metroBaseUrl}${ROZENITE_AGENT_BASE}/sessions/${sessionId}/tools`); + const json = (await resp.json()) as RozeniteApiResponse<{ tools: RozeniteApiTool[] }>; + if (!json.ok) throw new Error(json.error?.message ?? "Failed to fetch tools"); + const tool = (json.result?.tools ?? []).find((t) => t.name === name); if (!tool) throw new Error(`Tool '${name}' not found`); return tool.inputSchema; }, @@ -170,32 +161,21 @@ export class RozenitePlugin implements AgentPlugin { { name: "call", summary: "Call a Rozenite tool", - execute: async (ctx, input) => { + execute: async (_ctx, input) => { const { name, arguments: args } = input as { name: string; arguments?: unknown }; - const tool = this.registry.get(name); - if (!tool) throw new Error(`Tool '${name}' not found`); - - const callId = crypto.randomUUID(); - return new Promise((resolve, reject) => { - this.pendingCalls.set(callId, { resolve, reject }); - - void this.sendToApp(ctx.session!, { - type: "tool-call", - callId, - toolName: name, - arguments: args ?? null, - }).catch((err: unknown) => { - this.pendingCalls.delete(callId); - reject(err instanceof Error ? err : new Error(String(err))); - }); - - setTimeout(() => { - if (this.pendingCalls.has(callId)) { - this.pendingCalls.delete(callId); - reject(new Error(`Tool call '${name}' timed out after 60s`)); - } - }, 60_000); - }); + const { sessionId, metroBaseUrl } = this; + if (!sessionId || !metroBaseUrl) throw new Error("No active Rozenite session"); + const resp = await fetch( + `${metroBaseUrl}${ROZENITE_AGENT_BASE}/sessions/${sessionId}/call-tool`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ toolName: name, args: args ?? null }), + } + ); + const json = (await resp.json()) as RozeniteApiResponse<{ result: unknown }>; + if (!json.ok) throw new Error(json.error?.message ?? "Tool call failed"); + return json.result?.result; }, }, ]; diff --git a/packages/agent-cdp/src/plugins/rozenite/protocol.ts b/packages/agent-cdp/src/plugins/rozenite/protocol.ts index bd7dc01..336a350 100644 --- a/packages/agent-cdp/src/plugins/rozenite/protocol.ts +++ b/packages/agent-cdp/src/plugins/rozenite/protocol.ts @@ -1,25 +1,25 @@ -export const RUNTIME_GLOBAL = "__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__"; -export const DOMAIN_NAME = "rozenite"; -export const POLL_INTERVAL_MS = 500; -export const POLL_TIMEOUT_MS = 30_000; +export const ROZENITE_AGENT_BASE = "/rozenite/agent"; -export interface AgentTool { +export interface RozeniteApiTool { name: string; description: string; inputSchema: object; } -export type AppToAgentMessage = - | { type: "register-tool"; tools: AgentTool[] } - | { type: "unregister-tool"; toolNames: string[] } - | { type: "tool-result"; callId: string; success: true; result: unknown } - | { type: "tool-result"; callId: string; success: false; error: string }; - -export type AgentToAppMessage = - | { type: "agent-session-ready" } - | { type: "tool-call"; callId: string; toolName: string; arguments: unknown }; +export interface RozeniteSessionInfo { + id: string; + deviceId: string; + deviceName: string; + status: string; + toolCount: number; + createdAt: number; + lastActivityAt: number; + connectedAt?: number; + lastError?: string; +} -export interface BindingPayload { - domain: string; - message: unknown; +export interface RozeniteApiResponse { + ok: boolean; + result?: T; + error?: { message: string }; } \ No newline at end of file diff --git a/packages/agent-cdp/src/plugins/rozenite/tool-registry.ts b/packages/agent-cdp/src/plugins/rozenite/tool-registry.ts deleted file mode 100644 index 53bdfad..0000000 --- a/packages/agent-cdp/src/plugins/rozenite/tool-registry.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { AgentTool } from "./protocol.js"; - -export interface RegisteredTool extends AgentTool { - qualifiedName: string; -} - -export class RozeniteToolRegistry { - private readonly tools = new Map(); - - register(domain: string, tools: AgentTool[]): void { - for (const tool of tools) { - const qualifiedName = `${domain}.${tool.name}`; - this.tools.set(qualifiedName, { ...tool, qualifiedName }); - } - } - - unregister(toolNames: string[]): void { - for (const name of toolNames) { - this.tools.delete(name); - } - } - - clear(): void { - this.tools.clear(); - } - - list(): RegisteredTool[] { - return [...this.tools.values()]; - } - - get(qualifiedName: string): RegisteredTool | undefined { - return this.tools.get(qualifiedName); - } - - get size(): number { - return this.tools.size; - } -} \ No newline at end of file diff --git a/playground/metro.config.js b/playground/metro.config.js new file mode 100644 index 0000000..ca627da --- /dev/null +++ b/playground/metro.config.js @@ -0,0 +1,8 @@ +const { getDefaultConfig } = require('expo/metro-config'); +const { withRozenite } = require('@rozenite/metro'); + +const config = getDefaultConfig(__dirname); + +module.exports = withRozenite(config, { + enabled: process.env.WITH_ROZENITE === 'true', +}); \ No newline at end of file diff --git a/playground/package.json b/playground/package.json index 0d7e85b..22aa68b 100644 --- a/playground/package.json +++ b/playground/package.json @@ -14,6 +14,7 @@ "@react-navigation/bottom-tabs": "^7.15.5", "@react-navigation/elements": "^2.9.10", "@react-navigation/native": "^7.1.33", + "@rozenite/agent-bridge": "^1.10.0", "expo": "~55.0.24", "expo-constants": "~55.0.16", "expo-device": "~55.0.17", @@ -38,6 +39,7 @@ "react-native-worklets": "0.7.4" }, "devDependencies": { + "@rozenite/metro": "^1.10.0", "@types/react": "~19.2.2", "eslint": "^9.0.0", "eslint-config-expo": "~55.0.1", diff --git a/playground/src/app/index.tsx b/playground/src/app/index.tsx index 3ffba37..3ab0e7b 100644 --- a/playground/src/app/index.tsx +++ b/playground/src/app/index.tsx @@ -16,6 +16,8 @@ import { type TraceStopResponse, } from '@agent-cdp/sdk'; import { useState } from 'react'; + +import { ROZENITE_TOOL_COUNT, useRozeniteBridge } from '@/hooks/use-rozenite-bridge'; import { Pressable, ScrollView, StyleSheet } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -203,6 +205,38 @@ function formatBytes(value: number) { return `${Math.round(value / 1024)} KB`; } +const rozeniteAvailable = + typeof (globalThis as Record).__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__ !== 'undefined'; + +function RozeniteBridgeSection() { + const { lastCall } = useRozeniteBridge(); + + return ( + + Rozenite Agent Tools + + {ROZENITE_TOOL_COUNT} test tools registered: app.echo, app.getTimestamp, app.getPlaygroundInfo. + Call them with: agent-cdp rozenite call app.echo --input '{`{"text":"hello"}`}' + + + Tools registered: {ROZENITE_TOOL_COUNT} + {lastCall ? ( + <> + Last call: {lastCall.name} + + {JSON.stringify(lastCall.result)} + + + ) : ( + + No tool calls yet. Use agent-cdp rozenite call to invoke a tool. + + )} + + + ); +} + export default function HomeScreen() { const [sdkBusy, setSdkBusy] = useState(false); const [sdkFlowMessage, setSdkFlowMessage] = useState('Select the app target with agent-cdp, then run the SDK CPU profile flow.'); @@ -655,6 +689,7 @@ export default function HomeScreen() { + {rozeniteAvailable && } SDK Testing diff --git a/playground/src/hooks/use-rozenite-bridge.ts b/playground/src/hooks/use-rozenite-bridge.ts new file mode 100644 index 0000000..0524d8a --- /dev/null +++ b/playground/src/hooks/use-rozenite-bridge.ts @@ -0,0 +1,68 @@ +import { useState } from 'react'; + +import { useRozeniteInAppAgentTool } from '@rozenite/agent-bridge'; + +export type RozeniteBridgeState = { + lastCall: { name: string; result: unknown; ts: number } | null; +}; + +const echoTool = { + name: 'echo', + description: 'Returns the provided text argument unchanged', + inputSchema: { + type: 'object', + properties: { text: { type: 'string', description: 'Text to echo back' } }, + required: ['text'], + }, +}; + +const getTimestampTool = { + name: 'getTimestamp', + description: 'Returns the current device date/time as an ISO string', + inputSchema: { type: 'object', properties: {} }, +}; + +const getPlaygroundInfoTool = { + name: 'getPlaygroundInfo', + description: 'Returns basic information about the playground runtime environment', + inputSchema: { type: 'object', properties: {} }, +}; + +// Total number of test tools registered by useRozeniteBridge +export const ROZENITE_TOOL_COUNT = 3; + +export function useRozeniteBridge(): RozeniteBridgeState { + const [lastCall, setLastCall] = useState(null); + + useRozeniteInAppAgentTool({ + tool: echoTool, + handler: (args: { text?: string }) => { + const result = { echo: args?.text ?? '' }; + setLastCall({ name: 'app.echo', result, ts: Date.now() }); + return result; + }, + }); + + useRozeniteInAppAgentTool({ + tool: getTimestampTool, + handler: () => { + const result = { timestamp: new Date().toISOString() }; + setLastCall({ name: 'app.getTimestamp', result, ts: Date.now() }); + return result; + }, + }); + + useRozeniteInAppAgentTool({ + tool: getPlaygroundInfoTool, + handler: () => { + const result = { + hermes: typeof (globalThis as Record).HermesInternal !== 'undefined', + now: Date.now(), + }; + setLastCall({ name: 'app.getPlaygroundInfo', result, ts: Date.now() }); + return result; + }, + }); + + return { lastCall }; +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca30146..e84ae75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,6 +91,9 @@ importers: '@react-navigation/native': specifier: ^7.1.33 version: 7.2.4(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) + '@rozenite/agent-bridge': + specifier: ^1.10.0 + version: 1.10.0(react@19.2.0) expo: specifier: ~55.0.24 version: 55.0.24(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) @@ -158,6 +161,9 @@ importers: specifier: 0.7.4 version: 0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) devDependencies: + '@rozenite/metro': + specifier: ^1.10.0 + version: 1.10.0 '@types/react': specifier: ~19.2.2 version: 19.2.14 @@ -1170,21 +1176,25 @@ packages: resolution: {integrity: sha512-KlwzidgvHznbUaaglZT1goTS30osTV553pfbKve9B1PyTDkluNDfm/polOaf3SVLN7wL/NNLFZRMupvJ1eJXAw==} cpu: [arm64] os: [linux] + libc: [glibc] '@oxfmt/linux-arm64-musl@0.17.0': resolution: {integrity: sha512-+tbYJTocF4BNLaQQbc/xrBWTNgiU6zmYeF4NvRDxuuQjDOnmUZPn0EED3PZBRJyg4/YllhplHDo8x+gfcb9G3A==} cpu: [arm64] os: [linux] + libc: [musl] '@oxfmt/linux-x64-gnu@0.17.0': resolution: {integrity: sha512-pEmv7zJIw2HpnA4Tn1xrfJNGi2wOH2+usT14Pkvf/c5DdB+pOir6k/5jzfe70+V3nEtmtV9Lm+spndN/y6+X7A==} cpu: [x64] os: [linux] + libc: [glibc] '@oxfmt/linux-x64-musl@0.17.0': resolution: {integrity: sha512-+DrFSCZWyFdtEAWR5xIBTV8GX0RA9iB+y7ZlJPRAXrNG8TdBY9vc7/MIGolIgrkMPK4mGMn07YG/qEyPY+iKaw==} cpu: [x64] os: [linux] + libc: [musl] '@oxfmt/win32-arm64@0.17.0': resolution: {integrity: sha512-FoUZRR7mVpTYIaY/qz2BYwzqMnL+HsUxmMWAIy6nl29UEkDgxNygULJ4rIGY4/Axne41fhtldLrSGBOpwNm3jA==} @@ -1210,21 +1220,25 @@ packages: resolution: {integrity: sha512-NoAWscdfVj6Sci3NdbHHc1OivSSKpwtkLff5SoAM8XgJ9t7flf+zW7XOy3OeSgZAxNbcF4QGruv+XcBLR7tWMA==} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/linux-arm64-musl@0.17.0': resolution: {integrity: sha512-VQRmSdbuc0rpSZoLqdhKJG9nUjJmEymOU60dO3lKlCT5YXME4dxA+jf1AigtnmJS5FgOxQm54ECF9lh6dyP0ew==} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/linux-x64-gnu@0.17.0': resolution: {integrity: sha512-KcaXWkBfqt7weerU1EXJysEupEHB8AtJytufBOQMuLE5vx2OoAbjAk0vt2V14W8Lss9Sz+ET7nXo6ZAEku4vCA==} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/linux-x64-musl@0.17.0': resolution: {integrity: sha512-yWbFXWKKTrH4zR0FI1V6KDJp5NqOLFe1LZbKNeaoS1wtq5/aFPeM+d9dttGLoA5u6G9uhIK/nSnrPmtuNLPU4Q==} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/win32-arm64@0.17.0': resolution: {integrity: sha512-zdoB3mbvcx3eGOh6ElPJ01S2MOzyZo/gijeHw7yb2PXcRis+3clVje6kpnG7/TN69zoHv7WwDX6poJu8FURTqg==} @@ -1611,66 +1625,79 @@ packages: resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.2': resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.2': resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.2': resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.2': resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.2': resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.2': resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.2': resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.2': resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.2': resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.2': resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.2': resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.2': resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.2': resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} @@ -1702,6 +1729,37 @@ packages: cpu: [x64] os: [win32] + '@rozenite/agent-bridge@1.10.0': + resolution: {integrity: sha512-Rnfr85m3Ya91VG5r1x9+gSJ5gOCZLw4Yec0zBijdUSXxhH9LoChIuniG41hgxBYYKvMG22oeJnpI2atMdzFAgw==} + engines: {node: '>=20'} + peerDependencies: + react: '>=17' + + '@rozenite/agent-shared@1.10.0': + resolution: {integrity: sha512-iZimNxQFE0qsF4nucVYLt1AYrI6WRXlscL6QVHKkfaI0/AdI14cdGbJEoxuS7Twpkru4/4v0QScsM1OIPZxgqg==} + engines: {node: '>=20'} + + '@rozenite/metro@1.10.0': + resolution: {integrity: sha512-0+FVgu4+UAr0c/qm6+iP+mY38XtfKgcWzX1Jh9QNVFVYAEo3nmr9DwOJsGIB++sOhnHwtqh4Ry21+ouXS8P4Kw==} + engines: {node: '>=20'} + + '@rozenite/middleware@1.10.0': + resolution: {integrity: sha512-w5HrGKwSNC8HRJe8BleL/i4EF4BXJq8uU37oZ6EnL4amGKnbOobfCGCoj4XSEwX5EJYHwVchRfZMAtHtCwUlWQ==} + engines: {node: '>=20'} + + '@rozenite/plugin-bridge@1.10.0': + resolution: {integrity: sha512-kUu4UdDOoemJqB8wxfDaSgGKQuPjJPYCiC0QU5c/tNp16dIzrSA4IX12c7bYzkeM55YMU17MXnXpXH1MEGW1qw==} + engines: {node: '>=20'} + peerDependencies: + react: '>=17' + + '@rozenite/runtime@1.10.0': + resolution: {integrity: sha512-RvCBUl9g+fUTrGgKoY/0pVzrYXpKJzYddNDa0O/Pfgrevxn8E/hQ/5nzSXONH0I6X8lYDeQyDNvGLbbb96pgYg==} + engines: {node: '>=20'} + + '@rozenite/tools@1.10.0': + resolution: {integrity: sha512-tbEz6fc7yXMCzRQwHQIOasaj/XvKVG/m+fnegl6sP8SVMR+xP141Zw6rMJS3OD5AO1ln1mH4CY78dpnPt7td1A==} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -1878,41 +1936,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -2187,6 +2253,10 @@ packages: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + bplist-creator@0.1.0: resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} @@ -2376,9 +2446,29 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + core-js-compat@3.49.0: resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} @@ -2872,6 +2962,10 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2923,6 +3017,10 @@ packages: resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} engines: {node: '>= 0.8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -2951,10 +3049,18 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -3113,6 +3219,10 @@ packages: hyphenate-style-name@1.1.0: resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3151,6 +3261,10 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -3230,6 +3344,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3432,24 +3549,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -3529,12 +3650,20 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} memoize-one@6.0.0: resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -3854,6 +3983,9 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -3949,10 +4081,18 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + query-string@7.1.3: resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} engines: {node: '>=6'} @@ -3964,6 +4104,10 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + react-devtools-core@6.1.5: resolution: {integrity: sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==} @@ -4164,6 +4308,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + safe-array-concat@1.1.4: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} @@ -4179,6 +4327,9 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} @@ -4209,6 +4360,10 @@ packages: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serialize-error@2.1.0: resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} engines: {node: '>=0.10.0'} @@ -4217,6 +4372,10 @@ packages: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} @@ -4555,6 +4714,10 @@ packages: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -6639,6 +6802,52 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.2': optional: true + '@rozenite/agent-bridge@1.10.0(react@19.2.0)': + dependencies: + '@rozenite/agent-shared': 1.10.0 + '@rozenite/plugin-bridge': 1.10.0(react@19.2.0) + react: 19.2.0 + tslib: 2.8.1 + + '@rozenite/agent-shared@1.10.0': + dependencies: + tslib: 2.8.1 + + '@rozenite/metro@1.10.0': + dependencies: + '@rozenite/middleware': 1.10.0 + '@rozenite/tools': 1.10.0 + tslib: 2.8.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@rozenite/middleware@1.10.0': + dependencies: + '@rozenite/agent-shared': 1.10.0 + '@rozenite/runtime': 1.10.0 + '@rozenite/tools': 1.10.0 + express: 5.2.1 + semver: 7.8.0 + tslib: 2.8.1 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@rozenite/plugin-bridge@1.10.0(react@19.2.0)': + dependencies: + react: 19.2.0 + tslib: 2.8.1 + + '@rozenite/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + + '@rozenite/tools@1.10.0': {} + '@rtsao/scc@1.1.0': {} '@sinclair/typebox@0.27.10': {} @@ -7215,6 +7424,20 @@ snapshots: big-integer@1.6.52: {} + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + bplist-creator@0.1.0: dependencies: stream-buffers: 2.2.0 @@ -7416,8 +7639,18 @@ snapshots: consola@3.4.2: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + core-js-compat@3.49.0: dependencies: browserslist: 4.28.2 @@ -8084,6 +8317,39 @@ snapshots: exponential-backoff@3.1.3: {} + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -8138,6 +8404,17 @@ snapshots: transitivePeerDependencies: - supports-color + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -8169,8 +8446,12 @@ snapshots: dependencies: is-callable: 1.2.7 + forwarded@0.2.0: {} + fresh@0.5.2: {} + fresh@2.0.0: {} + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -8330,6 +8611,10 @@ snapshots: hyphenate-style-name@1.1.0: {} + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -8366,6 +8651,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + ipaddr.js@1.9.1: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -8445,6 +8732,8 @@ snapshots: is-number@7.0.0: {} + is-promise@4.0.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -8746,10 +9035,14 @@ snapshots: math-intrinsics@1.1.0: {} + media-typer@1.1.0: {} + memoize-one@5.2.1: {} memoize-one@6.0.0: {} + merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} metro-babel-transformer@0.83.7: @@ -9182,6 +9475,8 @@ snapshots: lru-cache: 11.3.6 minipass: 7.1.3 + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} pathval@2.0.1: {} @@ -9262,8 +9557,17 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + punycode@2.3.1: {} + qs@6.15.2: + dependencies: + side-channel: 1.1.0 + query-string@7.1.3: dependencies: decode-uri-component: 0.2.2 @@ -9277,6 +9581,13 @@ snapshots: range-parser@1.2.1: {} + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + react-devtools-core@6.1.5: dependencies: shell-quote: 1.8.3 @@ -9564,6 +9875,16 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.2 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + safe-array-concat@1.1.4: dependencies: call-bind: 1.0.9 @@ -9585,6 +9906,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safer-buffer@2.1.2: {} + sax@1.6.0: {} scheduler@0.27.0: {} @@ -9615,6 +9938,22 @@ snapshots: transitivePeerDependencies: - supports-color + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + serialize-error@2.1.0: {} serve-static@1.16.3: @@ -9626,6 +9965,15 @@ snapshots: transitivePeerDependencies: - supports-color + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + server-only@0.0.1: {} set-function-length@1.2.2: @@ -9971,6 +10319,12 @@ snapshots: type-fest@0.7.1: {} + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 From fc808f6d04af430163f3f5a3dbd57345429dce2b Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Fri, 22 May 2026 08:22:27 +0200 Subject: [PATCH 8/9] feat: restore CDP-based Rozenite plugin with debug logging Replaces the HTTP API approach with the correct CDP implementation using Runtime.addBinding / Runtime.bindingCalled. Tools are discovered dynamically from binding events rather than polled via HTTP. Key fixes over the original attempt: Runtime.enable is called before Runtime.addBinding, and the onEvent listener is registered before initializeDomain is called to avoid missing early binding events. Includes verbose console.log output on every Runtime.bindingCalled event to aid investigation of CDP session behaviour. --- .../src/__tests__/rozenite-plugin.test.ts | 432 +++++++++++------- .../src/plugins/rozenite/bootstrap.ts | 71 +++ .../agent-cdp/src/plugins/rozenite/index.ts | 278 +++++++---- .../src/plugins/rozenite/protocol.ts | 49 +- .../src/plugins/rozenite/tool-registry.ts | 33 ++ 5 files changed, 596 insertions(+), 267 deletions(-) create mode 100644 packages/agent-cdp/src/plugins/rozenite/bootstrap.ts create mode 100644 packages/agent-cdp/src/plugins/rozenite/tool-registry.ts diff --git a/packages/agent-cdp/src/__tests__/rozenite-plugin.test.ts b/packages/agent-cdp/src/__tests__/rozenite-plugin.test.ts index 118aa03..c848b34 100644 --- a/packages/agent-cdp/src/__tests__/rozenite-plugin.test.ts +++ b/packages/agent-cdp/src/__tests__/rozenite-plugin.test.ts @@ -1,31 +1,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { AgentPluginCommandContext, AgentPluginTargetContext, AgentPluginTargetSession } from "../plugin.js"; +import type { CdpEventMessage } from "../types.js"; +import type { + AgentPluginCommandContext, + AgentPluginTargetContext, + AgentPluginTargetSession, +} from "../plugin.js"; import { PluginOrchestrator } from "../plugin-orchestrator.js"; import { RozenitePlugin } from "../plugins/rozenite/index.js"; -import { ROZENITE_AGENT_BASE } from "../plugins/rozenite/protocol.js"; +import { AGENT_PLUGIN_ID, ROZENITE_DOMAIN } from "../plugins/rozenite/protocol.js"; import type { IpcResponse, TargetDescriptor } from "../types.js"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -const METRO_BASE = "http://localhost:8081"; -const DEVICE_ID = "test-device-id"; -const SESSION_URL = `${METRO_BASE}${ROZENITE_AGENT_BASE}/sessions`; -const SESSION_ID_URL = `${SESSION_URL}/${DEVICE_ID}`; -const SESSION_TOOLS_URL = `${SESSION_ID_URL}/tools`; -const SESSION_CALL_URL = `${SESSION_ID_URL}/call-tool`; - -const SESSION_INFO = { - id: DEVICE_ID, - deviceId: DEVICE_ID, - deviceName: "Test Device", - status: "connected", - toolCount: 0, - createdAt: 0, - lastActivityAt: 0, -}; +const BINDING_NAME = "__CHROME_DEVTOOLS_FRONTEND_BINDING__"; // --------------------------------------------------------------------------- // Targets @@ -38,8 +28,8 @@ const RN_TARGET: TargetDescriptor = { kind: "react-native", description: "", webSocketDebuggerUrl: "ws://localhost:8081/devtools/page/page-1", - sourceUrl: METRO_BASE, - reactNative: { logicalDeviceId: DEVICE_ID, capabilities: {} }, + sourceUrl: "http://localhost:8081", + reactNative: { logicalDeviceId: "test-device-id", capabilities: {} }, }; const CHROME_TARGET: TargetDescriptor = { @@ -53,23 +43,34 @@ const CHROME_TARGET: TargetDescriptor = { // --------------------------------------------------------------------------- class FakeSession implements AgentPluginTargetSession { - private readonly disconnectListeners: ((error?: Error) => void)[] = []; readonly target: TargetDescriptor; + private readonly eventListeners: Array<(event: CdpEventMessage) => void> = []; + private readonly disconnectListeners: Array<(error?: Error) => void> = []; + private connected = true; + + readonly sendHistory: Array<{ method: string; params?: Record }> = []; + sendImpl: (method: string, params?: Record) => Promise = () => + Promise.resolve({}); constructor(target: TargetDescriptor = RN_TARGET) { this.target = target; } isConnected(): boolean { - return true; + return this.connected; } - send(): Promise { - return Promise.resolve(undefined); + send(method: string, params?: Record): Promise { + this.sendHistory.push({ method, params }); + return this.sendImpl(method, params); } - onEvent(): () => void { - return () => {}; + onEvent(listener: (event: CdpEventMessage) => void): () => void { + this.eventListeners.push(listener); + return () => { + const i = this.eventListeners.indexOf(listener); + if (i >= 0) this.eventListeners.splice(i, 1); + }; } onDisconnected(listener: (error?: Error) => void): () => void { @@ -80,38 +81,50 @@ class FakeSession implements AgentPluginTargetSession { }; } + emitEvent(event: CdpEventMessage): void { + for (const listener of [...this.eventListeners]) listener(event); + } + emitDisconnect(error?: Error): void { - for (const listener of this.disconnectListeners) { - listener(error); - } + this.connected = false; + for (const listener of [...this.disconnectListeners]) listener(error); } } // --------------------------------------------------------------------------- -// Fetch mock helpers +// Bootstrap mock // --------------------------------------------------------------------------- -type FetchResponse = Record; +const { mockRunBootstrap } = vi.hoisted(() => ({ + mockRunBootstrap: vi + .fn<() => Promise>() + .mockResolvedValue("__CHROME_DEVTOOLS_FRONTEND_BINDING__"), +})); -function makeFetch(responses: Record) { - return vi.fn(async (url: string, init?: RequestInit) => { - const method = (init?.method ?? "GET").toUpperCase(); - const key = `${method} ${url}`; - const body = responses[key] ?? responses[url] ?? { ok: false, error: { message: "Not mocked" } }; - return { - ok: true, - json: async () => body, - }; - }); -} +vi.mock("../plugins/rozenite/bootstrap.js", () => ({ + runBootstrap: mockRunBootstrap, +})); -function makeConnectFetch(extra: Record = {}) { - return makeFetch({ - [`POST ${SESSION_URL}`]: { ok: true, result: { session: SESSION_INFO } }, - ...extra, - }); +// --------------------------------------------------------------------------- +// Event helpers +// --------------------------------------------------------------------------- + +function makeRozeniteEvent(type: string, payload: unknown): CdpEventMessage { + return { + method: "Runtime.bindingCalled", + params: { + name: BINDING_NAME, + payload: JSON.stringify({ + domain: ROZENITE_DOMAIN, + message: { pluginId: AGENT_PLUGIN_ID, type, payload }, + }), + }, + }; } +const ECHO_TOOL = { name: "app.echo", description: "Echoes text", inputSchema: { type: "object" } }; +const TS_TOOL = { name: "app.getTimestamp", description: "Returns timestamp", inputSchema: {} }; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -120,7 +133,10 @@ function makeTargetContext(session: FakeSession): AgentPluginTargetContext { return { pluginId: "rozenite", session }; } -function makeCommandContext(plugin: RozenitePlugin, session: FakeSession | null): AgentPluginCommandContext { +function makeCommandContext( + plugin: RozenitePlugin, + session: FakeSession | null, +): AgentPluginCommandContext { return { pluginId: "rozenite", session, @@ -132,9 +148,11 @@ async function runCommand( plugin: RozenitePlugin, name: string, session: FakeSession | null, - input?: unknown + input?: unknown, ): Promise { - const cmd = (plugin.commands as ReturnType[]).find((c) => c?.name === name); + const cmd = (plugin.commands as ReturnType[]).find( + (c) => c?.name === name, + ); if (!cmd) return { ok: false, error: `Unknown command '${name}'` }; const ctx = makeCommandContext(plugin, session); try { @@ -145,10 +163,8 @@ async function runCommand( } } -async function flushConnect(): Promise { - for (let i = 0; i < 5; i++) { - await Promise.resolve(); - } +async function flushAttach(): Promise { + for (let i = 0; i < 5; i++) await Promise.resolve(); } // --------------------------------------------------------------------------- @@ -156,10 +172,6 @@ async function flushConnect(): Promise { // --------------------------------------------------------------------------- describe("RozenitePlugin", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - describe("supportsTarget", () => { it("returns true for react-native targets", () => { const plugin = new RozenitePlugin(); @@ -173,116 +185,173 @@ describe("RozenitePlugin", () => { }); describe("connect", () => { - it("transitions to ready after successful session creation", async () => { - vi.stubGlobal("fetch", makeConnectFetch()); + afterEach(() => { + mockRunBootstrap.mockResolvedValue(BINDING_NAME); + }); + + it("transitions to ready after successful bootstrap", async () => { const plugin = new RozenitePlugin(); const session = new FakeSession(); expect(plugin.getState()).toEqual({ kind: "idle" }); await plugin.onTargetSelected(makeTargetContext(session)); - expect(plugin.getState()).toEqual({ kind: "waiting-for-runtime", reason: expect.any(String) }); + expect(plugin.getState()).toEqual({ + kind: "waiting-for-runtime", + reason: expect.any(String), + }); - await flushConnect(); + await flushAttach(); expect(plugin.getState()).toEqual({ kind: "ready" }); }); - it("sends deviceId in the POST body", async () => { - const mockFetch = makeConnectFetch(); - vi.stubGlobal("fetch", mockFetch); + it("calls initializeDomain and agent-session-ready after bootstrap", async () => { const plugin = new RozenitePlugin(); + const session = new FakeSession(); - await plugin.onTargetSelected(makeTargetContext(new FakeSession())); - await flushConnect(); + await plugin.onTargetSelected(makeTargetContext(session)); + await flushAttach(); - const postCall = mockFetch.mock.calls.find( - ([url, init]) => url === SESSION_URL && (init as RequestInit)?.method === "POST" + const initCall = session.sendHistory.find( + (c) => + c.method === "Runtime.evaluate" && + typeof c.params?.expression === "string" && + (c.params.expression as string).includes("initializeDomain"), ); - expect(postCall).toBeDefined(); - expect(JSON.parse((postCall![1] as RequestInit).body as string)).toEqual({ deviceId: DEVICE_ID }); - }); - - it("transitions to error when session creation fails", async () => { - vi.stubGlobal( - "fetch", - makeFetch({ [`POST ${SESSION_URL}`]: { ok: false, error: { message: "Rozenite not enabled" } } }) + expect(initCall).toBeDefined(); + const readyCall = session.sendHistory.find( + (c) => + c.method === "Runtime.evaluate" && + typeof c.params?.expression === "string" && + (c.params.expression as string).includes("agent-session-ready"), ); - const plugin = new RozenitePlugin(); - - await plugin.onTargetSelected(makeTargetContext(new FakeSession())); - await flushConnect(); - - expect(plugin.getState()).toEqual({ kind: "error", reason: "Rozenite not enabled" }); + expect(readyCall).toBeDefined(); }); - it("transitions to error when fetch throws (Metro unreachable)", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => { - throw new Error("fetch failed"); - }) - ); - const plugin = new RozenitePlugin(); + it("transitions to error when bootstrap rejects", async () => { + mockRunBootstrap.mockRejectedValueOnce(new Error("Bootstrap failed")); + const plugin = new RozenitePlugin(); await plugin.onTargetSelected(makeTargetContext(new FakeSession())); - await flushConnect(); + await flushAttach(); - expect(plugin.getState()).toEqual({ kind: "error", reason: "fetch failed" }); + expect(plugin.getState()).toEqual({ kind: "error", reason: "Bootstrap failed" }); }); - it("does not transition to error when target is cleared during connect", async () => { - vi.useFakeTimers(); - // Never resolves - vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); + it("does not transition to error when target is cleared during bootstrap", async () => { + mockRunBootstrap.mockImplementationOnce(() => new Promise(() => {})); + const plugin = new RozenitePlugin(); const session = new FakeSession(); await plugin.onTargetSelected(makeTargetContext(session)); - await plugin.onTargetCleared({ pluginId: "rozenite", target: RN_TARGET, reason: "target-cleared" }); + await plugin.onTargetCleared({ + pluginId: "rozenite", + target: RN_TARGET, + reason: "target-cleared", + }); + await flushAttach(); expect(plugin.getState()).toEqual({ kind: "idle" }); - vi.useRealTimers(); }); it("transitions back to idle after onTargetCleared", async () => { - vi.stubGlobal("fetch", makeConnectFetch({ [`DELETE ${SESSION_ID_URL}`]: { ok: true, result: { stopped: true } } })); const plugin = new RozenitePlugin(); + const session = new FakeSession(); - await plugin.onTargetSelected(makeTargetContext(new FakeSession())); - await flushConnect(); + await plugin.onTargetSelected(makeTargetContext(session)); + await flushAttach(); expect(plugin.getState()).toEqual({ kind: "ready" }); - await plugin.onTargetCleared({ pluginId: "rozenite", target: RN_TARGET, reason: "target-cleared" }); + await plugin.onTargetCleared({ + pluginId: "rozenite", + target: RN_TARGET, + reason: "target-cleared", + }); expect(plugin.getState()).toEqual({ kind: "idle" }); }); - it("DELETEs the session on onTargetCleared", async () => { - const mockFetch = makeConnectFetch({ - [`DELETE ${SESSION_ID_URL}`]: { ok: true, result: { stopped: true } }, - }); - vi.stubGlobal("fetch", mockFetch); + it("registers event listener before bootstrap completes", async () => { + // The event listener must be registered synchronously (before the async bootstrap) so + // early binding events are not missed. + let bootstrapResolved = false; + mockRunBootstrap.mockImplementationOnce( + () => + new Promise((resolve) => + setTimeout(() => { + bootstrapResolved = true; + resolve(BINDING_NAME); + }, 10), + ), + ); + const plugin = new RozenitePlugin(); + const session = new FakeSession(); - await plugin.onTargetSelected(makeTargetContext(new FakeSession())); - await flushConnect(); - await plugin.onTargetCleared({ pluginId: "rozenite", target: RN_TARGET, reason: "target-cleared" }); + // Don't await — just kick off + void plugin.onTargetSelected(makeTargetContext(session)); - const deleteCall = mockFetch.mock.calls.find( - ([url, init]) => url === SESSION_ID_URL && (init as RequestInit)?.method === "DELETE" - ); - expect(deleteCall).toBeDefined(); + // The listener is registered synchronously (before the first await in onTargetSelected), + // so it's already in place even before bootstrap resolves. + expect(bootstrapResolved).toBe(false); + expect( + (session as unknown as { eventListeners: unknown[] })["eventListeners"].length, + ).toBeGreaterThan(0); }); + }); - it("stays ready when CDP session disconnects (HTTP session is independent)", async () => { - vi.stubGlobal("fetch", makeConnectFetch()); - const plugin = new RozenitePlugin(); - const session = new FakeSession(); + describe("tool registration via binding events", () => { + let plugin: RozenitePlugin; + let session: FakeSession; + beforeEach(async () => { + plugin = new RozenitePlugin(); + session = new FakeSession(); await plugin.onTargetSelected(makeTargetContext(session)); - await flushConnect(); + await flushAttach(); + }); + + afterEach(async () => { + await plugin.onTargetCleared({ + pluginId: "rozenite", + target: RN_TARGET, + reason: "target-cleared", + }); + mockRunBootstrap.mockResolvedValue(BINDING_NAME); + }); + + it("registers tools from register-tool event", () => { + session.emitEvent(makeRozeniteEvent("register-tool", { tools: [ECHO_TOOL, TS_TOOL] })); expect(plugin.getState()).toEqual({ kind: "ready" }); + }); + + it("unregisters tools from unregister-tool event", () => { + session.emitEvent(makeRozeniteEvent("register-tool", { tools: [ECHO_TOOL, TS_TOOL] })); + session.emitEvent(makeRozeniteEvent("unregister-tool", { toolNames: ["app.echo"] })); + }); - session.emitDisconnect(); + it("ignores binding events with non-rozenite domain", () => { + session.emitEvent({ + method: "Runtime.bindingCalled", + params: { + name: BINDING_NAME, + payload: JSON.stringify({ domain: "react-devtools", message: {} }), + }, + }); + expect(plugin.getState()).toEqual({ kind: "ready" }); + }); + + it("ignores binding events with wrong pluginId", () => { + session.emitEvent({ + method: "Runtime.bindingCalled", + params: { + name: BINDING_NAME, + payload: JSON.stringify({ + domain: ROZENITE_DOMAIN, + message: { pluginId: "other-plugin", type: "register-tool", payload: { tools: [] } }, + }), + }, + }); expect(plugin.getState()).toEqual({ kind: "ready" }); }); }); @@ -292,50 +361,33 @@ describe("RozenitePlugin", () => { let session: FakeSession; beforeEach(async () => { - vi.stubGlobal( - "fetch", - makeConnectFetch({ - [SESSION_ID_URL]: { - ok: true, - result: { session: { ...SESSION_INFO, toolCount: 3 } }, - }, - [SESSION_TOOLS_URL]: { - ok: true, - result: { - tools: [ - { name: "echo", description: "Echoes text", inputSchema: { type: "object" } }, - { name: "getTimestamp", description: "Returns timestamp", inputSchema: { type: "object", properties: {} } }, - ], - }, - }, - [`POST ${SESSION_CALL_URL}`]: { - ok: true, - result: { result: { value: 42 } }, - }, - [`DELETE ${SESSION_ID_URL}`]: { ok: true, result: { stopped: true } }, - }) - ); plugin = new RozenitePlugin(); session = new FakeSession(); await plugin.onTargetSelected(makeTargetContext(session)); - await flushConnect(); + await flushAttach(); + session.emitEvent(makeRozeniteEvent("register-tool", { tools: [ECHO_TOOL, TS_TOOL] })); }); afterEach(async () => { - await plugin.onTargetCleared({ pluginId: "rozenite", target: RN_TARGET, reason: "target-cleared" }); - vi.unstubAllGlobals(); + await plugin.onTargetCleared({ + pluginId: "rozenite", + target: RN_TARGET, + reason: "target-cleared", + }); + mockRunBootstrap.mockResolvedValue(BINDING_NAME); }); it("status returns state, toolCount, and target", async () => { const result = await runCommand(plugin, "status", session); expect(result).toEqual({ ok: true, - data: { state: "ready", toolCount: 3, target: RN_TARGET }, + data: { state: "ready", toolCount: 2, target: RN_TARGET }, }); }); it("status works in waiting-for-runtime state (alwaysExecutable)", async () => { - vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); + mockRunBootstrap.mockImplementationOnce(() => new Promise(() => {})); + const plugin2 = new RozenitePlugin(); const session2 = new FakeSession(); const orchestrator = new PluginOrchestrator([plugin2]); @@ -347,7 +399,11 @@ describe("RozenitePlugin", () => { expect((result.data as { state: string }).state).toBe("waiting-for-runtime"); expect((result.data as { toolCount: number }).toolCount).toBe(0); - await plugin2.onTargetCleared({ pluginId: "rozenite", target: RN_TARGET, reason: "target-cleared" }); + await plugin2.onTargetCleared({ + pluginId: "rozenite", + target: RN_TARGET, + reason: "target-cleared", + }); }); it("tools returns list of tool names and descriptions", async () => { @@ -355,14 +411,14 @@ describe("RozenitePlugin", () => { expect(result).toEqual({ ok: true, data: [ - { name: "echo", description: "Echoes text" }, - { name: "getTimestamp", description: "Returns timestamp" }, + { name: "app.echo", description: "Echoes text" }, + { name: "app.getTimestamp", description: "Returns timestamp" }, ], }); }); it("tool-schema returns inputSchema for a registered tool", async () => { - const result = await runCommand(plugin, "tool-schema", session, { name: "echo" }); + const result = await runCommand(plugin, "tool-schema", session, { name: "app.echo" }); expect(result).toEqual({ ok: true, data: { type: "object" } }); }); @@ -371,30 +427,64 @@ describe("RozenitePlugin", () => { expect(result).toEqual({ ok: false, error: expect.stringContaining("unknown") }); }); - it("call returns tool result", async () => { - const result = await runCommand(plugin, "call", session, { name: "echo", arguments: { text: "hi" } }); - expect(result).toEqual({ ok: true, data: { value: 42 } }); + it("call sends tool-call and resolves when tool-result arrives", async () => { + const callPromise = runCommand(plugin, "call", session, { + name: "app.echo", + arguments: { text: "hi" }, + }); + + await Promise.resolve(); // let the send happen + + // Find the callId from the sendDomainMessage evaluate call + const callEval = session.sendHistory.find( + (c) => + c.method === "Runtime.evaluate" && + typeof c.params?.expression === "string" && + (c.params.expression as string).includes("tool-call"), + ); + expect(callEval).toBeDefined(); + const expr = callEval!.params!.expression as string; + // Extract callId from the expression + const match = /\\"callId\\":\\"([^"\\]+)\\"/.exec(expr); + expect(match).toBeTruthy(); + const callId = match![1]; + + session.emitEvent( + makeRozeniteEvent("tool-result", { callId, success: true, result: { echo: "hi" } }), + ); + + const result = await callPromise; + expect(result).toEqual({ ok: true, data: { echo: "hi" } }); }); - it("call returns error when Rozenite reports failure", async () => { - vi.stubGlobal( - "fetch", - makeConnectFetch({ - [`POST ${SESSION_CALL_URL}`]: { ok: false, error: { message: "Tool threw: something went wrong" } }, - }) + it("call rejects when tool-result reports failure", async () => { + const callPromise = runCommand(plugin, "call", session, { name: "app.echo" }); + await Promise.resolve(); + + const callEval = session.sendHistory.find( + (c) => + c.method === "Runtime.evaluate" && + typeof c.params?.expression === "string" && + (c.params.expression as string).includes("tool-call"), + ); + const match = /\\"callId\\":\\"([^"\\]+)\\"/.exec(callEval!.params!.expression as string); + const callId = match![1]; + + session.emitEvent( + makeRozeniteEvent("tool-result", { callId, success: false, error: "Tool threw an error" }), ); - const plugin2 = new RozenitePlugin(); - await plugin2.onTargetSelected(makeTargetContext(new FakeSession())); - await flushConnect(); - const result = await runCommand(plugin2, "call", new FakeSession(), { name: "echo" }); - expect(result).toEqual({ ok: false, error: expect.stringContaining("something went wrong") }); + const result = await callPromise; + expect(result).toEqual({ ok: false, error: expect.stringContaining("Tool threw an error") }); }); - it("call returns error for no active session", async () => { + it("call returns error when no active session", async () => { const freshPlugin = new RozenitePlugin(); - const result = await runCommand(freshPlugin, "call", null, { name: "echo" }); - expect(result).toEqual({ ok: false, error: expect.stringContaining("No active Rozenite session") }); + const result = await runCommand(freshPlugin, "call", null, { name: "app.echo" }); + expect(result).toEqual({ + ok: false, + error: expect.stringContaining("No active Rozenite session"), + }); }); }); -}); \ No newline at end of file +}); diff --git a/packages/agent-cdp/src/plugins/rozenite/bootstrap.ts b/packages/agent-cdp/src/plugins/rozenite/bootstrap.ts new file mode 100644 index 0000000..d50e377 --- /dev/null +++ b/packages/agent-cdp/src/plugins/rozenite/bootstrap.ts @@ -0,0 +1,71 @@ +import type { AgentPluginTargetSession } from "../../plugin.js"; +import { + BOOTSTRAP_POLL_INTERVAL_MS, + BOOTSTRAP_POLL_MAX_ATTEMPTS, + RUNTIME_GLOBAL, +} from "./protocol.js"; + +function abortError(): Error { + const e = new Error("Aborted"); + e.name = "AbortError"; + return e; +} + +async function pollForRuntime( + session: AgentPluginTargetSession, + signal: AbortSignal | undefined, +): Promise { + for (let attempt = 0; attempt < BOOTSTRAP_POLL_MAX_ATTEMPTS; attempt++) { + if (signal?.aborted) throw abortError(); + + const result = (await session.send("Runtime.evaluate", { + expression: `typeof globalThis.${RUNTIME_GLOBAL} !== 'undefined'`, + returnByValue: true, + })) as { result?: { value?: unknown }; exceptionDetails?: unknown } | undefined; + + if (result?.result?.value === true) return; + + if (attempt === BOOTSTRAP_POLL_MAX_ATTEMPTS - 1) { + throw new Error(`Timed out waiting for ${RUNTIME_GLOBAL} to be available`); + } + + await new Promise((resolve) => setTimeout(resolve, BOOTSTRAP_POLL_INTERVAL_MS)); + } +} + +async function getBindingName(session: AgentPluginTargetSession): Promise { + const result = (await session.send("Runtime.evaluate", { + expression: `globalThis.${RUNTIME_GLOBAL}.BINDING_NAME`, + returnByValue: true, + })) as { result?: { value?: unknown }; exceptionDetails?: unknown } | undefined; + + if (result?.exceptionDetails) { + throw new Error("Failed to get binding name: " + JSON.stringify(result.exceptionDetails)); + } + + const value = result?.result?.value; + if (typeof value !== "string" || !value) { + throw new Error(`Unexpected binding name value: ${JSON.stringify(value)}`); + } + + return value; +} + +export async function runBootstrap( + session: AgentPluginTargetSession, + signal?: AbortSignal, +): Promise { + await session.send("Runtime.enable"); + if (signal?.aborted) throw abortError(); + + await pollForRuntime(session, signal); + if (signal?.aborted) throw abortError(); + + const bindingName = await getBindingName(session); + if (signal?.aborted) throw abortError(); + + await session.send("Runtime.addBinding", { name: bindingName }); + + console.log(`[Rozenite] Bootstrap: binding name = ${bindingName}`); + return bindingName; +} diff --git a/packages/agent-cdp/src/plugins/rozenite/index.ts b/packages/agent-cdp/src/plugins/rozenite/index.ts index eb1f374..e66fc9e 100644 --- a/packages/agent-cdp/src/plugins/rozenite/index.ts +++ b/packages/agent-cdp/src/plugins/rozenite/index.ts @@ -6,13 +6,26 @@ import type { AgentPluginDetachContext, AgentPluginState, AgentPluginTargetContext, + AgentPluginTargetSession, } from "../../plugin.js"; +import { runBootstrap } from "./bootstrap.js"; import { - ROZENITE_AGENT_BASE, - type RozeniteApiResponse, - type RozeniteApiTool, - type RozeniteSessionInfo, + AGENT_PLUGIN_ID, + ROZENITE_DOMAIN, + RUNTIME_GLOBAL, + type RozeniteDevToolsMessage, + type RozeniteRegisterToolPayload, + type RozeniteToolCallPayload, + type RozeniteToolResultPayload, + type RozeniteUnregisterToolPayload, } from "./protocol.js"; +import { ToolRegistry } from "./tool-registry.js"; + +interface PendingCall { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timeoutId: ReturnType; +} export class RozenitePlugin implements AgentPlugin { readonly id = "rozenite"; @@ -21,9 +34,11 @@ export class RozenitePlugin implements AgentPlugin { readonly commands: readonly AgentPluginCommand[]; private state: AgentPluginState = { kind: "idle" }; - private sessionId: string | null = null; - private metroBaseUrl: string | null = null; + private session: AgentPluginTargetSession | null = null; + private removeEventListener: (() => void) | null = null; private abortController: AbortController | null = null; + private readonly toolRegistry = new ToolRegistry(); + private readonly pendingCalls = new Map(); constructor() { this.commands = this.buildCommands(); @@ -38,11 +53,30 @@ export class RozenitePlugin implements AgentPlugin { } async onTargetSelected(ctx: AgentPluginTargetContext): Promise { - this.state = { kind: "waiting-for-runtime", reason: "Connecting to Rozenite HTTP agent..." }; - this.sessionId = null; - this.metroBaseUrl = null; + this.detach(); + this.state = { kind: "waiting-for-runtime", reason: "Bootstrapping Rozenite CDP bridge..." }; this.abortController = new AbortController(); - void this.connect(ctx.session.target); + + const session = ctx.session; + + // Register event listener BEFORE calling initializeDomain to avoid missing early events. + this.removeEventListener = session.onEvent((message) => { + if ( + message.method === "Runtime.executionContextsCleared" || + message.method === "Runtime.executionContextCreated" + ) { + void this.reattach(session); + return; + } + if (message.method !== "Runtime.bindingCalled") return; + console.log( + "[Rozenite] Runtime.bindingCalled event received:", + JSON.stringify(message.params), + ); + void this.handleBindingCalled(message.params); + }); + + void this.attach(session); } async onTargetReconnected(ctx: AgentPluginTargetContext): Promise { @@ -50,61 +84,150 @@ export class RozenitePlugin implements AgentPlugin { } async onTargetCleared(_ctx: AgentPluginDetachContext): Promise { - await this.teardown(); - this.state = { kind: "idle" }; + this.detach(); } - private async teardown(): Promise { - const ctrl = this.abortController; + private detach(): void { + this.abortController?.abort(); this.abortController = null; - ctrl?.abort(); - - const { sessionId, metroBaseUrl } = this; - this.sessionId = null; - this.metroBaseUrl = null; - - if (sessionId && metroBaseUrl) { - void fetch(`${metroBaseUrl}${ROZENITE_AGENT_BASE}/sessions/${sessionId}`, { - method: "DELETE", - }).catch(() => {}); + this.removeEventListener?.(); + this.removeEventListener = null; + this.session = null; + this.toolRegistry.clear(); + for (const [, pending] of this.pendingCalls) { + clearTimeout(pending.timeoutId); + pending.reject(new Error("Session detached")); } + this.pendingCalls.clear(); + this.state = { kind: "idle" }; } - private async connect(target: TargetDescriptor): Promise { - const metroBaseUrl = target.sourceUrl; - const deviceId = target.reactNative?.logicalDeviceId; + private async attach(session: AgentPluginTargetSession): Promise { const signal = this.abortController?.signal; try { - const body: Record = {}; - if (deviceId) body.deviceId = deviceId; - - const response = await fetch(`${metroBaseUrl}${ROZENITE_AGENT_BASE}/sessions`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - signal, - }); + const bindingName = await runBootstrap(session, signal); + if (signal?.aborted) return; - const json = (await response.json()) as RozeniteApiResponse<{ session: RozeniteSessionInfo }>; + this.session = session; - if (signal?.aborted) return; + await session.send("Runtime.evaluate", { + expression: `void globalThis.${RUNTIME_GLOBAL}.initializeDomain(${JSON.stringify(ROZENITE_DOMAIN)})`, + }); - if (!json.ok) { - throw new Error(json.error?.message ?? "Failed to create Rozenite session"); - } + await this.sendDomainMessage(session, { + pluginId: AGENT_PLUGIN_ID, + type: "agent-session-ready", + payload: { sessionId: session.target.id }, + }); - this.metroBaseUrl = metroBaseUrl; - this.sessionId = json.result!.session.id; + console.log(`[Rozenite] Ready. Binding: ${bindingName}. Target: ${session.target.id}`); this.state = { kind: "ready" }; } catch (err) { const error = err as Error; if (error.name !== "AbortError" && !signal?.aborted) { + console.error("[Rozenite] Bootstrap failed:", error.message); this.state = { kind: "error", reason: error.message }; } } } + private async reattach(session: AgentPluginTargetSession): Promise { + if (this.session !== session || !session.isConnected()) return; + this.toolRegistry.clear(); + this.state = { kind: "waiting-for-runtime", reason: "Reconnecting after context reload..." }; + try { + const bindingName = await runBootstrap(session, this.abortController?.signal); + console.log(`[Rozenite] Reattached. Binding: ${bindingName}`); + await session.send("Runtime.evaluate", { + expression: `void globalThis.${RUNTIME_GLOBAL}.initializeDomain(${JSON.stringify(ROZENITE_DOMAIN)})`, + }); + await this.sendDomainMessage(session, { + pluginId: AGENT_PLUGIN_ID, + type: "agent-session-ready", + payload: { sessionId: session.target.id }, + }); + this.state = { kind: "ready" }; + } catch { + // A later reconnect will retry; ignore errors here. + } + } + + private async sendDomainMessage( + session: AgentPluginTargetSession, + message: RozeniteDevToolsMessage, + ): Promise { + const serialized = JSON.stringify(message); + const escaped = JSON.stringify(serialized); + await session.send("Runtime.evaluate", { + expression: `globalThis.${RUNTIME_GLOBAL}.sendMessage(${JSON.stringify(ROZENITE_DOMAIN)}, ${escaped})`, + }); + } + + private handleBindingCalled(params: Record | undefined): void { + const rawPayload = params?.payload; + console.log( + "[Rozenite] bindingCalled name:", + params?.name, + "payload length:", + typeof rawPayload === "string" ? rawPayload.length : "N/A", + ); + + if (typeof rawPayload !== "string") return; + + let envelope: { domain?: unknown; message?: unknown }; + try { + envelope = JSON.parse(rawPayload) as typeof envelope; + } catch { + console.warn("[Rozenite] Failed to parse binding payload:", rawPayload); + return; + } + + console.log("[Rozenite] Envelope domain:", envelope.domain); + if (envelope.domain !== ROZENITE_DOMAIN) return; + + const msg = envelope.message as RozeniteDevToolsMessage | undefined; + if (!msg || msg.pluginId !== AGENT_PLUGIN_ID) { + console.log("[Rozenite] Ignoring message from pluginId:", msg?.pluginId); + return; + } + + console.log("[Rozenite] Message type:", msg.type, "payload:", JSON.stringify(msg.payload)); + + switch (msg.type) { + case "register-tool": { + const { tools } = msg.payload as RozeniteRegisterToolPayload; + this.toolRegistry.register(tools); + console.log("[Rozenite] Registered tools:", tools.map((t) => t.name).join(", ")); + break; + } + case "unregister-tool": { + const { toolNames } = msg.payload as RozeniteUnregisterToolPayload; + this.toolRegistry.unregister(toolNames); + console.log("[Rozenite] Unregistered tools:", toolNames.join(", ")); + break; + } + case "tool-result": { + const { callId, success, result, error } = msg.payload as RozeniteToolResultPayload; + const pending = this.pendingCalls.get(callId); + if (!pending) { + console.warn("[Rozenite] Received tool-result for unknown callId:", callId); + return; + } + this.pendingCalls.delete(callId); + clearTimeout(pending.timeoutId); + if (success) { + pending.resolve(result); + } else { + pending.reject(new Error(error ?? "Tool call failed")); + } + break; + } + default: + console.log("[Rozenite] Unknown message type:", msg.type); + } + } + private buildCommands(): AgentPluginCommand[] { return [ { @@ -113,20 +236,10 @@ export class RozenitePlugin implements AgentPlugin { alwaysExecutable: true, execute: async (ctx) => { const state = ctx.getState(); - let toolCount = 0; - if (state.kind === "ready" && this.sessionId && this.metroBaseUrl) { - try { - const resp = await fetch( - `${this.metroBaseUrl}${ROZENITE_AGENT_BASE}/sessions/${this.sessionId}` - ); - const json = (await resp.json()) as RozeniteApiResponse<{ session: RozeniteSessionInfo }>; - if (json.ok && json.result) toolCount = json.result.session.toolCount; - } catch {} - } return { state: state.kind, ...(state.kind === "error" ? { error: state.reason } : {}), - toolCount, + toolCount: this.toolRegistry.size(), target: ctx.session?.target ?? null, }; }, @@ -135,12 +248,9 @@ export class RozenitePlugin implements AgentPlugin { name: "tools", summary: "List registered Rozenite tools", execute: async () => { - const { sessionId, metroBaseUrl } = this; - if (!sessionId || !metroBaseUrl) throw new Error("No active Rozenite session"); - const resp = await fetch(`${metroBaseUrl}${ROZENITE_AGENT_BASE}/sessions/${sessionId}/tools`); - const json = (await resp.json()) as RozeniteApiResponse<{ tools: RozeniteApiTool[] }>; - if (!json.ok) throw new Error(json.error?.message ?? "Failed to list tools"); - return (json.result?.tools ?? []).map((t) => ({ name: t.name, description: t.description })); + return this.toolRegistry + .getAll() + .map((t) => ({ name: t.name, description: t.description })); }, }, { @@ -148,12 +258,7 @@ export class RozenitePlugin implements AgentPlugin { summary: "Show input schema for a Rozenite tool", execute: async (_ctx, input) => { const { name } = input as { name: string }; - const { sessionId, metroBaseUrl } = this; - if (!sessionId || !metroBaseUrl) throw new Error("No active Rozenite session"); - const resp = await fetch(`${metroBaseUrl}${ROZENITE_AGENT_BASE}/sessions/${sessionId}/tools`); - const json = (await resp.json()) as RozeniteApiResponse<{ tools: RozeniteApiTool[] }>; - if (!json.ok) throw new Error(json.error?.message ?? "Failed to fetch tools"); - const tool = (json.result?.tools ?? []).find((t) => t.name === name); + const tool = this.toolRegistry.get(name); if (!tool) throw new Error(`Tool '${name}' not found`); return tool.inputSchema; }, @@ -163,21 +268,34 @@ export class RozenitePlugin implements AgentPlugin { summary: "Call a Rozenite tool", execute: async (_ctx, input) => { const { name, arguments: args } = input as { name: string; arguments?: unknown }; - const { sessionId, metroBaseUrl } = this; - if (!sessionId || !metroBaseUrl) throw new Error("No active Rozenite session"); - const resp = await fetch( - `${metroBaseUrl}${ROZENITE_AGENT_BASE}/sessions/${sessionId}/call-tool`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ toolName: name, args: args ?? null }), - } - ); - const json = (await resp.json()) as RozeniteApiResponse<{ result: unknown }>; - if (!json.ok) throw new Error(json.error?.message ?? "Tool call failed"); - return json.result?.result; + const session = this.session; + if (!session) throw new Error("No active Rozenite session"); + + const callId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + + const resultPromise = new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + this.pendingCalls.delete(callId); + reject(new Error("Tool call timeout")); + }, 30_000); + this.pendingCalls.set(callId, { resolve, reject, timeoutId }); + }); + + const payload: RozeniteToolCallPayload = { + callId, + toolName: name, + arguments: args ?? null, + }; + + await this.sendDomainMessage(session, { + pluginId: AGENT_PLUGIN_ID, + type: "tool-call", + payload, + }); + + return resultPromise; }, }, ]; } -} \ No newline at end of file +} diff --git a/packages/agent-cdp/src/plugins/rozenite/protocol.ts b/packages/agent-cdp/src/plugins/rozenite/protocol.ts index 336a350..f15e3c1 100644 --- a/packages/agent-cdp/src/plugins/rozenite/protocol.ts +++ b/packages/agent-cdp/src/plugins/rozenite/protocol.ts @@ -1,25 +1,42 @@ -export const ROZENITE_AGENT_BASE = "/rozenite/agent"; +export const RUNTIME_GLOBAL = "__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__"; +export const ROZENITE_DOMAIN = "rozenite"; +export const AGENT_PLUGIN_ID = "rozenite-agent"; +export const BOOTSTRAP_POLL_INTERVAL_MS = 250; +export const BOOTSTRAP_POLL_MAX_ATTEMPTS = 40; -export interface RozeniteApiTool { +export interface RozeniteRegisteredTool { name: string; description: string; inputSchema: object; } -export interface RozeniteSessionInfo { - id: string; - deviceId: string; - deviceName: string; - status: string; - toolCount: number; - createdAt: number; - lastActivityAt: number; - connectedAt?: number; - lastError?: string; +export interface RozeniteDevToolsMessage { + pluginId: string; + type: string; + payload: unknown; } -export interface RozeniteApiResponse { - ok: boolean; - result?: T; - error?: { message: string }; +export interface RozeniteRegisterToolPayload { + tools: RozeniteRegisteredTool[]; +} + +export interface RozeniteUnregisterToolPayload { + toolNames: string[]; +} + +export interface RozeniteToolResultPayload { + callId: string; + success: boolean; + result?: unknown; + error?: string; +} + +export interface RozeniteToolCallPayload { + callId: string; + toolName: string; + arguments: unknown; +} + +export interface RozeniteAgentSessionReadyPayload { + sessionId: string; } \ No newline at end of file diff --git a/packages/agent-cdp/src/plugins/rozenite/tool-registry.ts b/packages/agent-cdp/src/plugins/rozenite/tool-registry.ts new file mode 100644 index 0000000..af91a04 --- /dev/null +++ b/packages/agent-cdp/src/plugins/rozenite/tool-registry.ts @@ -0,0 +1,33 @@ +import type { RozeniteRegisteredTool } from "./protocol.js"; + +export class ToolRegistry { + private readonly tools = new Map(); + + register(tools: RozeniteRegisteredTool[]): void { + for (const tool of tools) { + this.tools.set(tool.name, tool); + } + } + + unregister(toolNames: string[]): void { + for (const name of toolNames) { + this.tools.delete(name); + } + } + + getAll(): RozeniteRegisteredTool[] { + return [...this.tools.values()]; + } + + get(name: string): RozeniteRegisteredTool | undefined { + return this.tools.get(name); + } + + size(): number { + return this.tools.size; + } + + clear(): void { + this.tools.clear(); + } +} From 0d675182a4cc0b24060b2cdb14b3b5897870fd8b Mon Sep 17 00:00:00 2001 From: Bartek Dybowski Date: Fri, 22 May 2026 08:28:29 +0200 Subject: [PATCH 9/9] chore: remove debug logging from Rozenite plugin --- .../src/plugins/rozenite/bootstrap.ts | 1 - .../agent-cdp/src/plugins/rozenite/index.ts | 36 +++---------------- 2 files changed, 4 insertions(+), 33 deletions(-) diff --git a/packages/agent-cdp/src/plugins/rozenite/bootstrap.ts b/packages/agent-cdp/src/plugins/rozenite/bootstrap.ts index d50e377..2efda0c 100644 --- a/packages/agent-cdp/src/plugins/rozenite/bootstrap.ts +++ b/packages/agent-cdp/src/plugins/rozenite/bootstrap.ts @@ -66,6 +66,5 @@ export async function runBootstrap( await session.send("Runtime.addBinding", { name: bindingName }); - console.log(`[Rozenite] Bootstrap: binding name = ${bindingName}`); return bindingName; } diff --git a/packages/agent-cdp/src/plugins/rozenite/index.ts b/packages/agent-cdp/src/plugins/rozenite/index.ts index e66fc9e..91d11f9 100644 --- a/packages/agent-cdp/src/plugins/rozenite/index.ts +++ b/packages/agent-cdp/src/plugins/rozenite/index.ts @@ -69,10 +69,6 @@ export class RozenitePlugin implements AgentPlugin { return; } if (message.method !== "Runtime.bindingCalled") return; - console.log( - "[Rozenite] Runtime.bindingCalled event received:", - JSON.stringify(message.params), - ); void this.handleBindingCalled(message.params); }); @@ -106,7 +102,7 @@ export class RozenitePlugin implements AgentPlugin { const signal = this.abortController?.signal; try { - const bindingName = await runBootstrap(session, signal); + await runBootstrap(session, signal); if (signal?.aborted) return; this.session = session; @@ -121,12 +117,10 @@ export class RozenitePlugin implements AgentPlugin { payload: { sessionId: session.target.id }, }); - console.log(`[Rozenite] Ready. Binding: ${bindingName}. Target: ${session.target.id}`); this.state = { kind: "ready" }; } catch (err) { const error = err as Error; if (error.name !== "AbortError" && !signal?.aborted) { - console.error("[Rozenite] Bootstrap failed:", error.message); this.state = { kind: "error", reason: error.message }; } } @@ -137,8 +131,7 @@ export class RozenitePlugin implements AgentPlugin { this.toolRegistry.clear(); this.state = { kind: "waiting-for-runtime", reason: "Reconnecting after context reload..." }; try { - const bindingName = await runBootstrap(session, this.abortController?.signal); - console.log(`[Rozenite] Reattached. Binding: ${bindingName}`); + await runBootstrap(session, this.abortController?.signal); await session.send("Runtime.evaluate", { expression: `void globalThis.${RUNTIME_GLOBAL}.initializeDomain(${JSON.stringify(ROZENITE_DOMAIN)})`, }); @@ -166,54 +159,35 @@ export class RozenitePlugin implements AgentPlugin { private handleBindingCalled(params: Record | undefined): void { const rawPayload = params?.payload; - console.log( - "[Rozenite] bindingCalled name:", - params?.name, - "payload length:", - typeof rawPayload === "string" ? rawPayload.length : "N/A", - ); - if (typeof rawPayload !== "string") return; let envelope: { domain?: unknown; message?: unknown }; try { envelope = JSON.parse(rawPayload) as typeof envelope; } catch { - console.warn("[Rozenite] Failed to parse binding payload:", rawPayload); return; } - console.log("[Rozenite] Envelope domain:", envelope.domain); if (envelope.domain !== ROZENITE_DOMAIN) return; const msg = envelope.message as RozeniteDevToolsMessage | undefined; - if (!msg || msg.pluginId !== AGENT_PLUGIN_ID) { - console.log("[Rozenite] Ignoring message from pluginId:", msg?.pluginId); - return; - } - - console.log("[Rozenite] Message type:", msg.type, "payload:", JSON.stringify(msg.payload)); + if (!msg || msg.pluginId !== AGENT_PLUGIN_ID) return; switch (msg.type) { case "register-tool": { const { tools } = msg.payload as RozeniteRegisterToolPayload; this.toolRegistry.register(tools); - console.log("[Rozenite] Registered tools:", tools.map((t) => t.name).join(", ")); break; } case "unregister-tool": { const { toolNames } = msg.payload as RozeniteUnregisterToolPayload; this.toolRegistry.unregister(toolNames); - console.log("[Rozenite] Unregistered tools:", toolNames.join(", ")); break; } case "tool-result": { const { callId, success, result, error } = msg.payload as RozeniteToolResultPayload; const pending = this.pendingCalls.get(callId); - if (!pending) { - console.warn("[Rozenite] Received tool-result for unknown callId:", callId); - return; - } + if (!pending) return; this.pendingCalls.delete(callId); clearTimeout(pending.timeoutId); if (success) { @@ -223,8 +197,6 @@ export class RozenitePlugin implements AgentPlugin { } break; } - default: - console.log("[Rozenite] Unknown message type:", msg.type); } }