@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.
@@ -17,9 +17,10 @@
17
17
  * State is process-global and single-session by design: one sandbox at a time,
18
18
  * mirroring how the stdio MCP server has always behaved.
19
19
  */
20
+ import { AsyncLocalStorage } from "node:async_hooks";
20
21
  import { generateActionCode } from "../codegen.js";
21
22
  import { getProvisionOptions, SessionStartInputSchema } from "../provision-types.js";
22
- import { sessionManager } from "../session.js";
23
+ import { SessionManager, sessionManager as globalSessionManager } from "../session.js";
23
24
  // Re-export the input contract so adapters (MCP server, eve tools) share one
24
25
  // source of truth for the session_start schema instead of redeclaring it.
25
26
  export { SessionStartInputSchema };
@@ -34,32 +35,79 @@ export class NoActiveSessionError extends Error {
34
35
  this.expiredSessionId = expiredSessionId;
35
36
  }
36
37
  }
37
- // =============================================================================
38
- // Singleton state (single sandbox per process)
39
- // =============================================================================
40
- let sdk = null;
41
- let lastScreenshotBase64 = null;
42
- let reconnectResolver = null;
38
+ /** Build a fresh, empty context. Callers that want isolation mint one per connection. */
39
+ export function createCoreContext() {
40
+ return {
41
+ sdk: null,
42
+ lastScreenshotBase64: null,
43
+ reconnectResolver: null,
44
+ reconnecting: null,
45
+ elementRefs: new Map(),
46
+ // Reuse the shared SessionManager for the global context so that consumers
47
+ // still importing `sessionManager` from ../session observe the same state;
48
+ // isolated contexts get their own instance.
49
+ sessions: globalSessionManager,
50
+ adapter: {},
51
+ };
52
+ }
53
+ const als = new AsyncLocalStorage();
54
+ /**
55
+ * The fallback context used when no isolated context is active — i.e. for eve
56
+ * and the stdio MCP server, which are one-session-per-process and never call
57
+ * {@link runInContext}. Behavior for those hosts is byte-identical to the old
58
+ * module-global singletons.
59
+ */
60
+ const globalContext = createCoreContext();
61
+ /** Resolve the active context: the ALS store if one is running, else the global. */
62
+ function ctx() {
63
+ return als.getStore() ?? globalContext;
64
+ }
65
+ /**
66
+ * Run `fn` with `context` as the active {@link CoreContext}, isolating every
67
+ * action's state (sdk, element refs, session, recovery hook) for the duration.
68
+ * The HTTP MCP server wraps each connection's request handling in this so
69
+ * concurrent clients never share a sandbox. Hosts that don't call this keep
70
+ * using {@link globalContext} exactly as before.
71
+ */
72
+ export function runInContext(context, fn) {
73
+ return als.run(context, fn);
74
+ }
75
+ /** Build an isolated context whose SessionManager is its own (not the global). */
76
+ export function createIsolatedContext() {
77
+ const c = createCoreContext();
78
+ c.sessions = new SessionManager();
79
+ return c;
80
+ }
81
+ /** The active context's adapter scratch space (see {@link CoreContext.adapter}). */
82
+ export function getAdapterState() {
83
+ return ctx().adapter;
84
+ }
85
+ /** The active context's current session (null when none). Adapters use this for
86
+ * read-only lookups (e.g. the MCP server's testFile/expiry) that must resolve
87
+ * the caller's own session, not a process-global one. */
88
+ export function getCurrentSession() {
89
+ return ctx().sessions.getCurrentSession();
90
+ }
91
+ /** Time remaining (ms) on a session in the active context. */
92
+ export function getSessionTimeRemaining(sessionId) {
93
+ return ctx().sessions.getTimeRemaining(sessionId);
94
+ }
43
95
  /**
44
96
  * Register (or clear, with `null`) the recovery hook. Durable adapters call this
45
97
  * at the start of each step with a resolver bound to the current tool context.
46
98
  */
47
99
  export function setReconnectResolver(resolver) {
48
- reconnectResolver = resolver;
100
+ ctx().reconnectResolver = resolver;
49
101
  }
50
- /** In-flight reconnect, so concurrent actions in one step share one rebuild. */
51
- let reconnecting = null;
52
- /** Stored element instances from `find`/`findall`, addressable by ref. */
53
- const elementRefs = new Map();
54
102
  /** Expose internals the adapters legitimately need (read-only intent). */
