@memberjunction/server 5.40.1 → 5.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/dist/agentSessions/HostInstance.d.ts +19 -0
  2. package/dist/agentSessions/HostInstance.d.ts.map +1 -0
  3. package/dist/agentSessions/HostInstance.js +48 -0
  4. package/dist/agentSessions/HostInstance.js.map +1 -0
  5. package/dist/agentSessions/SessionJanitor.d.ts +97 -0
  6. package/dist/agentSessions/SessionJanitor.d.ts.map +1 -0
  7. package/dist/agentSessions/SessionJanitor.js +222 -0
  8. package/dist/agentSessions/SessionJanitor.js.map +1 -0
  9. package/dist/agentSessions/SessionManager.d.ts +142 -0
  10. package/dist/agentSessions/SessionManager.d.ts.map +1 -0
  11. package/dist/agentSessions/SessionManager.js +308 -0
  12. package/dist/agentSessions/SessionManager.js.map +1 -0
  13. package/dist/agentSessions/index.d.ts +4 -0
  14. package/dist/agentSessions/index.d.ts.map +1 -0
  15. package/dist/agentSessions/index.js +26 -0
  16. package/dist/agentSessions/index.js.map +1 -0
  17. package/dist/auth/initializeProviders.d.ts.map +1 -1
  18. package/dist/auth/initializeProviders.js +6 -1
  19. package/dist/auth/initializeProviders.js.map +1 -1
  20. package/dist/config.d.ts +245 -0
  21. package/dist/config.d.ts.map +1 -1
  22. package/dist/config.js +34 -0
  23. package/dist/config.js.map +1 -1
  24. package/dist/context.d.ts.map +1 -1
  25. package/dist/context.js +41 -7
  26. package/dist/context.js.map +1 -1
  27. package/dist/generated/generated.d.ts +659 -3
  28. package/dist/generated/generated.d.ts.map +1 -1
  29. package/dist/generated/generated.js +5459 -1759
  30. package/dist/generated/generated.js.map +1 -1
  31. package/dist/generic/ResolverBase.js +1 -1
  32. package/dist/generic/ResolverBase.js.map +1 -1
  33. package/dist/generic/RunViewResolver.js +9 -10
  34. package/dist/generic/RunViewResolver.js.map +1 -1
  35. package/dist/index.d.ts +4 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +112 -42
  38. package/dist/index.js.map +1 -1
  39. package/dist/logging/StartupLogger.d.ts +121 -0
  40. package/dist/logging/StartupLogger.d.ts.map +1 -0
  41. package/dist/logging/StartupLogger.js +245 -0
  42. package/dist/logging/StartupLogger.js.map +1 -0
  43. package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -1
  44. package/dist/logging/variablesLoggingMiddleware.js +21 -2
  45. package/dist/logging/variablesLoggingMiddleware.js.map +1 -1
  46. package/dist/middleware/RateLimitMiddleware.d.ts +16 -0
  47. package/dist/middleware/RateLimitMiddleware.d.ts.map +1 -0
  48. package/dist/middleware/RateLimitMiddleware.js +77 -0
  49. package/dist/middleware/RateLimitMiddleware.js.map +1 -0
  50. package/dist/middleware/index.d.ts +1 -0
  51. package/dist/middleware/index.d.ts.map +1 -1
  52. package/dist/middleware/index.js +1 -0
  53. package/dist/middleware/index.js.map +1 -1
  54. package/dist/resolvers/AgentSessionResolver.d.ts +42 -0
  55. package/dist/resolvers/AgentSessionResolver.d.ts.map +1 -0
  56. package/dist/resolvers/AgentSessionResolver.js +152 -0
  57. package/dist/resolvers/AgentSessionResolver.js.map +1 -0
  58. package/dist/resolvers/EntityPermissionResolver.d.ts +16 -0
  59. package/dist/resolvers/EntityPermissionResolver.d.ts.map +1 -0
  60. package/dist/resolvers/EntityPermissionResolver.js +95 -0
  61. package/dist/resolvers/EntityPermissionResolver.js.map +1 -0
  62. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +688 -0
  63. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -0
  64. package/dist/resolvers/RealtimeClientSessionResolver.js +1774 -0
  65. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -0
  66. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +359 -0
  67. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -0
  68. package/dist/resolvers/RemoteBrowserActionResolver.js +896 -0
  69. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -0
  70. package/dist/rest/SignatureWebhookHandler.js +3 -2
  71. package/dist/rest/SignatureWebhookHandler.js.map +1 -1
  72. package/dist/services/ScheduledJobsService.d.ts.map +1 -1
  73. package/dist/services/ScheduledJobsService.js +6 -5
  74. package/dist/services/ScheduledJobsService.js.map +1 -1
  75. package/package.json +78 -74
  76. package/src/__tests__/RealtimeClientSessionResolver.test.ts +2605 -0
  77. package/src/__tests__/RemoteBrowserAudioStream.test.ts +174 -0
  78. package/src/__tests__/SessionJanitor.test.ts +234 -0
  79. package/src/__tests__/SessionManager.test.ts +465 -0
  80. package/src/__tests__/subscriptionRedaction.test.ts +5 -0
  81. package/src/agentSessions/HostInstance.ts +53 -0
  82. package/src/agentSessions/SessionJanitor.ts +267 -0
  83. package/src/agentSessions/SessionManager.ts +446 -0
  84. package/src/agentSessions/index.ts +27 -0
  85. package/src/auth/initializeProviders.ts +6 -1
  86. package/src/config.ts +38 -0
  87. package/src/context.ts +42 -7
  88. package/src/generated/generated.ts +3111 -567
  89. package/src/generic/ResolverBase.ts +1 -1
  90. package/src/generic/RunViewResolver.ts +9 -9
  91. package/src/index.ts +112 -42
  92. package/src/logging/StartupLogger.ts +317 -0
  93. package/src/logging/variablesLoggingMiddleware.ts +25 -5
  94. package/src/middleware/RateLimitMiddleware.ts +87 -0
  95. package/src/middleware/index.ts +1 -0
  96. package/src/resolvers/AgentSessionResolver.ts +138 -0
  97. package/src/resolvers/EntityPermissionResolver.ts +73 -0
  98. package/src/resolvers/RealtimeClientSessionResolver.ts +2162 -0
  99. package/src/resolvers/RemoteBrowserActionResolver.ts +907 -0
  100. package/src/rest/SignatureWebhookHandler.ts +3 -2
  101. package/src/services/ScheduledJobsService.ts +6 -5
