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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
400 changes: 400 additions & 0 deletions packages/agent-cdp/src/__tests__/rozenite-plugin.test.ts

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/agent-cdp/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-cdp/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 8 additions & 6 deletions packages/agent-cdp/src/plugin-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/agent-cdp/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>;
}
Expand Down
68 changes: 68 additions & 0 deletions packages/agent-cdp/src/plugins/rozenite/cli.ts
Original file line number Diff line number Diff line change
@@ -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 <name>")
.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 <name>")
.description("Call a Rozenite tool")
.option("--input <json>", "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);
});
}
183 changes: 183 additions & 0 deletions packages/agent-cdp/src/plugins/rozenite/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import type { TargetDescriptor } from "@agent-cdp/protocol";

import type {
AgentPlugin,
AgentPluginCommand,
AgentPluginDetachContext,
AgentPluginState,
AgentPluginTargetContext,
} from "../../plugin.js";
import {
ROZENITE_AGENT_BASE,
type RozeniteApiResponse,
type RozeniteApiTool,
type RozeniteSessionInfo,
} from "./protocol.js";

export class RozenitePlugin implements AgentPlugin {
readonly id = "rozenite";
readonly displayName = "Rozenite";
readonly description = "Rozenite React Native agent bridge";
readonly commands: readonly AgentPluginCommand[];

private state: AgentPluginState = { kind: "idle" };
private sessionId: string | null = null;
private metroBaseUrl: string | null = null;
private abortController: AbortController | null = null;

constructor() {
this.commands = this.buildCommands();
}

getState(): AgentPluginState {
return this.state;
}

supportsTarget(target: TargetDescriptor): boolean {
return target.kind === "react-native";
}

async onTargetSelected(ctx: AgentPluginTargetContext): Promise<void> {
this.state = { kind: "waiting-for-runtime", reason: "Connecting to Rozenite HTTP agent..." };
this.sessionId = null;
this.metroBaseUrl = null;
this.abortController = new AbortController();
void this.connect(ctx.session.target);
}

async onTargetReconnected(ctx: AgentPluginTargetContext): Promise<void> {
return this.onTargetSelected(ctx);
}

async onTargetCleared(_ctx: AgentPluginDetachContext): Promise<void> {
await this.teardown();
this.state = { kind: "idle" };
}

private async teardown(): Promise<void> {
const ctrl = this.abortController;
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(() => {});
}
}

private async connect(target: TargetDescriptor): Promise<void> {
const metroBaseUrl = target.sourceUrl;
const deviceId = target.reactNative?.logicalDeviceId;
const signal = this.abortController?.signal;

try {
const body: Record<string, string> = {};
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 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");
}

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 buildCommands(): AgentPluginCommand[] {
return [
{
name: "status",
summary: "Show Rozenite plugin state and registered tool count",
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,
target: ctx.session?.target ?? null,
};
},
},
{
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 }));
},
},
{
name: "tool-schema",
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);
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 { 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;
},
},
];
}
}
25 changes: 25 additions & 0 deletions packages/agent-cdp/src/plugins/rozenite/protocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export const ROZENITE_AGENT_BASE = "/rozenite/agent";

export interface RozeniteApiTool {
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 RozeniteApiResponse<T> {
ok: boolean;
result?: T;
error?: { message: string };
}
8 changes: 8 additions & 0 deletions playground/metro.config.js
Original file line number Diff line number Diff line change
@@ -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',
});
2 changes: 2 additions & 0 deletions playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading
Loading