@testdriverai/mcp 7.11.5-test → 7.11.7-canary

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.
@@ -18,9 +18,10 @@
18
18
  * mirroring how the stdio MCP server has always behaved.
19
19
  */
20
20
 
21
+ import { AsyncLocalStorage } from "node:async_hooks";
21
22
  import { generateActionCode } from "../codegen.js";
22
23
  import { getProvisionOptions, SessionStartInputSchema, type SessionStartInput } from "../provision-types.js";
23
- import { sessionManager, type SessionState } from "../session.js";
24
+ import { SessionManager, sessionManager as globalSessionManager, type SessionState } from "../session.js";
24
25
 
25
26
  // Re-export the input contract so adapters (MCP server, eve tools) share one
26
27
  // source of truth for the session_start schema instead of redeclaring it.
@@ -62,21 +63,120 @@ export class NoActiveSessionError extends Error {
62
63
  }
63
64
 
64
65
  // =============================================================================
65
- // Singleton state (single sandbox per process)
66
+ // Per-connection state (isolated via AsyncLocalStorage)
66
67
  // =============================================================================
67
68
 
68
- let sdk: any = null;
69
- let lastScreenshotBase64: string | null = null;
69
+ /**
70
+ * The mutable state a single sandbox session needs: the live SDK handle, the
71
+ * element-ref map from `find`/`findall`, the "last screenshot" `check` diffs
72
+ * against, the adapter recovery hook, the in-flight reconnect promise, and the
73
+ * session-lifecycle manager.
74
+ *
75
+ * This used to be a bag of module-level singletons ("single sandbox per
76
+ * process"). That is correct for hosts that ARE one-session-per-process — eve
77
+ * (each durable turn is its own recycled process) and the stdio MCP server — but
78
+ * the Streamable-HTTP MCP server is one long-lived process serving *concurrent*
79
+ * clients, where shared singletons let one client's tool call read another
80
+ * client's sandbox. So the state moves into {@link CoreContext} and is resolved
81
+ * per async call via {@link als}.
82
+ */
83
+ export interface CoreContext {
84
+ sdk: any;
85
+ lastScreenshotBase64: string | null;
86
+ reconnectResolver: ReconnectResolver | null;
87
+ /** In-flight reconnect, so concurrent actions in one context share one rebuild. */
88
+ reconnecting: Promise<void> | null;
89
+ /** Stored element instances from `find`/`findall`, addressable by ref. */
90
+ elementRefs: Map<
91
+ string,
92
+ { element: any; description: string; coords: { x: number; y: number; centerX: number; centerY: number } }
93
+ >;
94
+ /** Session lifecycle for THIS context. */
95
+ sessions: SessionManager;
96
+ /**
97
+ * Opaque per-connection scratch space for adapters (e.g. the MCP server's
98
+ * image store). mcp-core doesn't read this; it just guarantees each isolated
99
+ * context gets its own object, so adapter-side per-connection state rides along
100
+ * with the same AsyncLocalStorage isolation instead of needing a second one.
101
+ */
102
+ adapter: Record<string, unknown>;
103
+ }
104
+
105
+ /** Build a fresh, empty context. Callers that want isolation mint one per connection. */
106
+ export function createCoreContext(): CoreContext {
107
+ return {
108
+ sdk: null,
109
+ lastScreenshotBase64: null,
110
+ reconnectResolver: null,
111
+ reconnecting: null,
112
+ elementRefs: new Map(),
113
+ // Reuse the shared SessionManager for the global context so that consumers
114
+ // still importing `sessionManager` from ../session observe the same state;
115
+ // isolated contexts get their own instance.
116
+ sessions: globalSessionManager,
117
+ adapter: {},
118
+ };
119
+ }
120
+
121
+ const als = new AsyncLocalStorage<CoreContext>();
122
+
123
+ /**
124
+ * The fallback context used when no isolated context is active — i.e. for eve
125
+ * and the stdio MCP server, which are one-session-per-process and never call
126
+ * {@link runInContext}. Behavior for those hosts is byte-identical to the old
127
+ * module-global singletons.
128
+ */
129
+ const globalContext: CoreContext = createCoreContext();
130
+
131
+ /** Resolve the active context: the ALS store if one is running, else the global. */
132
+ function ctx(): CoreContext {
133
+ return als.getStore() ?? globalContext;
134
+ }
135
+
136
+ /**
137
+ * Run `fn` with `context` as the active {@link CoreContext}, isolating every
138
+ * action's state (sdk, element refs, session, recovery hook) for the duration.
139
+ * The HTTP MCP server wraps each connection's request handling in this so
140
+ * concurrent clients never share a sandbox. Hosts that don't call this keep
141
+ * using {@link globalContext} exactly as before.
142
+ */
143
+ export function runInContext<T>(context: CoreContext, fn: () => T): T {
144
+ return als.run(context, fn);
145
+ }
146
+
147
+ /** Build an isolated context whose SessionManager is its own (not the global). */
148
+ export function createIsolatedContext(): CoreContext {
149
+ const c = createCoreContext();
150
+ c.sessions = new SessionManager();
151
+ return c;
152
+ }
153
+
154
+ /** The active context's adapter scratch space (see {@link CoreContext.adapter}). */
155
+ export function getAdapterState(): Record<string, unknown> {
156
+ return ctx().adapter;
157
+ }
158
+
159
+ /** The active context's current session (null when none). Adapters use this for
160
+ * read-only lookups (e.g. the MCP server's testFile/expiry) that must resolve
161
+ * the caller's own session, not a process-global one. */
162
+ export function getCurrentSession(): SessionState | null {
163
+ return ctx().sessions.getCurrentSession();
164
+ }
165
+
166
+ /** Time remaining (ms) on a session in the active context. */
167
+ export function getSessionTimeRemaining(sessionId: string): number {
168
+ return ctx().sessions.getTimeRemaining(sessionId);
169
+ }
70
170
 
