Skip to content

Commit 50004b5

Browse files
committed
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
1 parent 8f4bc03 commit 50004b5

3 files changed

Lines changed: 212 additions & 6 deletions

File tree

packages/agent-cdp/src/plugin-orchestrator.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,19 @@ export class PluginOrchestrator {
6868
return { ok: false, error: `Unknown plugin '${pluginId}'` };
6969
}
7070

71-
const state = plugin.getState();
72-
const stateError = this.getStateError(pluginId, state);
73-
if (stateError) {
74-
return { ok: false, error: stateError };
75-
}
76-
7771
const cmd = plugin.commands.find((c) => c.name === command);
7872
if (!cmd) {
7973
return { ok: false, error: `Unknown command '${command}' for plugin '${pluginId}'` };
8074
}
8175

76+
if (!cmd.alwaysExecutable) {
77+
const state = plugin.getState();
78+
const stateError = this.getStateError(pluginId, state);
79+
if (stateError) {
80+
return { ok: false, error: stateError };
81+
}
82+
}
83+
8284
const context = this.buildCommandContext(plugin);
8385
try {
8486
const data = await cmd.execute(context, input);

packages/agent-cdp/src/plugin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export interface AgentPluginCommand {
4141
readonly name: string;
4242
readonly summary: string;
4343
readonly description?: string;
44+
readonly alwaysExecutable?: boolean;
4445

4546
execute(context: AgentPluginCommandContext, input?: unknown): Promise<unknown>;
4647
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import crypto from "node:crypto";
2+
3+
import type { TargetDescriptor } from "@agent-cdp/protocol";
4+
5+
import type {
6+
AgentPlugin,
7+
AgentPluginCommand,
8+
AgentPluginDetachContext,
9+
AgentPluginState,
10+
AgentPluginTargetContext,
11+
AgentPluginTargetSession,
12+
} from "../../plugin.js";
13+
import { bootstrapRozenite } from "./bootstrap.js";
14+
import {
15+
DOMAIN_NAME,
16+
RUNTIME_GLOBAL,
17+
type AgentToAppMessage,
18+
type AppToAgentMessage,
19+
type BindingPayload,
20+
} from "./protocol.js";
21+
import { RozeniteToolRegistry } from "./tool-registry.js";
22+
23+
export class RozenitePlugin implements AgentPlugin {
24+
readonly id = "rozenite";
25+
readonly displayName = "Rozenite";
26+
readonly description = "Rozenite React Native devtools bridge";
27+
readonly commands: readonly AgentPluginCommand[];
28+
29+
private state: AgentPluginState = { kind: "idle" };
30+
private readonly registry = new RozeniteToolRegistry();
31+
private abortController: AbortController | null = null;
32+
private readonly pendingCalls = new Map<string, {
33+
resolve: (value: unknown) => void;
34+
reject: (reason: Error) => void;
35+
}>();
36+
37+
constructor() {
38+
this.commands = this.buildCommands();
39+
}
40+
41+
getState(): AgentPluginState {
42+
return this.state;
43+
}
44+
45+
supportsTarget(target: TargetDescriptor): boolean {
46+
return target.kind === "react-native";
47+
}
48+
49+
async onTargetSelected(ctx: AgentPluginTargetContext): Promise<void> {
50+
this.state = { kind: "waiting-for-runtime", reason: `Waiting for ${RUNTIME_GLOBAL}` };
51+
this.registry.clear();
52+
this.abortController = new AbortController();
53+
ctx.session.onDisconnected(() => this.handleDisconnect());
54+
void this.runBootstrap(ctx.session);
55+
}
56+
57+
async onTargetReconnected(ctx: AgentPluginTargetContext): Promise<void> {
58+
return this.onTargetSelected(ctx);
59+
}
60+
61+
async onTargetCleared(_ctx: AgentPluginDetachContext): Promise<void> {
62+
this.teardown(new Error("Target cleared"));
63+
this.state = { kind: "idle" };
64+
}
65+
66+
private handleDisconnect(): void {
67+
this.teardown(new Error("Target disconnected"));
68+
this.state = { kind: "idle" };
69+
}
70+
71+
private teardown(error: Error): void {
72+
this.abortController?.abort();
73+
this.abortController = null;
74+
this.registry.clear();
75+
for (const pending of this.pendingCalls.values()) {
76+
pending.reject(error);
77+
}
78+
this.pendingCalls.clear();
79+
}
80+
81+
private async runBootstrap(session: AgentPluginTargetSession): Promise<void> {
82+
try {
83+
const { bindingName } = await bootstrapRozenite(session, this.abortController!.signal);
84+
85+
session.onEvent((event) => {
86+
if (event.method !== "Runtime.bindingCalled") return;
87+
const params = event.params as { name?: string; payload?: string };
88+
if (params.name !== bindingName) return;
89+
try {
90+
const envelope = JSON.parse(params.payload ?? "") as BindingPayload;
91+
if (envelope.domain !== DOMAIN_NAME) return;
92+
this.handleMessage(envelope.message as AppToAgentMessage);
93+
} catch {}
94+
});
95+
96+
await this.sendToApp(session, { type: "agent-session-ready" });
97+
this.state = { kind: "ready" };
98+
} catch (err) {
99+
if ((err as Error).message !== "aborted") {
100+
this.state = { kind: "error", reason: (err as Error).message };
101+
}
102+
}
103+
}
104+
105+
private handleMessage(msg: AppToAgentMessage): void {
106+
switch (msg.type) {
107+
case "register-tool":
108+
this.registry.register("app", msg.tools);
109+
break;
110+
case "unregister-tool":
111+
this.registry.unregister(msg.toolNames);
112+
break;
113+
case "tool-result": {
114+
const pending = this.pendingCalls.get(msg.callId);
115+
if (!pending) return;
116+
this.pendingCalls.delete(msg.callId);
117+
if (msg.success) {
118+
pending.resolve({ success: true, result: msg.result });
119+
} else {
120+
pending.resolve({ success: false, error: msg.error });
121+
}
122+
break;
123+
}
124+
}
125+
}
126+
127+
private async sendToApp(session: AgentPluginTargetSession, message: AgentToAppMessage): Promise<void> {
128+
const payload = JSON.stringify(JSON.stringify(message));
129+
await session.send("Runtime.evaluate", {
130+
expression: `${RUNTIME_GLOBAL}.sendMessage('${DOMAIN_NAME}', ${payload})`,
131+
});
132+
}
133+
134+
private buildCommands(): AgentPluginCommand[] {
135+
return [
136+
{
137+
name: "status",
138+
summary: "Show Rozenite plugin state and registered tool count",
139+
alwaysExecutable: true,
140+
execute: async (ctx) => {
141+
const state = ctx.getState();
142+
return {
143+
state: state.kind,
144+
...(state.kind === "error" ? { error: state.reason } : {}),
145+
toolCount: this.registry.size,
146+
target: ctx.session?.target ?? null,
147+
};
148+
},
149+
},
150+
{
151+
name: "tools",
152+
summary: "List registered Rozenite tools",
153+
execute: async () => {
154+
return this.registry.list().map((t) => ({
155+
name: t.qualifiedName,
156+
description: t.description,
157+
}));
158+
},
159+
},
160+
{
161+
name: "tool-schema",
162+
summary: "Show input schema for a Rozenite tool",
163+
execute: async (_ctx, input) => {
164+
const { name } = input as { name: string };
165+
const tool = this.registry.get(name);
166+
if (!tool) throw new Error(`Tool '${name}' not found`);
167+
return tool.inputSchema;
168+
},
169+
},
170+
{
171+
name: "call",
172+
summary: "Call a Rozenite tool",
173+
execute: async (ctx, input) => {
174+
const { name, arguments: args } = input as { name: string; arguments?: unknown };
175+
const tool = this.registry.get(name);
176+
if (!tool) throw new Error(`Tool '${name}' not found`);
177+
178+
const callId = crypto.randomUUID();
179+
return new Promise<unknown>((resolve, reject) => {
180+
this.pendingCalls.set(callId, { resolve, reject });
181+
182+
void this.sendToApp(ctx.session!, {
183+
type: "tool-call",
184+
callId,
185+
toolName: name,
186+
arguments: args ?? null,
187+
}).catch((err: unknown) => {
188+
this.pendingCalls.delete(callId);
189+
reject(err instanceof Error ? err : new Error(String(err)));
190+
});
191+
192+
setTimeout(() => {
193+
if (this.pendingCalls.has(callId)) {
194+
this.pendingCalls.delete(callId);
195+
reject(new Error(`Tool call '${name}' timed out after 60s`));
196+
}
197+
}, 60_000);
198+
});
199+
},
200+
},
201+
];
202+
}
203+
}

0 commit comments

Comments
 (0)