55
103
  export function getSdk() {
56
- return sdk;
104
+ return ctx().sdk;
57
105
  }
58
106
  export function getLastScreenshotBase64() {
59
- return lastScreenshotBase64;
107
+ return ctx().lastScreenshotBase64;
60
108
  }
61
109
  export function getElementRef(ref) {
62
- return elementRefs.get(ref);
110
+ return ctx().elementRefs.get(ref);
63
111
  }
64
112
  /**
65
113
  * Cheap, side-effect-free check for a usable in-process session: a live SDK and
@@ -69,8 +117,9 @@ export function getElementRef(ref) {
69
117
  * does NOT refresh the keepAlive window — it only reports liveness.
70
118
  */
71
119
  export function hasLiveSession() {
72
- const session = sessionManager.getCurrentSession();
73
- return !!sdk && !!session && sessionManager.isSessionValid(session.sessionId);
120
+ const c = ctx();
121
+ const session = c.sessions.getCurrentSession();
122
+ return !!c.sdk && !!session && c.sessions.isSessionValid(session.sessionId);
74
123
  }
75
124
  // =============================================================================
76
125
  // Helpers
@@ -144,38 +193,39 @@ function realtimeIsHealthy(s) {
144
193
  * {@link reconnecting} so a parked socket isn't reconnected N times in parallel.
145
194
  */
146
195
  async function tryRecoverSession() {
147
- if (!reconnectResolver)
196
+ const c = ctx();
197
+ if (!c.reconnectResolver)
148
198
  return false;
149
- if (!reconnecting) {
150
- const resolver = reconnectResolver;
151
- reconnecting = (async () => {
199
+ if (!c.reconnecting) {
200
+ const resolver = c.reconnectResolver;
201
+ c.reconnecting = (async () => {
152
202
  const params = await resolver();
153
203
  if (!params?.sandboxId)
154
204
  return; // nothing provisioned this session
155
205
  // Close any half-dead socket on the outgoing SDK before replacing it, so a
156
206
  // parked-then-rebuilt session doesn't leak an orphaned Ably connection.
157
207
  try {
158
- sdk?.sandbox?._ably?.close?.();
208
+ c.sdk?.sandbox?._ably?.close?.();
159
209
  }
160
210
  catch {
161
211
  /* best effort — reconnectSession installs a fresh SDK regardless */
162
212
  }
163
213
  await reconnectSession(params);
164
214
  })().finally(() => {
165
- reconnecting = null;
215
+ c.reconnecting = null;
166
216
  });
167
217
  }
168
218
  try {
169
- await reconnecting;
219
+ await c.reconnecting;
170
220
  }
171
221
  catch (err) {
172
- sdk = null;
222
+ c.sdk = null;
173
223
  if (err instanceof NoActiveSessionError)
174
224
  throw err;
175
225
  throw new NoActiveSessionError("SESSION_EXPIRED", `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.`);
176
226
  }
177
- const session = sessionManager.getCurrentSession();
178
- return !!sdk && !!session && sessionManager.isSessionValid(session.sessionId);
227
+ const session = c.sessions.getCurrentSession();
228
+ return !!c.sdk && !!session && c.sessions.isSessionValid(session.sessionId);
179
229
  }
180
230
  /**
181
231
  * Validate the active session and auto-extend it (active use keeps it alive),
@@ -188,17 +238,18 @@ async function tryRecoverSession() {
188
238
  * to recover from.
189
239
  */
190
240
  async function requireActiveSession() {
191
- const session = sessionManager.getCurrentSession();
241
+ const c = ctx();
242
+ const session = c.sessions.getCurrentSession();
192
243
  // Recoverable conditions:
193
- // - singletons wiped by a host recycle (`!sdk || !session`), or
244
+ // - context state wiped by a host recycle (`!sdk || !session`), or
194
245
  // - the SDK exists but its realtime socket died during a park
195
246
  // (`!realtimeIsHealthy`). We only treat a dead socket as recoverable when a
196
247
  // recovery hook is registered — i.e. a durable host (eve) that actually
197
248
  // parks. Long-lived hosts (stdio MCP, CLI) register no resolver, so a
198
249
  // transient in-process Ably blip is left to Ably's own auto-reconnect rather
199
- // than forcing a full singleton rebuild — preserving their prior behavior.
200
- const socketDead = !!sdk && !!session && !!reconnectResolver && !realtimeIsHealthy(sdk);
201
- const needsRecovery = !sdk || !session || socketDead;
250
+ // than forcing a full rebuild — preserving their prior behavior.
251
+ const socketDead = !!c.sdk && !!session && !!c.reconnectResolver && !realtimeIsHealthy(c.sdk);
252
+ const needsRecovery = !c.sdk || !session || socketDead;
202
253
  if (needsRecovery) {
203
254
  const recovered = await tryRecoverSession();
204
255
  if (!recovered) {
@@ -206,25 +257,25 @@ async function requireActiveSession() {
206
257
  // If the socket was dead, don't send the action into the void — surface
207
258
  // SESSION_EXPIRED so the agent re-provisions instead of hanging.
208
259
  if (socketDead) {
209
- sdk = null;
260
+ c.sdk = null;
210
261
  throw new NoActiveSessionError("SESSION_EXPIRED", "The sandbox connection was lost and could not be restored. Call session_start again to create a new sandbox session.", session?.sessionId);
211
262
  }
212
- // Otherwise the singleton was simply absent — the standard NO_SESSION.
263
+ // Otherwise the SDK was simply absent — the standard NO_SESSION.
213
264
  throw new NoActiveSessionError("NO_SESSION", "No active session. Call session_start first to create a sandbox before using any other tools.");
214
265
  }
215
266
  }
216
- const current = sessionManager.getCurrentSession();
217
- if (!current || !sessionManager.isSessionValid(current.sessionId)) {
218
- sdk = null;
267
+ const current = c.sessions.getCurrentSession();
268
+ if (!current || !c.sessions.isSessionValid(current.sessionId)) {
269
+ c.sdk = null;
219
270
  throw new NoActiveSessionError("SESSION_EXPIRED", "Session has expired or timed out. Call session_start again to create a new sandbox session.", current?.sessionId);
220
271
  }
221
272
  // Reset the keepAlive timer on each command so active use doesn't expire.
222
- sessionManager.refreshSession(current.sessionId);
273
+ c.sessions.refreshSession(current.sessionId);
223
274
  }
224
275
  /** Capture a fresh full-screen screenshot as bare base64 (or null on failure). */
225
276
  async function captureScreen() {
226
277
  try {
227
- const b64 = await sdk.agent.system.captureScreenBase64(1, false, true);
278
+ const b64 = await ctx().sdk.agent.system.captureScreenBase64(1, false, true);
228
279
  return b64 || null;
229
280
  }
230
281
  catch {
@@ -237,6 +288,7 @@ async function captureScreen() {
237
288
  * neutral result (including the initial screenshot and provision code).
238
289
  */
239
290
  export async function sessionStart(params, resolved, hooks = {}) {
291
+ const c = ctx();
240
292
  const progress = hooks.onProgress ?? (() => { });
241
293
  // Validate required fields for specific provision types (unless reconnecting).
242
294
  if (!params.sandboxId) {
@@ -247,7 +299,7 @@ export async function sessionStart(params, resolved, hooks = {}) {
247
299
  return { ok: false, text: "electron type requires 'appPath' parameter", data: { error: "Missing required parameter: appPath" } };
248
300
  }
249
301
  }
250
- const newSession = sessionManager.createSession({
302
+ const newSession = c.sessions.createSession({
251
303
  os: resolved.os,
252
304
  keepAlive: params.keepAlive,
253
305
  testFile: params.testFile,
@@ -272,7 +324,7 @@ export async function sessionStart(params, resolved, hooks = {}) {
272
324
  };
273
325
  }
274
326
  const TestDriverSDK = (await loadTestDriverSdk()).default;
275
- sdk = new TestDriverSDK(apiKey, {
327
+ c.sdk = new TestDriverSDK(apiKey, {
276
328
  os: resolved.os,
277
329
  logging: false,
278
330
  apiRoot,
@@ -283,13 +335,13 @@ export async function sessionStart(params, resolved, hooks = {}) {
283
335
  // Debug mode — attach to an existing sandbox, skip provisioning.
284
336
  if (params.sandboxId) {
285
337
  progress(`Connecting to existing sandbox ${params.sandboxId}...`);
286
- await sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
287
- const instance = sdk.getInstance();
288
- sessionManager.activateSession(newSession.sessionId, instance?.instanceId || params.sandboxId);
338
+ await c.sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
339
+ const instance = c.sdk.getInstance();
340
+ c.sessions.activateSession(newSession.sessionId, instance?.instanceId || params.sandboxId);
289
341
  progress("Capturing screenshot...");
290
342
  const shot = await captureScreen();
291
343
  if (shot)
292
- lastScreenshotBase64 = shot;
344
+ c.lastScreenshotBase64 = shot;
293
345
  return {
294
346
  ok: true,
295
347
  text: `Connected to existing sandbox (debug mode)\nSession: ${newSession.sessionId}\nSandbox: ${params.sandboxId}\n\nUse find, click, type, etc. to interact.`,
@@ -299,38 +351,38 @@ export async function sessionStart(params, resolved, hooks = {}) {
299
351
  };
300
352
  }
301
353
  progress(instanceIp ? `Connecting to self-hosted instance ${instanceIp}...` : "Connecting to cloud sandbox...");
302
- await sdk.connect({ reconnect: params.reconnect, keepAlive: params.keepAlive, ip: instanceIp });
303
- const instance = sdk.getInstance();
304
- sessionManager.activateSession(newSession.sessionId, instance?.instanceId || "unknown");
354
+ await c.sdk.connect({ reconnect: params.reconnect, keepAlive: params.keepAlive, ip: instanceIp });
355
+ const instance = c.sdk.getInstance();
356
+ c.sessions.activateSession(newSession.sessionId, instance?.instanceId || "unknown");
305
357
  const provisionOptions = getProvisionOptions(params);
306
358
  let provisionCmd = "";
307
359
  progress(`Provisioning ${params.type}...`);
308
360
  switch (params.type) {
309
361
  case "chrome":
310
- await sdk.provision.chrome(provisionOptions);
362
+ await c.sdk.provision.chrome(provisionOptions);
311
363
  provisionCmd = "provision.chrome";
312
364
  break;
313
365
  case "chromeExtension":
314
- await sdk.provision.chromeExtension(provisionOptions);
366
+ await c.sdk.provision.chromeExtension(provisionOptions);
315
367
  provisionCmd = "provision.chromeExtension";
316
368
  break;
317
369
  case "vscode":
318
- await sdk.provision.vscode(provisionOptions);
370
+ await c.sdk.provision.vscode(provisionOptions);
319
371
  provisionCmd = "provision.vscode";
320
372
  break;
321
373
  case "installer":
322
- await sdk.provision.installer(provisionOptions);
374
+ await c.sdk.provision.installer(provisionOptions);
323
375
  provisionCmd = "provision.installer";
324
376
  break;
325
377
  case "electron":
326
- await sdk.provision.electron(provisionOptions);
378
+ await c.sdk.provision.electron(provisionOptions);
327
379
  provisionCmd = "provision.electron";
328
380
  break;
329
381
  }
330
382
  progress("Capturing screenshot...");
331
383
  const shot = await captureScreen();
332
384
  if (shot)
333
- lastScreenshotBase64 = shot;
385
+ c.lastScreenshotBase64 = shot;
334
386
  const debuggerUrl = instance?.debuggerUrl || (instanceIp ? `http://${instanceIp}:9222` : null);
335
387
  const connectionType = instanceIp ? `Self-hosted (${instanceIp})` : "Cloud";
336
388
  return {
@@ -367,15 +419,16 @@ export async function sessionStart(params, resolved, hooks = {}) {
367
419
  * knows to call session_start again.
368
420
  */
369
421
  export async function reconnectSession(params) {
422
+ const c = ctx();
370
423
  const apiRoot = params.apiRoot || process.env.TD_API_ROOT || "https://api.testdriver.ai";
371
424
  const previewMode = process.env.TD_PREVIEW || "ide";
372
- const session = sessionManager.createSession({
425
+ const session = c.sessions.createSession({
373
426
  os: params.os,
374
427
  keepAlive: params.keepAlive,
375
428
  testFile: params.testFile,
376
429
  });
377
430
  const TestDriverSDK = (await loadTestDriverSdk()).default;
378
- sdk = new TestDriverSDK(params.apiKey, {
431
+ c.sdk = new TestDriverSDK(params.apiKey, {
379
432
  os: params.os,
380
433
  logging: false,
381
434
  apiRoot,
@@ -383,9 +436,9 @@ export async function reconnectSession(params) {
383
436
  ip: params.ip,
384
437
  e2bTemplateId: params.e2bTemplateId,
385
438
  });
386
- await sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
387
- const instance = sdk.getInstance();
388
- sessionManager.activateSession(session.sessionId, instance?.instanceId || params.sandboxId);
439
+ await c.sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
440
+ const instance = c.sdk.getInstance();
441
+ c.sessions.activateSession(session.sessionId, instance?.instanceId || params.sandboxId);
389
442
  return session.sessionId;
390
443
  }
391
444
  /**
@@ -401,9 +454,10 @@ export async function reconnectSession(params) {
401
454
  * them exactly as before.
402
455
  */
403
456
  export async function ensureActiveSession(params) {
404
- const session = sessionManager.getCurrentSession();
457
+ const c = ctx();
458
+ const session = c.sessions.getCurrentSession();
405
459
  // Already have a live SDK + valid session — nothing to do.
406
- if (sdk && session && sessionManager.isSessionValid(session.sessionId)) {
460
+ if (c.sdk && session && c.sessions.isSessionValid(session.sessionId)) {
407
461
  return false;
408
462
  }
409
463
  // No durable handle to recover from — leave the (missing/expired) state as-is
@@ -420,25 +474,26 @@ export async function ensureActiveSession(params) {
420
474
  // The sandbox is gone (server killed it after grace/disconnect cap, or the
421
475
  // id is stale). Surface a SESSION_EXPIRED so the adapter's mapper tells the
422
476
  // agent to call session_start again, instead of a raw connect error.
423
- sdk = null;
477
+ c.sdk = null;
424
478
  throw new NoActiveSessionError("SESSION_EXPIRED", `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.`, params.sandboxId);
425
479
  }
426
480
  }
427
481
  /** Disconnect the SDK (best-effort); used by adapters on cancel/teardown. */
428
482
  export async function disconnect() {
429
483
  try {
430
- await sdk?.disconnect?.();
484
+ await ctx().sdk?.disconnect?.();
431
485
  }
432
486
  catch {
433
487
  /* best effort */
434
488
  }
435
489
  }
436
490
  export function sessionStatus() {
437
- const session = sessionManager.getCurrentSession();
491
+ const c = ctx();
492
+ const session = c.sessions.getCurrentSession();
438
493
  if (!session) {
439
494
  return { ok: false, text: "No active session", data: { error: "No active session. Call session_start first." } };
440
495
  }
441
- const summary = sessionManager.getSessionSummary(session.sessionId);
496
+ const summary = c.sessions.getSessionSummary(session.sessionId);
442
497
  return {
443
498
  ok: true,
444
499
  text: `Session: ${session.sessionId}\nStatus: ${session.status}\nTime remaining: ${Math.round((summary?.timeRemaining || 0) / 1000)}s`,
@@ -446,11 +501,12 @@ export function sessionStatus() {
446
501
  };
447
502
  }
448
503
  export function sessionExtend(additionalMs) {
449
- const session = sessionManager.getCurrentSession();
504
+ const c = ctx();
505
+ const session = c.sessions.getCurrentSession();
450
506
  if (!session)
451
507
  return { ok: false, text: "No active session", data: { action: "session_extend" } };
452
- sessionManager.extendSession(session.sessionId, additionalMs);
453
- const newExpiry = sessionManager.getTimeRemaining(session.sessionId);
508
+ c.sessions.extendSession(session.sessionId, additionalMs);
509
+ const newExpiry = c.sessions.getTimeRemaining(session.sessionId);
454
510
  return {
455
511
  ok: true,
456
512
  text: `Session extended by ${additionalMs / 1000}s. New expiry: ${Math.round(newExpiry / 1000)}s`,
@@ -462,12 +518,13 @@ export function sessionExtend(additionalMs) {
462
518
  // =============================================================================
463
519
  export async function find(description, timeout) {
464
520
  await requireActiveSession();
465
- const element = await sdk.find(description, timeout ? { timeout } : undefined);
521
+ const c = ctx();
522
+ const element = await c.sdk.find(description, timeout ? { timeout } : undefined);
466
523
  const found = element.found();
467
524
  const coords = element.getCoordinates();
468
525
  const ref = `el-${Date.now()}`;
469
526
  if (found && coords) {
470
- elementRefs.set(ref, {
527
+ c.elementRefs.set(ref, {
471
528
  element,
472
529
  description,
473
530
  coords: { x: coords.x, y: coords.y, centerX: coords.centerX, centerY: coords.centerY },
@@ -501,7 +558,8 @@ export async function find(description, timeout) {
501
558
  }
502
559
  export async function findAll(description, timeout) {
503
560
  await requireActiveSession();
504
- const elements = await sdk.findAll(description, timeout ? { timeout } : undefined);
561
+ const c = ctx();
562
+ const elements = await c.sdk.findAll(description, timeout ? { timeout } : undefined);
505
563
  const count = elements.length;
506
564
  const refs = [];
507
565
  const elementInfos = [];
@@ -511,7 +569,7 @@ export async function findAll(description, timeout) {
511
569
  if (!coords)
512
570
  continue;
513
571
  const ref = `el-${Date.now()}-${i}`;
514
- elementRefs.set(ref, { element: el, description: `${description} [${i}]`, coords });
572
+ c.elementRefs.set(ref, { element: el, description: `${description} [${i}]`, coords });
515
573
  refs.push(ref);
516
574
  elementInfos.push({ ref, x: coords.x, y: coords.y, centerX: coords.centerX, centerY: coords.centerY, confidence: el.confidence });
517
575
  }
@@ -541,7 +599,8 @@ export async function findAll(description, timeout) {
541
599
  // =============================================================================
542
600
  async function actOnRef(ref, verb, action) {
543
601
  await requireActiveSession();
544
- const stored = elementRefs.get(ref);
602
+ const c = ctx();
603
+ const stored = c.elementRefs.get(ref);
545
604
  if (!stored) {
546
605
  return { ok: false, text: `Element reference "${ref}" not found. Use 'find' first to locate the element.`, data: { error: "Element reference not found" } };
547
606
  }
@@ -556,7 +615,7 @@ async function actOnRef(ref, verb, action) {
556
615
  await element.hover();
557
616
  const shot = await captureScreen();
558
617
  if (shot)
559
- lastScreenshotBase64 = shot;
618
+ c.lastScreenshotBase64 = shot;
560
619
  const data = leanResponse(element._response);
561
620
  if (action === "hover") {
562
621
  return {
@@ -583,7 +642,8 @@ export function hover(ref) {
583
642
  }
584
643
  export async function findAndClick(description, action = "click") {
585
644
  await requireActiveSession();
586
- const element = await sdk.find(description);
645
+ const c = ctx();
646
+ const element = await c.sdk.find(description);
587
647
  const found = element.found();
588
648
  if (!found) {
589
649
  const raw = element._response || {};
@@ -605,7 +665,7 @@ export async function findAndClick(description, action = "click") {
605
665
  const coords = element.getCoordinates();
606
666
  const ref = `el-${Date.now()}`;
607
667
  if (coords)
608
- elementRefs.set(ref, { element, description, coords });
668
+ c.elementRefs.set(ref, { element, description, coords });
609
669
  if (action === "click")
610
670
  await element.click();
611
671
  else if (action === "double-click")
@@ -637,7 +697,7 @@ export async function findAndClick(description, action = "click") {
637
697
  // =============================================================================
638
698
  export async function type(text, secret = false, delay) {
639
699
  await requireActiveSession();
640
- await sdk.type(text, { secret, delay });
700
+ await ctx().sdk.type(text, { secret, delay });
641
701
  return {
642
702
  ok: true,
643
703
  text: `Typed: ${secret ? "[secret text]" : `"${text}"`}`,
@@ -647,7 +707,7 @@ export async function type(text, secret = false, delay) {
647
707
  }
648
708
  export async function pressKeys(keys) {
649
709
  await requireActiveSession();
650
- await sdk.pressKeys(keys);
710
+ await ctx().sdk.pressKeys(keys);
651
711
  return {
652
712
  ok: true,
653
713
  text: `Pressed keys: ${keys.join(" + ")}`,
@@ -657,7 +717,7 @@ export async function pressKeys(keys) {
657
717
  }
658
718
  export async function scroll(direction = "down", amount) {
659
719
  await requireActiveSession();
660
- await sdk.scroll(direction, amount ? { amount } : undefined);
720
+ await ctx().sdk.scroll(direction, amount ? { amount } : undefined);
661
721
  return {
662
722
  ok: true,
663
723
  text: `Scrolled ${direction}${amount ? ` by ${amount}px` : ""}`,
@@ -667,7 +727,7 @@ export async function scroll(direction = "down", amount) {
667
727
  }
668
728
  export async function focusApplication(name) {
669
729
  await requireActiveSession();
670
- await sdk.focusApplication(name);
730
+ await ctx().sdk.focusApplication(name);
671
731
  return {
672
732
  ok: true,
673
733
  text: `Focused application: "${name}"`,
@@ -677,7 +737,7 @@ export async function focusApplication(name) {
677
737
  }
678
738
  export async function wait(timeout) {
679
739
  await requireActiveSession();
680
- await sdk.wait(timeout);
740
+ await ctx().sdk.wait(timeout);
681
741
  return {
682
742
  ok: true,
683
743
  text: `Waited for ${timeout}ms`,
@@ -687,7 +747,7 @@ export async function wait(timeout) {
687
747
  }
688
748
  export async function exec(language, code, timeout = 30000) {
689
749
  await requireActiveSession();
690
- const output = await sdk.exec(language, code, timeout);
750
+ const output = await ctx().sdk.exec(language, code, timeout);
691
751
  return {
692
752
  ok: true,
693
753
  text: `Executed ${language} code:\n${output || "(no output)"}`,
@@ -700,7 +760,7 @@ export async function exec(language, code, timeout = 30000) {
700
760
  // =============================================================================
701
761
  export async function assert(assertion) {
702
762
  await requireActiveSession();
703
- const result = await sdk.assert(assertion);
763
+ const result = await ctx().sdk.assert(assertion);
704
764
  return {
705
765
  ok: result,
706
766
  text: result ? `✓ Assertion passed: "${assertion}"` : `✗ Assertion failed: "${assertion}"`,
@@ -715,12 +775,13 @@ export async function assert(assertion) {
715
775
  */
716
776
  export async function check(task, referenceImage) {
717
777
  await requireActiveSession();
718
- const currentScreenshot = await sdk.agent.system.captureScreenBase64(1, false, true);
719
- const beforeScreenshot = referenceImage || lastScreenshotBase64 || currentScreenshot;
720
- lastScreenshotBase64 = currentScreenshot;
721
- const mousePosition = await sdk.agent.system.getMousePosition();
722
- const activeWindow = await sdk.agent.system.activeWin();
723
- const response = await sdk.agent.sdk.req("check", {
778
+ const c = ctx();
779
+ const currentScreenshot = await c.sdk.agent.system.captureScreenBase64(1, false, true);
780
+ const beforeScreenshot = referenceImage || c.lastScreenshotBase64 || currentScreenshot;
781
+ c.lastScreenshotBase64 = currentScreenshot;
782
+ const mousePosition = await c.sdk.agent.system.getMousePosition();
783
+ const activeWindow = await c.sdk.agent.system.activeWin();
784
+ const response = await c.sdk.agent.sdk.req("check", {
724
785
  tasks: [task],
725
786
  images: [beforeScreenshot, currentScreenshot],
726
787
  mousePosition,
@@ -741,9 +802,10 @@ export async function check(task, referenceImage) {
741
802
  /** Capture a screenshot for the user (cursor visible). */
742
803
  export async function screenshot() {
743
804
  await requireActiveSession();
744
- const shot = await sdk.agent.system.captureScreenBase64(1, false, true);
805
+ const c = ctx();
806
+ const shot = await c.sdk.agent.system.captureScreenBase64(1, false, true);
745
807
  if (shot)
746
- lastScreenshotBase64 = shot;
808
+ c.lastScreenshotBase64 = shot;
747
809
  return {
748
810
  ok: !!shot,
749
811
  text: shot ? "Captured screenshot" : "Failed to capture screenshot",