71
171
  /**
72
- * Optional adapter-supplied recovery hook. When the singleton SDK is missing or
73
- * stale at action time, {@link requireActiveSession} calls this to obtain the
74
- * params needed to rebuild the connection from the still-alive sandbox, then
75
- * reconnects before throwing NO_SESSION.
172
+ * Optional adapter-supplied recovery hook. When the SDK is missing or stale at
173
+ * action time, {@link requireActiveSession} calls this to obtain the params
174
+ * needed to rebuild the connection from the still-alive sandbox, then reconnects
175
+ * before throwing NO_SESSION.
76
176
  *
77
177
  * Why a hook instead of mcp-core owning the handle: the durable facts (sandbox
78
178
  * id, config) and the API key must survive a host process recycle, but this
79
- * module's globals do NOT — they reset on the very recycle we're recovering
179
+ * context's state does NOT — it resets on the very recycle we're recovering
80
180
  * from. So the *durable owner* (eve's per-session state) registers a resolver
81
181
  * that reads its own durable store and re-resolves the key per call. Hosts with
82
182
  * one long-lived process (stdio MCP server, CLI) register nothing: the resolver
@@ -85,38 +185,28 @@ let lastScreenshotBase64: string | null = null;
85
185
  *
86
186
  * The resolver returns `null` when it has nothing to recover from (no sandbox
87
187
  * provisioned this session), in which case we fall through to the normal throw.
88
- * It is re-registered each step by the adapter (module globals reset on recycle),
188
+ * It is re-registered each step by the adapter (context state resets on recycle),
89
189
  * so a stale closure can't outlive the process it was bound to.
90
190
  */
91
191
  export type ReconnectResolver = () => Promise<ReconnectParams | null>;
92
- let reconnectResolver: ReconnectResolver | null = null;
93
192
 
94
193
  /**
95
194
  * Register (or clear, with `null`) the recovery hook. Durable adapters call this
96
195
  * at the start of each step with a resolver bound to the current tool context.
97
196
  */
98
197
  export function setReconnectResolver(resolver: ReconnectResolver | null): void {
99
- reconnectResolver = resolver;
198
+ ctx().reconnectResolver = resolver;
100
199
  }
101
200
 
102
- /** In-flight reconnect, so concurrent actions in one step share one rebuild. */
103
- let reconnecting: Promise<void> | null = null;
104
-
105
- /** Stored element instances from `find`/`findall`, addressable by ref. */
106
- const elementRefs = new Map<
107
- string,
108
- { element: any; description: string; coords: { x: number; y: number; centerX: number; centerY: number } }
109
- >();
110
-
111
201
  /** Expose internals the adapters legitimately need (read-only intent). */
112
202
  export function getSdk(): any {
113
- return sdk;
203
+ return ctx().sdk;
114
204
  }
115
205
  export function getLastScreenshotBase64(): string | null {
116
- return lastScreenshotBase64;
206
+ return ctx().lastScreenshotBase64;
117
207
  }