@@ -0,0 +1,907 @@
1
+ /**
2
+ * @fileoverview GraphQL resolvers for the **Remote Browser** native realtime channel, CLIENT-DIRECT
3
+ * topology.
4
+ *
5
+ * Realtime sessions are client-direct: the model talks to the browser, and the agent's browser-driving
6
+ * tools execute CLIENT-side (like the live Whiteboard). Each client-executed browser tool relays its
7
+ * intent to the server through {@link RemoteBrowserActionResolver.ExecuteRemoteBrowserAction}, which drives
8
+ * the server-side {@link RemoteBrowserEngine}'s live browser. A second query,
9
+ * {@link RemoteBrowserActionResolver.RemoteBrowserSnapshot}, returns the current screenshot + URL for the
10
+ * client's live view.
11
+ *
12
+ * Both operations are **ownership-gated** exactly like {@link import('./RealtimeClientSessionResolver').RealtimeClientSessionResolver}:
13
+ * the `AIAgentSession.UserID` must equal the calling user. The mutation lazily starts the browser on first
14
+ * use — so a realtime session that never touches the browser never launches Chrome — resolving the backend
15
+ * from the agent's `TypeConfiguration` (`{ remoteBrowser: { provider } }`), else the single Active provider.
16
+ *
17
+ * @module @memberjunction/server
18
+ */
19
+ import { Resolver, Mutation, Query, Arg, Ctx, Float, ObjectType, Field, PubSub, PubSubEngine } from 'type-graphql';
20
+ import { AppContext, UserPayload } from '../types.js';
21
+ import { UserInfo, IMetadataProvider, LogError } from '@memberjunction/core';
22
+ import { UUIDsEqual } from '@memberjunction/global';
23
+ import { MJAIAgentSessionEntity, MJAIAgentEntity } from '@memberjunction/core-entities';
24
+ import { RemoteBrowserEngine } from '@memberjunction/remote-browser-server';
25
+ import { IRemoteBrowserSession, RemoteBrowserAction, RemoteBrowserAudioChunk, RemoteBrowserHumanInput, RemoteBrowserModifierKey, RemoteBrowserCapabilityNotSupportedError } from '@memberjunction/remote-browser-base';
26
+ import { ResolverBase } from '../generic/ResolverBase.js';
27
+ import { GetReadWriteProvider } from '../util.js';
28
+ import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
29
+ import { AIEngine } from '@memberjunction/aiengine';
30
+ import { AIPromptRunner } from '@memberjunction/ai-prompts';
31
+ import { AIPromptParams } from '@memberjunction/ai-core-plus';
32
+
33
+ /** Entity name — centralised so the `MJ:`-prefix convention is applied in exactly one place. */
34
+ const SESSION_ENTITY = 'MJ: AI Agent Sessions';
35
+ /** Entity name for the agent whose `TypeConfiguration` carries the remote-browser provider preference. */
36
+ const AGENT_ENTITY = 'MJ: AI Agents';
37
+ /**
38
+ * Name of the governable vision AI Prompt that interprets a browser screenshot for a non-vision voice agent.
39
+ * Seeded as metadata (`metadata/prompts/.remote-browser-visual-interpreter-prompt.json`) and pinned to the
40
+ * `Gemini 3.1 Flash-Lite` model via its `MJ: AI Prompt Models` association — so the model is chosen by the
41
+ * prompt's own configuration, not hard-coded here.
42
+ */
43
+ const VISUAL_INTERPRETER_PROMPT_NAME = 'Remote Browser Visual Interpreter';
44
+
45
+ /**
46
+ * The strongly-typed shape of the slice of an agent's `TypeConfiguration` JSON this resolver reads — the
47
+ * optional `remoteBrowser.provider` backend-name preference. Everything else in the blob is ignored here.
48
+ */
49
+ interface RemoteBrowserTypeConfiguration {
50
+ remoteBrowser?: {
51
+ /** The display name of the remote-browser backend to use (e.g. `'Self-Hosted Chrome'`). */
52
+ provider?: string;
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Result of {@link RemoteBrowserActionResolver.ExecuteRemoteBrowserAction} — the outcome of one browser
58
+ * action plus the resulting URL the client narrates / displays.
59
+ */
60
+ @ObjectType()
61
+ export class RemoteBrowserActionResult {
62
+ /** Whether the browser action completed successfully. */
63
+ @Field(() => Boolean)
64
+ Success: boolean;
65
+
66
+ /** The page URL after the action, when known. Null when the action produced no URL. */
67
+ @Field(() => String, { nullable: true })
68
+ CurrentUrl?: string;
69
+
70
+ /** Human-readable detail — an error message on failure, a note on success. Null when none. */
71
+ @Field(() => String, { nullable: true })
72
+ Detail?: string;
73
+ }
74
+
75
+ /**
76
+ * Result of {@link RemoteBrowserActionResolver.RemoteBrowserSnapshot} — the current viewport screenshot +
77
+ * URL for the client's live view. Both fields are null when the session holds no live browser.
78
+ */
79
+ @ObjectType()
80
+ export class RemoteBrowserSnapshot {
81
+ /** The current viewport screenshot, Base64-encoded. Null when no live browser exists for the session. */
82
+ @Field(() => String, { nullable: true })
83
+ ScreenshotBase64?: string;
84
+
85
+ /** The browser's current URL. Null when no live browser exists for the session. */
86
+ @Field(() => String, { nullable: true })
87
+ CurrentUrl?: string;
88
+ }
89
+
90
+ /**
91
+ * Result of {@link RemoteBrowserActionResolver.StartRemoteBrowserScreencast} — whether the live CDP
92
+ * screencast started. When `false` the backend lacks the `ScreenStreaming` capability (or the start
93
+ * failed); the client keeps its 700ms snapshot poll as the fallback live view. When `true` the server
94
+ * is now PUSHING encoded frames on the user's push-status topic and the client paints them on a canvas.
95
+ */
96
+ @ObjectType()
97
+ export class RemoteBrowserScreencastResult {
98
+ /** Whether the server-pushed screencast is now running for this session. */
99
+ @Field(() => Boolean)
100
+ Streaming: boolean;
101
+ }
102
+
103
+ /**
104
+ * Result of {@link RemoteBrowserActionResolver.StartRemoteBrowserAudioStream} — whether the live tab-audio
105
+ * stream started. When `false` the backend lacks an audio-capture mechanism (v1 gates audio by backend
106
+ * implementation, not a metadata flag) or the start failed; the client simply plays no audio. When `true`
107
+ * the server is now PUSHING encoded audio chunks on the user's push-status topic and the client plays them.
108
+ */
109
+ @ObjectType()
110
+ export class RemoteBrowserAudioStreamResult {
111
+ /** Whether the server-pushed tab-audio stream is now running for this session. */
112
+ @Field(() => Boolean)
113
+ Streaming: boolean;
114
+ }
115
+
116
+ /**
117
+ * One UI element the visual interpreter localized in the screenshot — a label plus the pixel centroid the
118
+ * voice agent can feed straight into `browser_Click(x, y)`. Coordinates are in the SCREENSHOT's own pixel
119
+ * space (top-left origin), which equals the live browser viewport.
120
+ */
121
+ @ObjectType()
122
+ export class RemoteBrowserInterpretedElement {
123
+ /** Short human-readable label for the element (e.g. `'Sign In button'`). */
124
+ @Field()
125
+ Label: string;
126
+
127
+ /** Pixel X of the element's centroid in the screenshot's coordinate space. */
128
+ @Field(() => Float)
129
+ X: number;
130
+
131
+ /** Pixel Y of the element's centroid in the screenshot's coordinate space. */
132
+ @Field(() => Float)
133
+ Y: number;
134
+
135
+ /** The interpreter's 0-1 confidence that this localization is correct. */
136
+ @Field(() => Float)
137
+ Confidence: number;
138
+ }
139
+
140
+ /**
141
+ * Result of {@link RemoteBrowserActionResolver.InterpretRemoteBrowserPage} — how a fast vision model "sees"
142
+ * the current browser viewport on behalf of a voice agent that cannot view images. `Description` is a concise
143
+ * text summary; `Elements` localizes any UI elements the request asked for (empty for a plain describe).
144
+ * `Detail` carries a non-fatal note (e.g. `'no live browser'` or a vision-error message) — every path is
145
+ * best-effort and never throws.
146
+ */
147
+ @ObjectType()
148
+ export class RemoteBrowserInterpretation {
149
+ /** A 1-3 sentence summary of what is visible/actionable, or null when nothing could be interpreted. */
150
+ @Field(() => String, { nullable: true })
151
+ Description?: string;
152
+
153
+ /** UI elements matching the request, each with a pixel centroid. Empty for a describe-only request. */
154
+ @Field(() => [RemoteBrowserInterpretedElement])
155
+ Elements: RemoteBrowserInterpretedElement[];
156
+
157
+ /** Non-fatal note explaining a null/empty result (e.g. `'no live browser'`, a parse/vision error). */
158
+ @Field(() => String, { nullable: true })
159
+ Detail?: string;
160
+ }
161
+
162
+ /** The strict-JSON shape the visual-interpreter prompt is contracted to return, before mapping to GraphQL. */
163
+ interface VisualInterpreterPayload {
164
+ description?: string;
165
+ elements?: Array<{ label?: string; x?: number; y?: number; confidence?: number }>;
166
+ }
167
+
168
+ /**
169
+ * Resolver for the Remote Browser native realtime channel. A single {@link RemoteBrowserEngine} instance
170
+ * (the process-wide singleton) backs every request; ownership is enforced per call against the session's
171
+ * `UserID`.
172
+ */
173
+ @Resolver()
174
+ export class RemoteBrowserActionResolver extends ResolverBase {
175
+ /**
176
+ * Agent-session ids whose live CDP screencast this resolver has already started. Keyed by
177
+ * `agentSessionID` so a re-issued {@link RemoteBrowserActionResolver.StartRemoteBrowserScreencast}
178
+ * (e.g. the surface re-binding after a tab collapse) is idempotent and never stacks two screencasts
179
+ * on the one session. Entries are removed by {@link RemoteBrowserActionResolver.StopRemoteBrowserScreencast}.
180
+ */
181
+ private startedScreencasts = new Set<string>();
182
+
183
+ /**
184
+ * Agent-session ids whose live tab-audio stream this resolver has already started. Keyed by
185
+ * `agentSessionID` so a re-issued {@link RemoteBrowserActionResolver.StartRemoteBrowserAudioStream}
186
+ * (the surface re-binding) is idempotent and never stacks two captures on the one session. Entries are
187
+ * removed by {@link RemoteBrowserActionResolver.StopRemoteBrowserAudioStream}.
188
+ */
189
+ private startedAudioStreams = new Set<string>();
190
+
191
+ /**
192
+ * Execute ONE browser action relayed from the client-direct realtime session, returning the outcome +
193
+ * resulting URL.
194
+ *
195
+ * Flow:
196
+ * 1. Ownership gate — the session's `UserID` must equal the caller's (throws otherwise).
197
+ * 2. Lazily start (or reuse) the session's browser via {@link RemoteBrowserEngine.StartSessionForAgentSession},
198
+ * resolving the backend from the agent's `TypeConfiguration` (`{ remoteBrowser: { provider } }`), else
199
+ * the single Active provider.
200
+ * 3. Build a strongly-typed {@link RemoteBrowserAction} from `kind` + the supplied fields.
201
+ * 4. Execute it against the live session and return the result.
202
+ *
203
+ * @param agentSessionID The `AIAgentSession` id the browser is bound to.
204
+ * @param kind The action kind (`'navigate' | 'click' | 'type' | 'key' | 'scroll' | 'back' | 'forward' | 'wait'`).
205
+ * @returns The action result (success + resulting URL + detail).
206
+ */
207
+ @Mutation(() => RemoteBrowserActionResult)
208
+ async ExecuteRemoteBrowserAction(
209
+ @Arg('agentSessionID', () => String) agentSessionID: string,
210
+ @Arg('kind', () => String) kind: string,
211
+ @Ctx() { userPayload, providers }: AppContext,
212
+ @Arg('url', () => String, { nullable: true }) url?: string,
213
+ @Arg('selector', () => String, { nullable: true }) selector?: string,
214
+ @Arg('x', () => Float, { nullable: true }) x?: number,
215
+ @Arg('y', () => Float, { nullable: true }) y?: number,
216
+ @Arg('text', () => String, { nullable: true }) text?: string,
217
+ @Arg('key', () => String, { nullable: true }) key?: string,
218
+ @Arg('deltaX', () => Float, { nullable: true }) deltaX?: number,
219
+ @Arg('deltaY', () => Float, { nullable: true }) deltaY?: number,
220
+ @Arg('ms', () => Float, { nullable: true }) ms?: number,
221
+ ): Promise<RemoteBrowserActionResult> {
222
+ const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
223
+ const session = await this.loadOwnedSession(agentSessionID, contextUser, provider);
224
+
225
+ const action = this.buildAction({ kind, url, selector, x, y, text, key, deltaX, deltaY, ms });
226
+ if (!action) {
227
+ return { Success: false, Detail: `Unknown or incomplete remote-browser action '${kind}'.` };
228
+ }
229
+
230
+ const providerName = await this.resolveProviderName(session, contextUser, provider);
231
+ try {
232
+ const liveSession = await RemoteBrowserEngine.Instance.StartSessionForAgentSession(agentSessionID, contextUser, providerName);
233
+ const result = await liveSession.ExecuteAction(action);
234
+ return { Success: result.Success, CurrentUrl: result.CurrentUrl, Detail: result.Detail };
235
+ } catch (err) {
236
+ // Surface the real failure to BOTH the MJAPI terminal (for diagnosis) and the model (so it
237
+ // narrates the actual cause instead of the opaque client-side "no response from the server").
238
+ const message = err instanceof Error ? err.message : String(err);
239
+ LogError(`ExecuteRemoteBrowserAction failed (provider='${providerName}', kind='${kind}'): ${message}`);
240
+ return { Success: false, Detail: `Remote browser error (${providerName}): ${message}` };
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Return the current viewport screenshot + URL for the session's live browser — the client's live view.
246
+ * Ownership-gated. When the session holds no live browser (never started, or already torn down), the
247
+ * result's fields are null rather than an error.
248
+ *
249
+ * @param agentSessionID The `AIAgentSession` id.
250
+ * @returns The current screenshot + URL, or an empty snapshot when no live browser exists.
251
+ */
252
+ @Query(() => RemoteBrowserSnapshot)
253
+ async RemoteBrowserSnapshot(
254
+ @Arg('agentSessionID', () => String) agentSessionID: string,
255
+ @Ctx() { userPayload, providers }: AppContext,
256
+ ): Promise<RemoteBrowserSnapshot> {
257
+ const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
258
+ await this.loadOwnedSession(agentSessionID, contextUser, provider);
259
+
260
+ const liveSession = RemoteBrowserEngine.Instance.GetSessionForAgentSession(agentSessionID);
261
+ if (!liveSession) {
262
+ return {};
263
+ }
264
+ const screenshot = await liveSession.CaptureScreenshot();
265
+ return { ScreenshotBase64: screenshot, CurrentUrl: liveSession.GetCurrentUrl() };
266
+ }
267
+
268
+ /**
269
+ * VISION-QUERY path (separate from the navigate/click {@link RemoteBrowserActionResolver.ExecuteRemoteBrowserAction}
270
+ * path — this OBSERVES the page, it never drives it). Captures the session's current screenshot and runs
271
+ * a fast vision AI Prompt over it so a non-omnimodal voice agent (audio/text-only) can effectively SEE the
272
+ * page: a concise text description ("kinda see") and, when `query` names a target, the pixel centroid(s) the
273
+ * agent can then feed to `browser_Click(x, y)`.
274
+ *
275
+ * Flow:
276
+ * 1. Ownership gate — the session's `UserID` must equal the caller's (throws otherwise).
277
+ * 2. Get the LIVE session and `CaptureScreenshot()`. No live session / no screenshot → `{ Description: null,
278
+ * Elements: [], Detail: 'no live browser' }`.
279
+ * 3. Run the `Remote Browser Visual Interpreter` prompt with the screenshot as an `image_url` content block
280
+ * plus an instruction derived from `query` (empty/"describe" → describe; a named target → locate it).
281
+ * 4. Parse the strict-JSON result tolerantly and map it to {@link RemoteBrowserInterpretation}.
282
+ *
283
+ * Best-effort + tolerant by contract: any vision/prompt/parse failure is logged and returned as
284
+ * `{ Description: null, Elements: [], Detail: <message> }`. This mutation NEVER throws past the ownership gate.
285
+ *
286
+ * @param agentSessionID The `AIAgentSession` id the browser is bound to.
287
+ * @param query Optional request — empty/"describe" for a page description, else a visual target to localize.
288
+ * @returns The interpretation (description + localized elements + optional detail note).
289
+ */
290
+ @Mutation(() => RemoteBrowserInterpretation)
291
+ async InterpretRemoteBrowserPage(
292
+ @Arg('agentSessionID', () => String) agentSessionID: string,
293
+ @Ctx() { userPayload, providers }: AppContext,
294
+ @Arg('query', () => String, { nullable: true }) query?: string,
295
+ ): Promise<RemoteBrowserInterpretation> {
296
+ const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
297
+ await this.loadOwnedSession(agentSessionID, contextUser, provider);
298
+
299
+ const liveSession = RemoteBrowserEngine.Instance.GetSessionForAgentSession(agentSessionID);
300
+ if (!liveSession) {
301
+ return { Description: undefined, Elements: [], Detail: 'no live browser' };
302
+ }
303
+
304
+ try {
305
+ const screenshot = await liveSession.CaptureScreenshot();
306
+ if (!screenshot) {
307
+ return { Description: undefined, Elements: [], Detail: 'no live browser' };
308
+ }
309
+ return await this.runVisualInterpreter(screenshot, query, contextUser);
310
+ } catch (err) {
311
+ const message = err instanceof Error ? err.message : String(err);
312
+ LogError(`InterpretRemoteBrowserPage failed for session ${agentSessionID}: ${message}`);
313
+ return { Description: undefined, Elements: [], Detail: message };
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Starts a live CDP screencast on the session's browser and PUSHES each encoded frame to the calling
319
+ * user's push-status topic — replacing the client's 700ms snapshot poll with low-latency pushed frames.
320
+ * Ownership-gated. Idempotent: a re-call for an already-streaming session is a no-op that reports
321
+ * `Streaming: true`.
322
+ *
323
+ * Capability gating: {@link IRemoteBrowserSession.StartScreencast} throws
324
+ * {@link RemoteBrowserCapabilityNotSupportedError} on a backend without `ScreenStreaming` — caught here
325
+ * and reported as `Streaming: false`, leaving the client on its polling fallback. Any other failure is
326
+ * logged and likewise reported as `Streaming: false` (the poll keeps the view alive).
327
+ *
328
+ * @param agentSessionID The `AIAgentSession` id the browser is bound to.
329
+ * @returns `{ Streaming: true }` when frames are now being pushed, else `{ Streaming: false }`.
330
+ */
331
+ @Mutation(() => RemoteBrowserScreencastResult)
332
+ async StartRemoteBrowserScreencast(
333
+ @Arg('agentSessionID', () => String) agentSessionID: string,
334
+ @Ctx() { userPayload, providers }: AppContext,
335
+ @PubSub() pubSub: PubSubEngine,
336
+ ): Promise<RemoteBrowserScreencastResult> {
337
+ const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
338
+ const session = await this.loadOwnedSession(agentSessionID, contextUser, provider);
339
+
340
+ // Idempotent: a re-bind must not stack a second screencast on the one live browser.
341
+ if (this.startedScreencasts.has(agentSessionID)) {
342
+ return { Streaming: true };
343
+ }
344
+
345
+ const providerName = await this.resolveProviderName(session, contextUser, provider);
346
+ try {
347
+ const liveSession = await RemoteBrowserEngine.Instance.StartSessionForAgentSession(agentSessionID, contextUser, providerName);
348
+ await liveSession.StartScreencast((frame) => this.publishFrame(pubSub, userPayload, agentSessionID, frame));
349
+ this.startedScreencasts.add(agentSessionID);
350
+ return { Streaming: true };
351
+ } catch (err) {
352
+ if (err instanceof RemoteBrowserCapabilityNotSupportedError) {
353
+ // Backend can't stream — the client keeps polling. Not an error condition.
354
+ return { Streaming: false };
355
+ }
356
+ const message = err instanceof Error ? err.message : String(err);
357
+ LogError(`StartRemoteBrowserScreencast failed (provider='${providerName}'): ${message}`);
358
+ return { Streaming: false };
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Stops a screencast previously started by {@link RemoteBrowserActionResolver.StartRemoteBrowserScreencast}.
364
+ * Ownership-gated and best-effort: when no live browser exists, or `StopScreencast` rejects, the call
365
+ * still resolves `true` (the client's teardown should never depend on this succeeding).
366
+ *
367
+ * @param agentSessionID The `AIAgentSession` id.
368
+ * @returns `true` (always) once the stop has been attempted.
369
+ */
370
+ @Mutation(() => Boolean)
371
+ async StopRemoteBrowserScreencast(
372
+ @Arg('agentSessionID', () => String) agentSessionID: string,
373
+ @Ctx() { userPayload, providers }: AppContext,
374
+ ): Promise<boolean> {
375
+ const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
376
+ await this.loadOwnedSession(agentSessionID, contextUser, provider);
377
+
378
+ this.startedScreencasts.delete(agentSessionID);
379
+ const liveSession = RemoteBrowserEngine.Instance.GetSessionForAgentSession(agentSessionID);
380
+ if (liveSession) {
381
+ try {
382
+ await liveSession.StopScreencast();
383
+ } catch (err) {
384
+ // Best-effort: a backend without ScreenStreaming throws here too; teardown ignores it.
385
+ const message = err instanceof Error ? err.message : String(err);
386
+ LogError(`StopRemoteBrowserScreencast (best-effort) for session ${agentSessionID}: ${message}`);
387
+ }
388
+ }
389
+ return true;
390
+ }
391
+
392
+ /**
393
+ * Starts streaming the session browser's TAB AUDIO and PUSHES each encoded chunk to the calling user's
394
+ * push-status topic — so a co-agent demoing a video/audio site is HEARD, not just seen. Ownership-gated.
395
+ * Idempotent: a re-call for an already-streaming session is a no-op that reports `Streaming: true`.
396
+ *
397
+ * Capability gating (v1 = by backend implementation): {@link IRemoteBrowserSession.StartAudioStream}
398
+ * throws {@link RemoteBrowserCapabilityNotSupportedError} on a backend with no audio-capture mechanism —
399
+ * caught here and reported as `Streaming: false` (the client simply plays no audio). Any other failure is
400
+ * logged and likewise reported as `Streaming: false`.
401
+ *
402
+ * @param agentSessionID The `AIAgentSession` id the browser is bound to.
403
+ * @returns `{ Streaming: true }` when audio chunks are now being pushed, else `{ Streaming: false }`.
404
+ */
405
+ @Mutation(() => RemoteBrowserAudioStreamResult)
406
+ async StartRemoteBrowserAudioStream(
407
+ @Arg('agentSessionID', () => String) agentSessionID: string,
408
+ @Ctx() { userPayload, providers }: AppContext,
409
+ @PubSub() pubSub: PubSubEngine,
410
+ ): Promise<RemoteBrowserAudioStreamResult> {
411
+ const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
412
+ const session = await this.loadOwnedSession(agentSessionID, contextUser, provider);
413
+
414
+ // Idempotent: a re-bind must not stack a second audio capture on the one live browser.
415
+ if (this.startedAudioStreams.has(agentSessionID)) {
416
+ return { Streaming: true };
417
+ }
418
+
419
+ const providerName = await this.resolveProviderName(session, contextUser, provider);
420
+ try {
421
+ const liveSession = await RemoteBrowserEngine.Instance.StartSessionForAgentSession(agentSessionID, contextUser, providerName);
422
+ await liveSession.StartAudioStream((chunk) => this.publishAudioChunk(pubSub, userPayload, agentSessionID, chunk));
423
+ this.startedAudioStreams.add(agentSessionID);
424
+ return { Streaming: true };
425
+ } catch (err) {
426
+ if (err instanceof RemoteBrowserCapabilityNotSupportedError) {
427
+ // Backend can't capture audio — the client plays no audio. Not an error condition.
428
+ return { Streaming: false };
429
+ }
430
+ const message = err instanceof Error ? err.message : String(err);
431
+ LogError(`StartRemoteBrowserAudioStream failed (provider='${providerName}'): ${message}`);
432
+ return { Streaming: false };
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Stops a tab-audio stream previously started by {@link RemoteBrowserActionResolver.StartRemoteBrowserAudioStream}.
438
+ * Ownership-gated and best-effort: when no live browser exists, or `StopAudioStream` rejects, the call
439
+ * still resolves `true` (the client's teardown should never depend on this succeeding).
440
+ *
441
+ * @param agentSessionID The `AIAgentSession` id.
442
+ * @returns `true` (always) once the stop has been attempted.
443
+ */
444
+ @Mutation(() => Boolean)
445
+ async StopRemoteBrowserAudioStream(
446
+ @Arg('agentSessionID', () => String) agentSessionID: string,
447
+ @Ctx() { userPayload, providers }: AppContext,
448
+ ): Promise<boolean> {
449
+ const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
450
+ await this.loadOwnedSession(agentSessionID, contextUser, provider);
451
+
452
+ this.startedAudioStreams.delete(agentSessionID);
453
+ const liveSession = RemoteBrowserEngine.Instance.GetSessionForAgentSession(agentSessionID);
454
+ if (liveSession) {
455
+ try {
456
+ await liveSession.StopAudioStream();
457
+ } catch (err) {
458
+ // Best-effort: a backend without audio capture throws here too; teardown ignores it.
459
+ const message = err instanceof Error ? err.message : String(err);
460
+ LogError(`StopRemoteBrowserAudioStream (best-effort) for session ${agentSessionID}: ${message}`);
461
+ }
462
+ }
463
+ return true;
464
+ }
465
+
466
+ /**
467
+ * Relays ONE human-takeover input (pointer move/click or key press) from the user watching the live
468
+ * screencast into the session's server-hosted browser — the "grab the wheel" path. Ownership-gated.
469
+ * The surface captures the event on the live-view canvas, maps display→viewport coordinates, and calls
470
+ * this; the resolver builds a strongly-typed {@link RemoteBrowserHumanInput} and routes it over CDP via
471
+ * {@link IRemoteBrowserSession.RouteHumanInput}.
472
+ *
473
+ * Gracefully best-effort, never throws past the ownership gate:
474
+ * - No live browser for the session → `false`.
475
+ * - Unknown/incomplete input (`buildHumanInput` returns null) → `false`.
476
+ * - Backend lacks `HumanTakeover` ({@link RemoteBrowserCapabilityNotSupportedError}) → `false` (the
477
+ * live view stays view-only for that backend).
478
+ * - Any other failure → logged + `false`.
479
+ *
480
+ * NOTE: this prototype does NOT reject by control mode here — the engine's floor arbiter governs the
481
+ * agent⇄human floor, so we just relay. Finer floor / `AgentOnly` gating is a follow-up.
482
+ *
483
+ * @param agentSessionID The `AIAgentSession` id the browser is bound to.
484
+ * @param kind The input kind (`'pointer-move' | 'pointer-click' | 'pointer-down' | 'pointer-up' | 'key' | 'scroll'`).
485
+ * @param modifiers Optional comma-separated modifier keys held during the input (`'Shift'`, `'Control'`,
486
+ * `'Alt'`, `'Meta'`) — carries Shift-click selection and Ctrl/Cmd+key chords faithfully.
487
+ * @returns `true` when the input was routed, else `false`.
488
+ */
489
+ @Mutation(() => Boolean)
490
+ async RelayRemoteBrowserHumanInput(
491
+ @Arg('agentSessionID', () => String) agentSessionID: string,
492
+ @Arg('kind', () => String) kind: string,
493
+ @Ctx() { userPayload, providers }: AppContext,
494
+ @Arg('x', () => Float, { nullable: true }) x?: number,
495
+ @Arg('y', () => Float, { nullable: true }) y?: number,
496
+ @Arg('button', () => String, { nullable: true }) button?: string,
497
+ @Arg('key', () => String, { nullable: true }) key?: string,
498
+ @Arg('deltaX', () => Float, { nullable: true }) deltaX?: number,
499
+ @Arg('deltaY', () => Float, { nullable: true }) deltaY?: number,
500
+ @Arg('modifiers', () => String, { nullable: true }) modifiers?: string,
501
+ ): Promise<boolean> {
502
+ const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
503
+ await this.loadOwnedSession(agentSessionID, contextUser, provider);
504
+
505
+ const liveSession = RemoteBrowserEngine.Instance.GetSessionForAgentSession(agentSessionID);
506
+ if (!liveSession) {
507
+ return false;
508
+ }
509
+
510
+ const input = this.buildHumanInput({ kind, x, y, button, key, deltaX, deltaY, modifiers });
511
+ if (!input) {
512
+ return false;
513
+ }
514
+
515
+ try {
516
+ liveSession.RouteHumanInput(input);
517
+ return true;
518
+ } catch (err) {
519
+ if (err instanceof RemoteBrowserCapabilityNotSupportedError) {
520
+ // Backend can't take human input — the live view stays view-only. Not an error condition.
521
+ return false;
522
+ }
523
+ const message = err instanceof Error ? err.message : String(err);
524
+ LogError(`RelayRemoteBrowserHumanInput failed (kind='${kind}'): ${message}`);
525
+ return false;
526
+ }
527
+ }
528
+
529
+ // ----- internals -------------------------------------------------------------------------
530
+
531
+ /**
532
+ * Publishes one encoded screencast frame to the calling user's push-status topic, in the same envelope
533
+ * shape the conversations client already routes (mirrors `RealtimeClientSessionResolver`'s delegation
534
+ * progress publish). The client matches on `resolver` + `type`, then on `agentSessionID`, and paints
535
+ * `dataBase64` onto its canvas.
536
+ *
537
+ * @param pubSub The resolver-injected pub/sub engine.
538
+ * @param userPayload The calling user's payload (its `sessionId` scopes the topic to this browser).
539
+ * @param agentSessionID The `AIAgentSession` id the frame belongs to.
540
+ * @param frame The encoded viewport frame.
541
+ */
542
+ private publishFrame(
543
+ pubSub: PubSubEngine,
544
+ userPayload: UserPayload,
545
+ agentSessionID: string,
546
+ frame: { DataBase64: string; Width: number; Height: number; SequenceNumber: number },
547
+ ): void {
548
+ pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
549
+ message: JSON.stringify({
550
+ resolver: 'RemoteBrowserActionResolver',
551
+ type: 'RemoteBrowserScreencastFrame',
552
+ agentSessionID,
553
+ dataBase64: frame.DataBase64,
554
+ width: frame.Width,
555
+ height: frame.Height,
556
+ seq: frame.SequenceNumber,
557
+ }),
558
+ sessionId: userPayload.sessionId,
559
+ });
560
+ }
561
+
562
+ /**
563
+ * Publishes one encoded tab-audio chunk to the calling user's push-status topic, in the same envelope
564
+ * shape the conversations client routes for screencast frames (distinguished by `type`). The client
565
+ * matches on `resolver` + `type`, then on `agentSessionID`, and feeds `dataBase64` to its audio player.
566
+ *
567
+ * @param pubSub The resolver-injected pub/sub engine.
568
+ * @param userPayload The calling user's payload (its `sessionId` scopes the topic to this browser).
569
+ * @param agentSessionID The `AIAgentSession` id the chunk belongs to.
570
+ * @param chunk The encoded audio chunk.
571
+ */
572
+ private publishAudioChunk(
573
+ pubSub: PubSubEngine,
574
+ userPayload: UserPayload,
575
+ agentSessionID: string,
576
+ chunk: RemoteBrowserAudioChunk,
577
+ ): void {
578
+ pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
579
+ message: JSON.stringify({
580
+ resolver: 'RemoteBrowserActionResolver',
581
+ type: 'RemoteBrowserAudioChunk',
582
+ agentSessionID,
583
+ dataBase64: chunk.DataBase64,
584
+ codec: chunk.Codec,
585
+ sampleRate: chunk.SampleRate,
586
+ channels: chunk.Channels,
587
+ seq: chunk.SequenceNumber,
588
+ }),
589
+ sessionId: userPayload.sessionId,
590
+ });
591
+ }
592
+
593
+ /**
594
+ * Runs the governable `Remote Browser Visual Interpreter` AI Prompt over a screenshot + request, and maps
595
+ * its strict-JSON output to {@link RemoteBrowserInterpretation}. The model is chosen by the PROMPT's own
596
+ * `MJ: AI Prompt Models` association (pinned to Gemini 3.1 Flash-Lite in metadata) — not hard-coded here.
597
+ * Tolerant: a missing prompt, a failed run, or unparseable output all degrade to a best-effort result.
598
+ *
599
+ * @param screenshotBase64 The raw Base64 viewport screenshot (no `data:` prefix).
600
+ * @param query The agent's request — empty/"describe" for a description, else a target to localize.
601
+ * @param contextUser The owning user (server-side multi-user safety).
602
+ * @returns The mapped interpretation.
603
+ */
604
+ private async runVisualInterpreter(screenshotBase64: string, query: string | undefined, contextUser: UserInfo): Promise<RemoteBrowserInterpretation> {
605
+ await AIEngine.Instance.Config(false, contextUser);
606
+ const promptEntity = AIEngine.Instance.Prompts.find((p) => p.Name?.trim().toLowerCase() === VISUAL_INTERPRETER_PROMPT_NAME.toLowerCase());
607
+ if (!promptEntity) {
608
+ const detail = `Visual interpreter prompt '${VISUAL_INTERPRETER_PROMPT_NAME}' not found.`;
609
+ LogError(detail);
610
+ return { Description: undefined, Elements: [], Detail: detail };
611
+ }
612
+
613
+ const instruction = this.buildInterpreterInstruction(query);
614
+ const params = new AIPromptParams();
615
+ params.prompt = promptEntity;
616
+ params.contextUser = contextUser;
617
+ // The template surfaces the request via a {{ query }} variable; the screenshot rides as an image block.
618
+ params.data = { query: instruction };
619
+ params.conversationMessages = [
620
+ {
621
+ role: 'user',
622
+ content: [
623
+ { type: 'text', content: instruction },
624
+ // Declare the explicit MIME so the modality check matches a concrete
625
+ // 'image/jpeg' driver capability (not just a wildcard probe).
626
+ { type: 'image_url', content: `data:image/jpeg;base64,${screenshotBase64}`, mimeType: 'image/jpeg' },
627
+ ],
628
+ },
629
+ ];
630
+
631
+ const runner = new AIPromptRunner();
632
+ const result = await runner.ExecutePrompt(params);
633
+ if (!result.success) {
634
+ const detail = result.errorMessage ?? 'Visual interpreter prompt failed.';
635
+ LogError(`InterpretRemoteBrowserPage: vision prompt failed: ${detail}`);
636
+ return { Description: undefined, Elements: [], Detail: detail };
637
+ }
638
+ return this.mapInterpreterResult(result.rawResult);
639
+ }
640
+
641
+ /**
642
+ * Builds the natural-language instruction handed to the visual interpreter from the raw `query`. An empty
643
+ * / blank / "describe" request asks for a concise page description; anything else asks to locate that target
644
+ * and return its pixel centroid.
645
+ *
646
+ * @param query The raw request from the agent.
647
+ * @returns The instruction string.
648
+ */
649
+ private buildInterpreterInstruction(query: string | undefined): string {
650
+ const trimmed = query?.trim();
651
+ if (!trimmed || trimmed.toLowerCase() === 'describe') {
652
+ return 'Describe concisely what is visible and actionable on this page. Return an empty elements array.';
653
+ }
654
+ return (
655
+ `Find this UI element and report its approximate pixel centroid: "${trimmed}". ` +
656
+ 'Also give a one-sentence description of the page for context. ' +
657
+ 'If the element is not present, return an empty elements array.'
658
+ );
659
+ }
660
+
661
+ /**
662
+ * Tolerantly parses the interpreter's raw text output into a {@link RemoteBrowserInterpretation}. Strips
663
+ * code fences before parsing; on parse failure returns the raw text as `Description` with no elements.
664
+ *
665
+ * @param raw The prompt's raw text output (may be null/undefined/fenced JSON).
666
+ * @returns The mapped interpretation.
667
+ */
668
+ private mapInterpreterResult(raw: string | null | undefined): RemoteBrowserInterpretation {
669
+ const text = (raw ?? '').trim();
670
+ if (text.length === 0) {
671
+ return { Description: undefined, Elements: [], Detail: 'Empty interpreter response.' };
672
+ }
673
+ const stripped = this.stripCodeFences(text);
674
+ let payload: VisualInterpreterPayload;
675
+ try {
676
+ const parsed: unknown = JSON.parse(stripped);
677
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
678
+ return { Description: text, Elements: [], Detail: 'Interpreter output was not a JSON object.' };
679
+ }
680
+ payload = parsed as VisualInterpreterPayload;
681
+ } catch {
682
+ // Not JSON — hand the raw text back as the description so the agent still gets something useful.
683
+ return { Description: text, Elements: [] };
684
+ }
685
+
686
+ const elements = Array.isArray(payload.elements)
687
+ ? payload.elements
688
+ .filter((e) => typeof e?.x === 'number' && typeof e?.y === 'number')
689
+ .map((e) => ({
690
+ Label: typeof e.label === 'string' ? e.label : '',
691
+ X: e.x as number,
692
+ Y: e.y as number,
693
+ Confidence: typeof e.confidence === 'number' ? e.confidence : 0,
694
+ }))
695
+ : [];
696
+ return {
697
+ Description: typeof payload.description === 'string' ? payload.description : undefined,
698
+ Elements: elements,
699
+ };
700
+ }
701
+
702
+ /**
703
+ * Strips a leading/trailing Markdown code fence (```json … ```) from a model response so the inner JSON can
704
+ * be parsed. Returns the input unchanged when no fence is present.
705
+ *
706
+ * @param text The possibly-fenced text.
707
+ * @returns The text with any surrounding fence removed.
708
+ */
709
+ private stripCodeFences(text: string): string {
710
+ const fence = /^```(?:json)?\s*([\s\S]*?)\s*```$/i;
711
+ const match = fence.exec(text.trim());
712
+ return match ? match[1].trim() : text;
713
+ }
714
+
715
+ /** Resolve the request user + read-write provider, throwing a clear error if unauthenticated. */
716
+ protected requireUserAndProvider(
717
+ userPayload: AppContext['userPayload'],
718
+ providers: AppContext['providers'],
719
+ ): { contextUser: UserInfo; provider: IMetadataProvider } {
720
+ const contextUser = this.GetUserFromPayload(userPayload);
721
+ if (!contextUser) {
722
+ throw new Error('Not authenticated: no user context for remote-browser operation');
723
+ }
724
+ return { contextUser, provider: GetReadWriteProvider(providers) };
725
+ }
726
+
727
+ /**
728
+ * Loads the agent session and enforces inbound ownership: the session's `UserID` must equal the
729
+ * caller's. Throws when the session is missing or owned by another user.
730
+ *
731
+ * @param agentSessionID The `AIAgentSession` id.
732
+ * @returns The loaded, owned session entity.
733
+ */
734
+ protected async loadOwnedSession(agentSessionID: string, contextUser: UserInfo, provider: IMetadataProvider): Promise<MJAIAgentSessionEntity> {
735
+ const session = await provider.GetEntityObject<MJAIAgentSessionEntity>(SESSION_ENTITY, contextUser);
736
+ if (!(await session.Load(agentSessionID))) {
737
+ throw new Error(`Remote-browser session ${agentSessionID} not found.`);
738
+ }
739
+ if (!UUIDsEqual(session.UserID, contextUser.ID)) {
740
+ throw new Error(`Not authorized: remote-browser session ${agentSessionID} is not owned by you.`);
741
+ }
742
+ return session;
743
+ }
744
+
745
+ /**
746
+ * Resolves the remote-browser backend name for the session from the session's agent's
747
+ * `TypeConfiguration` JSON (`{ remoteBrowser: { provider } }`). Best-effort: a missing agent, absent
748
+ * config, or unparseable JSON all yield `undefined`, letting the engine fall back to the single Active
749
+ * provider.
750
+ *
751
+ * @param session The owned session entity (supplies the agent id).
752
+ * @returns The configured backend name, or `undefined` to let the engine auto-select.
753
+ */
754
+ protected async resolveProviderName(session: MJAIAgentSessionEntity, contextUser: UserInfo, provider: IMetadataProvider): Promise<string | undefined> {
755
+ // The session's agent IS the co-agent (the realtime voice agent), and the interactive channels
756
+ // (Remote Browser, Whiteboard) are the CO-AGENT's abilities — so the remoteBrowser backend config
757
+ // lives on the co-agent's TypeConfiguration, not on the target agent it voices.
758
+ try {
759
+ const agent = await provider.GetEntityObject<MJAIAgentEntity>(AGENT_ENTITY, contextUser);
760
+ if (!(await agent.Load(session.AgentID))) {
761
+ return undefined;
762
+ }
763
+ const config = this.parseTypeConfiguration(agent.TypeConfiguration);
764
+ const name = config?.remoteBrowser?.provider?.trim();
765
+ return name && name.length > 0 ? name : undefined;
766
+ } catch (error) {
767
+ LogError(
768
+ `ExecuteRemoteBrowserAction: failed to read agent TypeConfiguration for session ${session.ID} — ` +
769
+ `falling back to the single Active provider: ${error instanceof Error ? error.message : String(error)}`,
770
+ );
771
+ return undefined;
772
+ }
773
+ }
774
+
775
+ /**
776
+ * Parses an agent's `TypeConfiguration` JSON into the {@link RemoteBrowserTypeConfiguration} slice this
777
+ * resolver reads. Returns `null` for null/blank/non-object/unparseable input.
778
+ *
779
+ * @param json The raw `TypeConfiguration` JSON, or null.
780
+ * @returns The parsed config slice, or `null`.
781
+ */
782
+ private parseTypeConfiguration(json: string | null): RemoteBrowserTypeConfiguration | null {
783
+ if (!json || json.trim().length === 0) {
784
+ return null;
785
+ }
786
+ try {
787
+ const parsed: unknown = JSON.parse(json);
788
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as RemoteBrowserTypeConfiguration) : null;
789
+ } catch {
790
+ return null;
791
+ }
792
+ }
793
+
794
+ /**
795
+ * Builds a strongly-typed {@link RemoteBrowserAction} from the relayed `kind` + fields, validating the
796
+ * fields each kind requires. Returns `null` for an unknown kind or a kind missing its required field(s).
797
+ *
798
+ * @param input The relayed action kind + all optional fields.
799
+ * @returns The built action, or `null` when the kind is unknown / incomplete.
800
+ */
801
+ private buildAction(input: {
802
+ kind: string;
803
+ url?: string;
804
+ selector?: string;
805
+ x?: number;
806
+ y?: number;
807
+ text?: string;
808
+ key?: string;
809
+ deltaX?: number;
810
+ deltaY?: number;
811
+ ms?: number;
812
+ }): RemoteBrowserAction | null {
813
+ switch (input.kind) {
814
+ case 'navigate':
815
+ return input.url ? { Kind: 'navigate', Url: input.url } : null;
816
+ case 'click':
817
+ if (input.selector || (typeof input.x === 'number' && typeof input.y === 'number')) {
818
+ return { Kind: 'click', Selector: input.selector, X: input.x, Y: input.y };
819
+ }
820
+ return null;
821
+ case 'type':
822
+ return typeof input.text === 'string' ? { Kind: 'type', Text: input.text, Selector: input.selector } : null;
823
+ case 'key':
824
+ return input.key ? { Kind: 'key', Key: input.key } : null;
825
+ case 'scroll':
826
+ if (input.selector || typeof input.deltaX === 'number' || typeof input.deltaY === 'number') {
827
+ return { Kind: 'scroll', DeltaX: input.deltaX, DeltaY: input.deltaY, Selector: input.selector };
828
+ }
829
+ return null;
830
+ case 'back':
831
+ return { Kind: 'back' };
832
+ case 'forward':
833
+ return { Kind: 'forward' };
834
+ case 'wait':
835
+ if (typeof input.ms === 'number' || input.selector) {
836
+ return { Kind: 'wait', Ms: input.ms, Selector: input.selector };
837
+ }
838
+ return null;
839
+ default:
840
+ return null;
841
+ }
842
+ }
843
+
844
+ /**
845
+ * Builds a strongly-typed {@link RemoteBrowserHumanInput} from the relayed `kind` + fields, validating
846
+ * each kind's required field(s). Returns `null` for an unknown kind or a kind missing its required
847
+ * field(s): pointer-move/click/down/up need finite `x`,`y`; key needs a non-empty `key`; scroll needs
848
+ * finite `x`,`y`,`deltaX`,`deltaY`. The `button` is clamped to the allowed union
849
+ * (`'left' | 'middle' | 'right'`), defaulting unknown/absent values to `'left'`. Held `modifiers`
850
+ * (e.g. Shift-click, Ctrl/Cmd+key) ride on pointer clicks/presses and key presses.
851
+ *
852
+ * @param input The relayed input kind + all optional fields.
853
+ * @returns The built human input, or `null` when the kind is unknown / incomplete.
854
+ */
855
+ private buildHumanInput(input: { kind: string; x?: number; y?: number; button?: string; key?: string; deltaX?: number; deltaY?: number; modifiers?: string }): RemoteBrowserHumanInput | null {
856
+ const modifiers = this.parseModifiers(input.modifiers);
857
+ const hasXy = Number.isFinite(input.x) && Number.isFinite(input.y);
858
+ switch (input.kind) {
859
+ case 'pointer-move':
860
+ return hasXy ? { Kind: 'pointer-move', X: input.x as number, Y: input.y as number } : null;
861
+ case 'pointer-click':
862
+ return hasXy
863
+ ? { Kind: 'pointer-click', X: input.x as number, Y: input.y as number, Button: this.clampButton(input.button), ...(modifiers.length ? { Modifiers: modifiers } : {}) }
864
+ : null;
865
+ case 'pointer-down':
866
+ return hasXy
867
+ ? { Kind: 'pointer-down', X: input.x as number, Y: input.y as number, Button: this.clampButton(input.button), ...(modifiers.length ? { Modifiers: modifiers } : {}) }
868
+ : null;
869
+ case 'pointer-up':
870
+ return hasXy
871
+ ? { Kind: 'pointer-up', X: input.x as number, Y: input.y as number, Button: this.clampButton(input.button), ...(modifiers.length ? { Modifiers: modifiers } : {}) }
872
+ : null;
873
+ case 'key':
874
+ return input.key && input.key.length > 0 ? { Kind: 'key', Key: input.key, ...(modifiers.length ? { Modifiers: modifiers } : {}) } : null;
875
+ case 'scroll':
876
+ return hasXy && Number.isFinite(input.deltaX) && Number.isFinite(input.deltaY)
877
+ ? { Kind: 'scroll', X: input.x as number, Y: input.y as number, DeltaX: input.deltaX as number, DeltaY: input.deltaY as number }
878
+ : null;
879
+ default:
880
+ return null;
881
+ }
882
+ }
883
+
884
+ /** Clamps a relayed mouse-button string to the allowed union, defaulting to `'left'`. */
885
+ private clampButton(button: string | undefined): 'left' | 'middle' | 'right' {
886
+ return button === 'middle' || button === 'right' ? button : 'left';
887
+ }
888
+
889
+ /**
890
+ * Parses the relayed comma-separated `modifiers` string into the validated
891
+ * {@link RemoteBrowserModifierKey} list, dropping any unrecognized token. Returns an empty array for a
892
+ * null/blank input.
893
+ *
894
+ * @param modifiers The raw `'Shift,Control'`-style CSV, or undefined.
895
+ * @returns The validated modifier list (possibly empty).
896
+ */
897
+ private parseModifiers(modifiers: string | undefined): RemoteBrowserModifierKey[] {
898
+ if (!modifiers) {
899
+ return [];
900
+ }
901
+ const allowed: RemoteBrowserModifierKey[] = ['Shift', 'Control', 'Alt', 'Meta'];
902
+ return modifiers
903
+ .split(',')
904
+ .map((m) => m.trim())
905
+ .filter((m): m is RemoteBrowserModifierKey => (allowed as string[]).includes(m));
906
+ }
907
+ }