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