118
208
  export function getElementRef(ref: string) {
119
- return elementRefs.get(ref);
209
+ return ctx().elementRefs.get(ref);
120
210
  }
121
211
 
122
212
  /**
@@ -127,8 +217,9 @@ export function getElementRef(ref: string) {
127
217
  * does NOT refresh the keepAlive window — it only reports liveness.
128
218
  */
129
219
  export function hasLiveSession(): boolean {
130
- const session = sessionManager.getCurrentSession();
131
- return !!sdk && !!session && sessionManager.isSessionValid(session.sessionId);
220
+ const c = ctx();
221
+ const session = c.sessions.getCurrentSession();
222
+ return !!c.sdk && !!session && c.sessions.isSessionValid(session.sessionId);
132
223
  }
133
224
 
134
225
  // =============================================================================
@@ -206,36 +297,37 @@ function realtimeIsHealthy(s: any): boolean {
206
297
  * {@link reconnecting} so a parked socket isn't reconnected N times in parallel.
207
298
  */
208
299
  async function tryRecoverSession(): Promise<boolean> {
209
- if (!reconnectResolver) return false;
210
- if (!reconnecting) {
211
- const resolver = reconnectResolver;
212
- reconnecting = (async () => {
300
+ const c = ctx();
301
+ if (!c.reconnectResolver) return false;
302
+ if (!c.reconnecting) {
303
+ const resolver = c.reconnectResolver;
304
+ c.reconnecting = (async () => {
213
305
  const params = await resolver();
214
306
  if (!params?.sandboxId) return; // nothing provisioned this session
215
307
  // Close any half-dead socket on the outgoing SDK before replacing it, so a
216
308
  // parked-then-rebuilt session doesn't leak an orphaned Ably connection.
217
309
  try {
218
- sdk?.sandbox?._ably?.close?.();
310
+ c.sdk?.sandbox?._ably?.close?.();
219
311
  } catch {
220
312
  /* best effort — reconnectSession installs a fresh SDK regardless */
221
313
  }
222
314
  await reconnectSession(params);
223
315
  })().finally(() => {
224
- reconnecting = null;
316
+ c.reconnecting = null;
225
317
  });
226
318
  }
227
319
  try {
228
- await reconnecting;
320
+ await c.reconnecting;
229
321
  } catch (err) {
230
- sdk = null;
322
+ c.sdk = null;
231
323
  if (err instanceof NoActiveSessionError) throw err;
232
324
  throw new NoActiveSessionError(
233
325
  "SESSION_EXPIRED",
234
326
  `Could not reconnect to the sandbox: ${err instanceof Error ? err.message : String(err)}. The sandbox has expired — call session_start again to create a new one.`
235
327
  );
236
328
  }
237
- const session = sessionManager.getCurrentSession();
238
- return !!sdk && !!session && sessionManager.isSessionValid(session.sessionId);
329
+ const session = c.sessions.getCurrentSession();
330
+ return !!c.sdk && !!session && c.sessions.isSessionValid(session.sessionId);
239
331
  }
240
332
 
241
333
  /**
@@ -249,18 +341,19 @@ async function tryRecoverSession(): Promise<boolean> {
249
341
  * to recover from.
250
342
  */
251
343
  async function requireActiveSession(): Promise<void> {
252
- const session = sessionManager.getCurrentSession();
344
+ const c = ctx();
345
+ const session = c.sessions.getCurrentSession();
253
346
 
254
347
  // Recoverable conditions:
255
- // - singletons wiped by a host recycle (`!sdk || !session`), or
348
+ // - context state wiped by a host recycle (`!sdk || !session`), or
256
349
  // - the SDK exists but its realtime socket died during a park
257
350
  // (`!realtimeIsHealthy`). We only treat a dead socket as recoverable when a
258
351
  // recovery hook is registered — i.e. a durable host (eve) that actually
259
352
  // parks. Long-lived hosts (stdio MCP, CLI) register no resolver, so a
260
353
  // transient in-process Ably blip is left to Ably's own auto-reconnect rather
261
- // than forcing a full singleton rebuild — preserving their prior behavior.
262
- const socketDead = !!sdk && !!session && !!reconnectResolver && !realtimeIsHealthy(sdk);
263
- const needsRecovery = !sdk || !session || socketDead;
354
+ // than forcing a full rebuild — preserving their prior behavior.
355
+ const socketDead = !!c.sdk && !!session && !!c.reconnectResolver && !realtimeIsHealthy(c.sdk);
356
+ const needsRecovery = !c.sdk || !session || socketDead;
264
357
  if (needsRecovery) {
265
358
  const recovered = await tryRecoverSession();
266
359
  if (!recovered) {
@@ -268,14 +361,14 @@ async function requireActiveSession(): Promise<void> {
268
361
  // If the socket was dead, don't send the action into the void — surface
269
362
  // SESSION_EXPIRED so the agent re-provisions instead of hanging.
270
363
  if (socketDead) {
271
- sdk = null;
364
+ c.sdk = null;
272
365
  throw new NoActiveSessionError(
273
366
  "SESSION_EXPIRED",
274
367
  "The sandbox connection was lost and could not be restored. Call session_start again to create a new sandbox session.",
275
368
  session?.sessionId
276
369
  );
277
370
  }
278
- // Otherwise the singleton was simply absent — the standard NO_SESSION.
371
+ // Otherwise the SDK was simply absent — the standard NO_SESSION.
279
372
  throw new NoActiveSessionError(
280
373
  "NO_SESSION",
281
374
  "No active session. Call session_start first to create a sandbox before using any other tools."
@@ -283,9 +376,9 @@ async function requireActiveSession(): Promise<void> {
283
376
  }
284
377
  }
285
378
 
286
- const current = sessionManager.getCurrentSession();
287
- if (!current || !sessionManager.isSessionValid(current.sessionId)) {
288
- sdk = null;
379
+ const current = c.sessions.getCurrentSession();
380
+ if (!current || !c.sessions.isSessionValid(current.sessionId)) {
381
+ c.sdk = null;
289
382
  throw new NoActiveSessionError(
290
383
  "SESSION_EXPIRED",
291
384
  "Session has expired or timed out. Call session_start again to create a new sandbox session.",
@@ -294,13 +387,13 @@ async function requireActiveSession(): Promise<void> {
294
387
  }
295
388
 
296
389
  // Reset the keepAlive timer on each command so active use doesn't expire.
297
- sessionManager.refreshSession(current.sessionId);
390
+ c.sessions.refreshSession(current.sessionId);
298
391
  }
299
392
 
300
393
  /** Capture a fresh full-screen screenshot as bare base64 (or null on failure). */
301
394
  async function captureScreen(): Promise<string | null> {
302
395
  try {
303
- const b64 = await sdk.agent.system.captureScreenBase64(1, false, true);
396
+ const b64 = await ctx().sdk.agent.system.captureScreenBase64(1, false, true);
304
397
  return b64 || null;
305
398
  } catch {
306
399
  return null;
@@ -326,6 +419,7 @@ export async function sessionStart(
326
419
  resolved: { os: "linux" | "windows"; e2bTemplateId?: string; apiKey?: string },
327
420
  hooks: SessionStartHooks = {}
328
421
  ): Promise<ActionResult> {
422
+ const c = ctx();
329
423
  const progress = hooks.onProgress ?? (() => {});
330
424
 
331
425
  // Validate required fields for specific provision types (unless reconnecting).
@@ -338,7 +432,7 @@ export async function sessionStart(
338
432
  }
339
433
  }
340
434
 
341
- const newSession = sessionManager.createSession({
435
+ const newSession = c.sessions.createSession({
342
436
  os: resolved.os,
343
437
  keepAlive: params.keepAlive,
344
438
  testFile: params.testFile,
@@ -366,7 +460,7 @@ export async function sessionStart(
366
460
  }
367
461
 
368
462
  const TestDriverSDK = (await loadTestDriverSdk()).default;
369
- sdk = new TestDriverSDK(apiKey, {
463
+ c.sdk = new TestDriverSDK(apiKey, {
370
464
  os: resolved.os,
371
465
  logging: false,
372
466
  apiRoot,
@@ -378,13 +472,13 @@ export async function sessionStart(
378
472
  // Debug mode — attach to an existing sandbox, skip provisioning.
379
473
  if (params.sandboxId) {
380
474
  progress(`Connecting to existing sandbox ${params.sandboxId}...`);
381
- await sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
382
- const instance = sdk.getInstance();
383
- sessionManager.activateSession(newSession.sessionId, instance?.instanceId || params.sandboxId);
475
+ await c.sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
476
+ const instance = c.sdk.getInstance();
477
+ c.sessions.activateSession(newSession.sessionId, instance?.instanceId || params.sandboxId);
384
478
 
385
479
  progress("Capturing screenshot...");
386
480
  const shot = await captureScreen();
387
- if (shot) lastScreenshotBase64 = shot;
481
+ if (shot) c.lastScreenshotBase64 = shot;
388
482
 
389
483
  return {
390
484
  ok: true,
@@ -396,10 +490,10 @@ export async function sessionStart(
396
490
  }
397
491
 
398
492
  progress(instanceIp ? `Connecting to self-hosted instance ${instanceIp}...` : "Connecting to cloud sandbox...");
399
- await sdk.connect({ reconnect: params.reconnect, keepAlive: params.keepAlive, ip: instanceIp });
493
+ await c.sdk.connect({ reconnect: params.reconnect, keepAlive: params.keepAlive, ip: instanceIp });
400
494
 
401
- const instance = sdk.getInstance();
402
- sessionManager.activateSession(newSession.sessionId, instance?.instanceId || "unknown");
495
+ const instance = c.sdk.getInstance();
496
+ c.sessions.activateSession(newSession.sessionId, instance?.instanceId || "unknown");
403
497
 
404
498
  const provisionOptions = getProvisionOptions(params);
405
499
  let provisionCmd = "";
@@ -407,30 +501,30 @@ export async function sessionStart(
407
501
  progress(`Provisioning ${params.type}...`);
408
502
  switch (params.type) {
409
503
  case "chrome":
410
- await sdk.provision.chrome(provisionOptions);
504
+ await c.sdk.provision.chrome(provisionOptions);
411
505
  provisionCmd = "provision.chrome";
412
506
  break;
413
507
  case "chromeExtension":
414
- await sdk.provision.chromeExtension(provisionOptions);
508
+ await c.sdk.provision.chromeExtension(provisionOptions);
415
509
  provisionCmd = "provision.chromeExtension";
416
510
  break;
417
511
  case "vscode":
418
- await sdk.provision.vscode(provisionOptions);
512
+ await c.sdk.provision.vscode(provisionOptions);
419
513
  provisionCmd = "provision.vscode";
420
514
  break;
421
515
  case "installer":
422
- await sdk.provision.installer(provisionOptions);
516
+ await c.sdk.provision.installer(provisionOptions);
423
517
  provisionCmd = "provision.installer";
424
518
  break;
425
519
  case "electron":
426
- await sdk.provision.electron(provisionOptions);
520
+ await c.sdk.provision.electron(provisionOptions);
427
521
  provisionCmd = "provision.electron";
428
522
  break;
429
523
  }
430
524
 
431
525
  progress("Capturing screenshot...");
432
526
  const shot = await captureScreen();
433
- if (shot) lastScreenshotBase64 = shot;
527
+ if (shot) c.lastScreenshotBase64 = shot;
434
528
 
435
529
  const debuggerUrl = instance?.debuggerUrl || (instanceIp ? `http://${instanceIp}:9222` : null);
436
530
  const connectionType = instanceIp ? `Self-hosted (${instanceIp})` : "Cloud";
@@ -492,17 +586,18 @@ export interface ReconnectParams {
492
586
  * knows to call session_start again.
493
587
  */
494
588
  export async function reconnectSession(params: ReconnectParams): Promise<string> {
589
+ const c = ctx();
495
590
  const apiRoot = params.apiRoot || process.env.TD_API_ROOT || "https://api.testdriver.ai";
496
591
  const previewMode = process.env.TD_PREVIEW || "ide";
497
592
 
498
- const session = sessionManager.createSession({
593
+ const session = c.sessions.createSession({
499
594
  os: params.os,
500
595
  keepAlive: params.keepAlive,
501
596
  testFile: params.testFile,
502
597
  });
503
598
 
504
599
  const TestDriverSDK = (await loadTestDriverSdk()).default;
505
- sdk = new TestDriverSDK(params.apiKey, {
600
+ c.sdk = new TestDriverSDK(params.apiKey, {
506
601
  os: params.os,
507
602
  logging: false,
508
603
  apiRoot,
@@ -511,9 +606,9 @@ export async function reconnectSession(params: ReconnectParams): Promise<string>
511
606
  e2bTemplateId: params.e2bTemplateId,
512
607
  });
513
608
 
514
- await sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
515
- const instance = sdk.getInstance();
516
- sessionManager.activateSession(session.sessionId, instance?.instanceId || params.sandboxId);
609
+ await c.sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
610
+ const instance = c.sdk.getInstance();
611
+ c.sessions.activateSession(session.sessionId, instance?.instanceId || params.sandboxId);
517
612
  return session.sessionId;
518
613
  }
519
614
 
@@ -530,9 +625,10 @@ export async function reconnectSession(params: ReconnectParams): Promise<string>
530
625
  * them exactly as before.
531
626
  */
532
627
  export async function ensureActiveSession(params?: ReconnectParams): Promise<boolean> {
533
- const session = sessionManager.getCurrentSession();
628
+ const c = ctx();
629
+ const session = c.sessions.getCurrentSession();
534
630
  // Already have a live SDK + valid session — nothing to do.
535
- if (sdk && session && sessionManager.isSessionValid(session.sessionId)) {
631
+ if (c.sdk && session && c.sessions.isSessionValid(session.sessionId)) {
536
632
  return false;
537
633
  }
538
634
  // No durable handle to recover from — leave the (missing/expired) state as-is
@@ -548,7 +644,7 @@ export async function ensureActiveSession(params?: ReconnectParams): Promise<boo
548
644
  // The sandbox is gone (server killed it after grace/disconnect cap, or the
549
645
  // id is stale). Surface a SESSION_EXPIRED so the adapter's mapper tells the
550
646
  // agent to call session_start again, instead of a raw connect error.
551
- sdk = null;
647
+ c.sdk = null;
552
648
  throw new NoActiveSessionError(
553
649
  "SESSION_EXPIRED",
554
650
  `Could not reconnect to sandbox ${params.sandboxId}: ${err instanceof Error ? err.message : String(err)}. The sandbox has expired — call session_start again to create a new one.`,
@@ -560,18 +656,19 @@ export async function ensureActiveSession(params?: ReconnectParams): Promise<boo
560
656
  /** Disconnect the SDK (best-effort); used by adapters on cancel/teardown. */
561
657
  export async function disconnect(): Promise<void> {
562
658
  try {
563
- await sdk?.disconnect?.();
659
+ await ctx().sdk?.disconnect?.();
564
660
  } catch {
565
661
  /* best effort */
566
662
  }
567
663
  }
568
664
 
569
665
  export function sessionStatus(): ActionResult {
570
- const session = sessionManager.getCurrentSession();
666
+ const c = ctx();
667
+ const session = c.sessions.getCurrentSession();
571
668
  if (!session) {
572
669
  return { ok: false, text: "No active session", data: { error: "No active session. Call session_start first." } };
573
670
  }
574
- const summary = sessionManager.getSessionSummary(session.sessionId);
671
+ const summary = c.sessions.getSessionSummary(session.sessionId);
575
672
  return {
576
673
  ok: true,
577
674
  text: `Session: ${session.sessionId}\nStatus: ${session.status}\nTime remaining: ${Math.round((summary?.timeRemaining || 0) / 1000)}s`,
@@ -580,10 +677,11 @@ export function sessionStatus(): ActionResult {
580
677
  }
581
678
 
582
679
  export function sessionExtend(additionalMs: number): ActionResult {
583
- const session = sessionManager.getCurrentSession();
680
+ const c = ctx();
681
+ const session = c.sessions.getCurrentSession();
584
682
  if (!session) return { ok: false, text: "No active session", data: { action: "session_extend" } };
585
- sessionManager.extendSession(session.sessionId, additionalMs);
586
- const newExpiry = sessionManager.getTimeRemaining(session.sessionId);
683
+ c.sessions.extendSession(session.sessionId, additionalMs);
684
+ const newExpiry = c.sessions.getTimeRemaining(session.sessionId);
587
685
  return {
588
686
  ok: true,
589
687
  text: `Session extended by ${additionalMs / 1000}s. New expiry: ${Math.round(newExpiry / 1000)}s`,
@@ -597,13 +695,14 @@ export function sessionExtend(additionalMs: number): ActionResult {
597
695
 
598
696
  export async function find(description: string, timeout?: number): Promise<ActionResult> {
599
697
  await requireActiveSession();
600
- const element = await sdk.find(description, timeout ? { timeout } : undefined);
698
+ const c = ctx();
699
+ const element = await c.sdk.find(description, timeout ? { timeout } : undefined);
601
700
  const found = element.found();
602
701
  const coords = element.getCoordinates();
603
702
 
604
703
  const ref = `el-${Date.now()}`;
605
704
  if (found && coords) {
606
- elementRefs.set(ref, {
705
+ c.elementRefs.set(ref, {
607
706
  element,
608
707
  description,
609
708
  coords: { x: coords.x, y: coords.y, centerX: coords.centerX, centerY: coords.centerY },
@@ -638,7 +737,8 @@ export async function find(description: string, timeout?: number): Promise<Actio
638
737
 
639
738
  export async function findAll(description: string, timeout?: number): Promise<ActionResult> {
640
739
  await requireActiveSession();
641
- const elements = await sdk.findAll(description, timeout ? { timeout } : undefined);
740
+ const c = ctx();
741
+ const elements = await c.sdk.findAll(description, timeout ? { timeout } : undefined);
642
742
  const count = elements.length;
643
743
 
644
744
  const refs: string[] = [];
@@ -648,7 +748,7 @@ export async function findAll(description: string, timeout?: number): Promise<Ac
648
748
  const coords = el.getCoordinates();
649
749
  if (!coords) continue;
650
750
  const ref = `el-${Date.now()}-${i}`;
651
- elementRefs.set(ref, { element: el, description: `${description} [${i}]`, coords });
751
+ c.elementRefs.set(ref, { element: el, description: `${description} [${i}]`, coords });
652
752
  refs.push(ref);
653
753
  elementInfos.push({ ref, x: coords.x, y: coords.y, centerX: coords.centerX, centerY: coords.centerY, confidence: el.confidence });
654
754
  }
@@ -685,7 +785,8 @@ async function actOnRef(
685
785
  action: "click" | "hover"
686
786
  ): Promise<ActionResult> {
687
787
  await requireActiveSession();
688
- const stored = elementRefs.get(ref);
788
+ const c = ctx();
789
+ const stored = c.elementRefs.get(ref);
689
790
  if (!stored) {
690
791
  return { ok: false, text: `Element reference "${ref}" not found. Use 'find' first to locate the element.`, data: { error: "Element reference not found" } };
691
792
  }
@@ -697,7 +798,7 @@ async function actOnRef(
697
798
  else if (verb === "hover") await element.hover();
698
799
 
699
800
  const shot = await captureScreen();
700
- if (shot) lastScreenshotBase64 = shot;
801
+ if (shot) c.lastScreenshotBase64 = shot;
701
802
  const data = leanResponse(element._response);
702
803
 
703
804
  if (action === "hover") {
@@ -727,7 +828,8 @@ export function hover(ref: string): Promise<ActionResult> {
727
828
 
728
829
  export async function findAndClick(description: string, action: "click" | "double-click" | "right-click" = "click"): Promise<ActionResult> {
729
830
  await requireActiveSession();
730
- const element = await sdk.find(description);
831
+ const c = ctx();
832
+ const element = await c.sdk.find(description);
731
833
  const found = element.found();
732
834
 
733
835
  if (!found) {
@@ -748,7 +850,7 @@ export async function findAndClick(description: string, action: "click" | "doubl
748
850
 
749
851
  const coords = element.getCoordinates();
750
852
  const ref = `el-${Date.now()}`;
751
- if (coords) elementRefs.set(ref, { element, description, coords });
853
+ if (coords) c.elementRefs.set(ref, { element, description, coords });
752
854
 
753
855
  if (action === "click") await element.click();
754
856
  else if (action === "double-click") await element.doubleClick();
@@ -781,7 +883,7 @@ export async function findAndClick(description: string, action: "click" | "doubl
781
883
 
782
884
  export async function type(text: string, secret = false, delay?: number): Promise<ActionResult> {
783
885
  await requireActiveSession();
784
- await sdk.type(text, { secret, delay });
886
+ await ctx().sdk.type(text, { secret, delay });
785
887
  return {
786
888
  ok: true,
787
889
  text: `Typed: ${secret ? "[secret text]" : `"${text}"`}`,
@@ -792,7 +894,7 @@ export async function type(text: string, secret = false, delay?: number): Promis
792
894
 
793
895
  export async function pressKeys(keys: string[]): Promise<ActionResult> {
794
896
  await requireActiveSession();
795
- await sdk.pressKeys(keys);
897
+ await ctx().sdk.pressKeys(keys);
796
898
  return {
797
899
  ok: true,
798
900
  text: `Pressed keys: ${keys.join(" + ")}`,
@@ -803,7 +905,7 @@ export async function pressKeys(keys: string[]): Promise<ActionResult> {
803
905
 
804
906
  export async function scroll(direction: "up" | "down" | "left" | "right" = "down", amount?: number): Promise<ActionResult> {
805
907
  await requireActiveSession();
806
- await sdk.scroll(direction, amount ? { amount } : undefined);
908
+ await ctx().sdk.scroll(direction, amount ? { amount } : undefined);
807
909
  return {
808
910
  ok: true,
809
911
  text: `Scrolled ${direction}${amount ? ` by ${amount}px` : ""}`,
@@ -814,7 +916,7 @@ export async function scroll(direction: "up" | "down" | "left" | "right" = "down
814
916
 
815
917
  export async function focusApplication(name: string): Promise<ActionResult> {
816
918
  await requireActiveSession();
817
- await sdk.focusApplication(name);
919
+ await ctx().sdk.focusApplication(name);
818
920
  return {
819
921
  ok: true,
820
922
  text: `Focused application: "${name}"`,
@@ -825,7 +927,7 @@ export async function focusApplication(name: string): Promise<ActionResult> {
825
927
 
826
928
  export async function wait(timeout: number): Promise<ActionResult> {
827
929
  await requireActiveSession();
828
- await sdk.wait(timeout);
930
+ await ctx().sdk.wait(timeout);
829
931
  return {
830
932
  ok: true,
831
933
  text: `Waited for ${timeout}ms`,
@@ -836,7 +938,7 @@ export async function wait(timeout: number): Promise<ActionResult> {
836
938
 
837
939
  export async function exec(language: "sh" | "pwsh", code: string, timeout = 30000): Promise<ActionResult> {
838
940
  await requireActiveSession();
839
- const output = await sdk.exec(language, code, timeout);
941
+ const output = await ctx().sdk.exec(language, code, timeout);
840
942
  return {
841
943
  ok: true,
842
944
  text: `Executed ${language} code:\n${output || "(no output)"}`,
@@ -851,7 +953,7 @@ export async function exec(language: "sh" | "pwsh", code: string, timeout = 3000
851
953
 
852
954
  export async function assert(assertion: string): Promise<ActionResult> {
853
955
  await requireActiveSession();
854
- const result = await sdk.assert(assertion);
956
+ const result = await ctx().sdk.assert(assertion);
855
957
  return {
856
958
  ok: result,
857
959
  text: result ? `✓ Assertion passed: "${assertion}"` : `✗ Assertion failed: "${assertion}"`,
@@ -867,14 +969,15 @@ export async function assert(assertion: string): Promise<ActionResult> {
867
969
  */
868
970
  export async function check(task: string, referenceImage?: string): Promise<ActionResult> {
869
971
  await requireActiveSession();
870
- const currentScreenshot = await sdk.agent.system.captureScreenBase64(1, false, true);
871
- const beforeScreenshot = referenceImage || lastScreenshotBase64 || currentScreenshot;
872
- lastScreenshotBase64 = currentScreenshot;
972
+ const c = ctx();
973
+ const currentScreenshot = await c.sdk.agent.system.captureScreenBase64(1, false, true);
974
+ const beforeScreenshot = referenceImage || c.lastScreenshotBase64 || currentScreenshot;
975
+ c.lastScreenshotBase64 = currentScreenshot;
873
976
 
874
- const mousePosition = await sdk.agent.system.getMousePosition();
875
- const activeWindow = await sdk.agent.system.activeWin();
977
+ const mousePosition = await c.sdk.agent.system.getMousePosition();
978
+ const activeWindow = await c.sdk.agent.system.activeWin();
876
979
 
877
- const response = await sdk.agent.sdk.req("check", {
980
+ const response = await c.sdk.agent.sdk.req("check", {
878
981
  tasks: [task],
879
982
  images: [beforeScreenshot, currentScreenshot],
880
983
  mousePosition,
@@ -899,8 +1002,9 @@ export async function check(task: string, referenceImage?: string): Promise<Acti
899
1002
  /** Capture a screenshot for the user (cursor visible). */
900
1003
  export async function screenshot(): Promise<ActionResult> {
901
1004
  await requireActiveSession();
902
- const shot = await sdk.agent.system.captureScreenBase64(1, false, true);
903
- if (shot) lastScreenshotBase64 = shot;
1005
+ const c = ctx();
1006
+ const shot = await c.sdk.agent.system.captureScreenBase64(1, false, true);
1007
+ if (shot) c.lastScreenshotBase64 = shot;
904
1008
  return {
905
1009
  ok: !!shot,
906
1010
  text: shot ? "Captured screenshot" : "Failed to capture screenshot",