1- import crypto from "node:crypto" ;
2-
31import type { TargetDescriptor } from "@agent-cdp/protocol" ;
42
53import type {
@@ -8,31 +6,24 @@ import type {
86 AgentPluginDetachContext ,
97 AgentPluginState ,
108 AgentPluginTargetContext ,
11- AgentPluginTargetSession ,
129} from "../../plugin.js" ;
13- import { bootstrapRozenite } from "./bootstrap.js" ;
1410import {
15- DOMAIN_NAME ,
16- RUNTIME_GLOBAL ,
17- type AgentToAppMessage ,
18- type AppToAgentMessage ,
19- type BindingPayload ,
11+ ROZENITE_AGENT_BASE ,
12+ type RozeniteApiResponse ,
13+ type RozeniteApiTool ,
14+ type RozeniteSessionInfo ,
2015} from "./protocol.js" ;
21- import { RozeniteToolRegistry } from "./tool-registry.js" ;
2216
2317export class RozenitePlugin implements AgentPlugin {
2418 readonly id = "rozenite" ;
2519 readonly displayName = "Rozenite" ;
26- readonly description = "Rozenite React Native devtools bridge" ;
20+ readonly description = "Rozenite React Native agent bridge" ;
2721 readonly commands : readonly AgentPluginCommand [ ] ;
2822
2923 private state : AgentPluginState = { kind : "idle" } ;
30- private readonly registry = new RozeniteToolRegistry ( ) ;
24+ private sessionId : string | null = null ;
25+ private metroBaseUrl : string | null = null ;
3126 private abortController : AbortController | null = null ;
32- private readonly pendingCalls = new Map < string , {
33- resolve : ( value : unknown ) => void ;
34- reject : ( reason : Error ) => void ;
35- } > ( ) ;
3627
3728 constructor ( ) {
3829 this . commands = this . buildCommands ( ) ;
@@ -47,90 +38,73 @@ export class RozenitePlugin implements AgentPlugin {
4738 }
4839
4940 async onTargetSelected ( ctx : AgentPluginTargetContext ) : Promise < void > {
50- this . state = { kind : "waiting-for-runtime" , reason : `Waiting for ${ RUNTIME_GLOBAL } ` } ;
51- this . registry . clear ( ) ;
41+ this . state = { kind : "waiting-for-runtime" , reason : "Connecting to Rozenite HTTP agent..." } ;
42+ this . sessionId = null ;
43+ this . metroBaseUrl = null ;
5244 this . abortController = new AbortController ( ) ;
53- ctx . session . onDisconnected ( ( ) => this . handleDisconnect ( ) ) ;
54- void this . runBootstrap ( ctx . session ) ;
45+ void this . connect ( ctx . session . target ) ;
5546 }
5647
5748 async onTargetReconnected ( ctx : AgentPluginTargetContext ) : Promise < void > {
5849 return this . onTargetSelected ( ctx ) ;
5950 }
6051
6152 async onTargetCleared ( _ctx : AgentPluginDetachContext ) : Promise < void > {
62- this . teardown ( new Error ( "Target cleared" ) ) ;
53+ await this . teardown ( ) ;
6354 this . state = { kind : "idle" } ;
6455 }
6556
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 ( ) ;
57+ private async teardown ( ) : Promise < void > {
58+ const ctrl = this . abortController ;
7359 this . abortController = null ;
74- this . registry . clear ( ) ;
75- for ( const pending of this . pendingCalls . values ( ) ) {
76- pending . reject ( error ) ;
60+ ctrl ?. abort ( ) ;
61+
62+ const { sessionId, metroBaseUrl } = this ;
63+ this . sessionId = null ;
64+ this . metroBaseUrl = null ;
65+
66+ if ( sessionId && metroBaseUrl ) {
67+ void fetch ( `${ metroBaseUrl } ${ ROZENITE_AGENT_BASE } /sessions/${ sessionId } ` , {
68+ method : "DELETE" ,
69+ } ) . catch ( ( ) => { } ) ;
7770 }
78- this . pendingCalls . clear ( ) ;
7971 }
8072
81- private async runBootstrap ( session : AgentPluginTargetSession ) : Promise < void > {
73+ private async connect ( target : TargetDescriptor ) : Promise < void > {
74+ const metroBaseUrl = target . sourceUrl ;
75+ const deviceId = target . reactNative ?. logicalDeviceId ;
76+ const signal = this . abortController ?. signal ;
77+
8278 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 { }
79+ const body : Record < string , string > = { } ;
80+ if ( deviceId ) body . deviceId = deviceId ;
81+
82+ const response = await fetch ( `${ metroBaseUrl } ${ ROZENITE_AGENT_BASE } /sessions` , {
83+ method : "POST" ,
84+ headers : { "Content-Type" : "application/json" } ,
85+ body : JSON . stringify ( body ) ,
86+ signal,
9487 } ) ;
9588
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 } ;
89+ const json = ( await response . json ( ) ) as RozeniteApiResponse < { session : RozeniteSessionInfo } > ;
90+
91+ if ( signal ?. aborted ) return ;
92+
93+ if ( ! json . ok ) {
94+ throw new Error ( json . error ?. message ?? "Failed to create Rozenite session" ) ;
10195 }
102- }
103- }
10496
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 ;
97+ this . metroBaseUrl = metroBaseUrl ;
98+ this . sessionId = json . result ! . session . id ;
99+ this . state = { kind : "ready" } ;
100+ } catch ( err ) {
101+ const error = err as Error ;
102+ if ( error . name !== "AbortError" && ! signal ?. aborted ) {
103+ this . state = { kind : "error" , reason : error . message } ;
123104 }
124105 }
125106 }
126107
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-
134108 private buildCommands ( ) : AgentPluginCommand [ ] {
135109 return [
136110 {
@@ -139,10 +113,20 @@ export class RozenitePlugin implements AgentPlugin {
139113 alwaysExecutable : true ,
140114 execute : async ( ctx ) => {
141115 const state = ctx . getState ( ) ;
116+ let toolCount = 0 ;
117+ if ( state . kind === "ready" && this . sessionId && this . metroBaseUrl ) {
118+ try {
119+ const resp = await fetch (
120+ `${ this . metroBaseUrl } ${ ROZENITE_AGENT_BASE } /sessions/${ this . sessionId } `
121+ ) ;
122+ const json = ( await resp . json ( ) ) as RozeniteApiResponse < { session : RozeniteSessionInfo } > ;
123+ if ( json . ok && json . result ) toolCount = json . result . session . toolCount ;
124+ } catch { }
125+ }
142126 return {
143127 state : state . kind ,
144128 ...( state . kind === "error" ? { error : state . reason } : { } ) ,
145- toolCount : this . registry . size ,
129+ toolCount,
146130 target : ctx . session ?. target ?? null ,
147131 } ;
148132 } ,
@@ -151,51 +135,47 @@ export class RozenitePlugin implements AgentPlugin {
151135 name : "tools" ,
152136 summary : "List registered Rozenite tools" ,
153137 execute : async ( ) => {
154- return this . registry . list ( ) . map ( ( t ) => ( {
155- name : t . qualifiedName ,
156- description : t . description ,
157- } ) ) ;
138+ const { sessionId, metroBaseUrl } = this ;
139+ if ( ! sessionId || ! metroBaseUrl ) throw new Error ( "No active Rozenite session" ) ;
140+ const resp = await fetch ( `${ metroBaseUrl } ${ ROZENITE_AGENT_BASE } /sessions/${ sessionId } /tools` ) ;
141+ const json = ( await resp . json ( ) ) as RozeniteApiResponse < { tools : RozeniteApiTool [ ] } > ;
142+ if ( ! json . ok ) throw new Error ( json . error ?. message ?? "Failed to list tools" ) ;
143+ return ( json . result ?. tools ?? [ ] ) . map ( ( t ) => ( { name : t . name , description : t . description } ) ) ;
158144 } ,
159145 } ,
160146 {
161147 name : "tool-schema" ,
162148 summary : "Show input schema for a Rozenite tool" ,
163149 execute : async ( _ctx , input ) => {
164150 const { name } = input as { name : string } ;
165- const tool = this . registry . get ( name ) ;
151+ const { sessionId, metroBaseUrl } = this ;
152+ if ( ! sessionId || ! metroBaseUrl ) throw new Error ( "No active Rozenite session" ) ;
153+ const resp = await fetch ( `${ metroBaseUrl } ${ ROZENITE_AGENT_BASE } /sessions/${ sessionId } /tools` ) ;
154+ const json = ( await resp . json ( ) ) as RozeniteApiResponse < { tools : RozeniteApiTool [ ] } > ;
155+ if ( ! json . ok ) throw new Error ( json . error ?. message ?? "Failed to fetch tools" ) ;
156+ const tool = ( json . result ?. tools ?? [ ] ) . find ( ( t ) => t . name === name ) ;
166157 if ( ! tool ) throw new Error ( `Tool '${ name } ' not found` ) ;
167158 return tool . inputSchema ;
168159 } ,
169160 } ,
170161 {
171162 name : "call" ,
172163 summary : "Call a Rozenite tool" ,
173- execute : async ( ctx , input ) => {
164+ execute : async ( _ctx , input ) => {
174165 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- } ) ;
166+ const { sessionId, metroBaseUrl } = this ;
167+ if ( ! sessionId || ! metroBaseUrl ) throw new Error ( "No active Rozenite session" ) ;
168+ const resp = await fetch (
169+ `${ metroBaseUrl } ${ ROZENITE_AGENT_BASE } /sessions/${ sessionId } /call-tool` ,
170+ {
171+ method : "POST" ,
172+ headers : { "Content-Type" : "application/json" } ,
173+ body : JSON . stringify ( { toolName : name , args : args ?? null } ) ,
174+ }
175+ ) ;
176+ const json = ( await resp . json ( ) ) as RozeniteApiResponse < { result : unknown } > ;
177+ if ( ! json . ok ) throw new Error ( json . error ?. message ?? "Tool call failed" ) ;
178+ return json . result ?. result ;
199179 } ,
200180 } ,
201181 ] ;
0 commit comments