@cairnvibe/sdk 0.2.13 → 0.4.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 (44) hide show
  1. package/dist/agent-loop.d.ts +113 -0
  2. package/dist/agent-loop.js +128 -0
  3. package/dist/cairn-widget.js +14 -9
  4. package/dist/cursor-overlay.d.ts +19 -0
  5. package/dist/cursor-overlay.js +126 -0
  6. package/dist/element-ladder.d.ts +71 -0
  7. package/dist/element-ladder.js +168 -0
  8. package/dist/index.d.ts +79 -1
  9. package/dist/index.js +886 -96
  10. package/dist/key-rotator.d.ts +28 -0
  11. package/dist/key-rotator.js +57 -3
  12. package/dist/memory-sqlite.d.ts +86 -0
  13. package/dist/memory-sqlite.js +230 -0
  14. package/dist/realtime-cli.js +22 -1
  15. package/dist/realtime-server.d.ts +83 -2
  16. package/dist/realtime-server.js +561 -121
  17. package/dist/server.d.ts +266 -5
  18. package/dist/server.js +1013 -83
  19. package/dist/skill-store.d.ts +17 -0
  20. package/dist/skill-store.js +78 -0
  21. package/dist/tts-stream.d.ts +25 -0
  22. package/dist/tts-stream.js +32 -0
  23. package/dist/vad.d.ts +27 -0
  24. package/dist/vad.js +128 -0
  25. package/dist/verb-executor.d.ts +32 -11
  26. package/dist/verb-executor.js +315 -39
  27. package/dist/webmcp-client.d.ts +14 -1
  28. package/dist/webmcp-client.js +22 -1
  29. package/package.json +3 -1
  30. package/src/agent-loop.ts +222 -0
  31. package/src/cursor-overlay.ts +130 -0
  32. package/src/element-ladder.ts +170 -0
  33. package/src/index.tsx +935 -100
  34. package/src/key-rotator.ts +57 -2
  35. package/src/memory-sqlite.ts +283 -0
  36. package/src/realtime-cli.ts +24 -1
  37. package/src/realtime-server.ts +669 -123
  38. package/src/server.ts +1119 -83
  39. package/src/skill-store.ts +88 -0
  40. package/src/tts-stream.ts +30 -0
  41. package/src/vad.ts +153 -0
  42. package/src/verb-executor.ts +329 -42
  43. package/src/web-component.ts +97 -24
  44. package/src/webmcp-client.ts +30 -2
@@ -6,36 +6,58 @@
6
6
  // enforces the same schema independently — never trust the client alone.
7
7
 
8
8
  import { VerbResponseSchema, type ApiCall, type BatchAction, type TourStep, type VerbResponse } from "@cairnvibe/core";
9
- import { findElement, fillElement, highlightElement, logMiss, readElement, type MissContext } from "./element-ladder";
9
+ import { dragElement, findElement, findElementWithRetry, fillElement, highlightElement, logMiss, pressKey, readElement, selectOption, waitForDomSettle, type MissContext } from "./element-ladder";
10
+ import { moveCursorTo } from "./cursor-overlay";
10
11
  import { executeWebMcpTool } from "./webmcp-client";
11
12
 
12
- /** The real result of one agent-loop step (click/fill/read/call_tool, or a
13
- * batch of several) — fed back to the model as its next turn's
14
- * "observation" so it can decide what to do next instead of acting blind.
15
- * The loop that drives this lives on the caller's side, not here:
13
+ /** The real result of one agent-loop step (click/fill/read/call_tool/
14
+ * navigate, or a batch of several) — fed back to the model as its next
15
+ * turn's "observation" so it can decide what to do next instead of acting
16
+ * blind. The loop that drives this lives on the caller's side, not here:
16
17
  * index.tsx's runTypedAgentLoop for the HTTP path, realtime-server.ts's
17
18
  * finalizeTurn for the realtime one — this module only ever executes one
18
19
  * step (or one batch of steps) at a time. */
19
20
  export interface ToolStepResult {
20
- verb: "click" | "fill" | "read" | "call_tool" | "batch";
21
+ verb: "click" | "fill" | "read" | "call_tool" | "batch" | "navigate" | "drag" | "select" | "key" | "scroll" | "wait_for";
21
22
  target?: string;
22
23
  ok: boolean;
23
24
  observation: string;
24
25
  }
25
26
 
27
+ // wait_for's own real, bounded retry budget — longer than
28
+ // findElementWithRetry's own default (2 attempts, 300ms apart, ~300ms
29
+ // total), since this verb exists specifically for "I know something
30
+ // async should show up" — a toast, a panel appearing after a click — not
31
+ // the incidental transient-miss recovery findElementWithRetry's default
32
+ // already covers for click/fill/batch steps.
33
+ const WAIT_FOR_ATTEMPTS = 6;
34
+ const WAIT_FOR_DELAY_MS = 500;
35
+
26
36
  /**
27
37
  * Promise wrapper around executeVerbResponse for a continuing verb
28
- * (click/fill/read/call_tool) resolves once the real action has actually
29
- * finished (synchronously for click/fill/read, after a real await for
30
- * call_tool) with its real observation, instead of the fire-and-forget
31
- * callback shape every other verb uses. This is what a loop driver awaits
32
- * before deciding whether to call the model again.
38
+ * (click/fill/read/call_tool, or now a navigate marked `continueAfter`
39
+ * see isTerminalVerb in @cairnvibe/core) resolves once the real action
40
+ * has actually finished (synchronously for click/fill/read, after a real
41
+ * await for call_tool/navigate) with its real observation, instead of the
42
+ * fire-and-forget callback shape every other verb uses. This is what a
43
+ * loop driver awaits before deciding whether to call the model again.
44
+ * `onNavigate` is only needed for that new navigate-as-continuing-step
45
+ * case — every existing caller that never passes it keeps working
46
+ * unchanged (a continueAfter navigate with no onNavigate here would just
47
+ * never actually move the page; real callers always pass one, same as
48
+ * handleVerb's own options already do for the terminal case).
33
49
  */
34
- export function executeToolStep(raw: unknown, route: string, liveElements?: Map<string, HTMLElement>): Promise<ToolStepResult | null> {
50
+ export function executeToolStep(
51
+ raw: unknown,
52
+ route: string,
53
+ liveElements?: Map<string, HTMLElement>,
54
+ onNavigate?: (route: string) => void,
55
+ onConfirmTool?: (tool: { name: string; description: string }) => Promise<boolean>,
56
+ ): Promise<ToolStepResult | null> {
35
57
  return new Promise((resolve) => {
36
58
  // executeVerbResponse only ever reaches onToolStep for a genuinely
37
59
  // continuing verb — callers are only expected to call this after
38
- // already confirming (via TERMINAL_VERBS) that the parsed verb is one,
60
+ // already confirming (via isTerminalVerb) that the parsed verb is one,
39
61
  // so this should always fire; a real timeout (not an immediate
40
62
  // microtask — call_tool's own real network round trip needs the time)
41
63
  // is the safety net for the case where it somehow doesn't, so a loop
@@ -44,6 +66,8 @@ export function executeToolStep(raw: unknown, route: string, liveElements?: Map<
44
66
  executeVerbResponse(raw, route, {
45
67
  onExplain: () => {},
46
68
  liveElements,
69
+ onNavigate,
70
+ onConfirmTool,
47
71
  onToolStep: (result) => {
48
72
  clearTimeout(timer);
49
73
  resolve(result);
@@ -75,6 +99,15 @@ export interface VerbExecutorOptions {
75
99
  * saw. Absent entirely for a caller that hasn't wired up live scanning.
76
100
  */
77
101
  liveElements?: Map<string, HTMLElement>;
102
+ /**
103
+ * Architecture Pillar 6 (the safety layer) — real confirmation for a
104
+ * WebMCP tool whose own registration declared `riskTier: "confirm"`
105
+ * (webmcp-client.ts's own doc comment covers the enforcement point).
106
+ * Absent means every "confirm"-tier tool call is declined by default —
107
+ * the safe fallback for a host app that hasn't wired up a real
108
+ * confirmation UI, never an implicit yes.
109
+ */
110
+ onConfirmTool?: (tool: { name: string; description: string }) => Promise<boolean>;
78
111
  }
79
112
 
80
113
  const FALLBACK_TEXT = "I'm not sure — I couldn't understand that response. Try rephrasing your question.";
@@ -104,17 +137,46 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
104
137
  return;
105
138
  }
106
139
  highlightElement(el);
107
- // "open" means make the thing actually appear (a menu, a modal, a
108
- // panel) highlighting alone doesn't do that; a real click does.
109
- if (verb.verb === "open") el.click();
110
- if (verb.text) options.onExplain(verb.text);
140
+ // The cursor visibly arrives before "open"'s real click fires the
141
+ // whole point is a user watching sees where it's about to click
142
+ // BEFORE the menu/modal/panel actually opens, not simultaneously.
143
+ void moveCursorTo(el).then(() => {
144
+ // "open" means make the thing actually appear (a menu, a modal, a
145
+ // panel) — highlighting alone doesn't do that; a real click does.
146
+ if (verb.verb === "open") el.click();
147
+ if (verb.text) options.onExplain(verb.text);
148
+ });
111
149
  return;
112
150
  }
113
151
 
114
- case "navigate":
152
+ case "navigate": {
153
+ // Real, live-reported gap this closes: navigate used to ALWAYS end
154
+ // the turn the instant it fired, even for a compound goal like "buy
155
+ // earbuds" that needs navigate, then search, then a real report
156
+ // back — see isTerminalVerb's own doc comment in @cairnvibe/core.
157
+ // `options.onToolStep` is only ever set by executeToolStep's own
158
+ // continuing-step wrapper — handleVerb's options never provide it —
159
+ // so this branch can only run when the caller already confirmed
160
+ // (via isTerminalVerb) that this navigate was genuinely marked
161
+ // continueAfter; the defensive `verb.continueAfter` check here is
162
+ // belt-and-suspenders, not the real gate.
163
+ if (verb.continueAfter && options.onToolStep) {
164
+ if (verb.text) options.onExplain(verb.text);
165
+ options.onNavigate?.(verb.route);
166
+ // A client-side route change is itself an async re-render (a new
167
+ // page's whole DOM mounting) — same real race waitForDomSettle
168
+ // already closes for fill/click, arguably more likely here. The
169
+ // NEXT resolveVerb call needs the settled new page's context, not
170
+ // whatever was on screen the instant router.push was called.
171
+ void waitForDomSettle(300, 200, 2000).then(() => {
172
+ options.onToolStep?.({ verb: "navigate", target: verb.route, ok: true, observation: `Navigated to ${verb.route}.` });
173
+ });
174
+ return;
175
+ }
115
176
  options.onNavigate?.(verb.route);
116
177
  if (verb.text) options.onExplain(verb.text);
117
178
  return;
179
+ }
118
180
 
119
181
  case "do": {
120
182
  const allowed = options.registeredActions ?? [];
@@ -142,8 +204,10 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
142
204
  // on a different page) — never fired in addition to a real
143
205
  // click, so the action can't run twice.
144
206
  highlightElement(el);
145
- el.click();
146
- if (verb.text) options.onExplain(verb.text);
207
+ void moveCursorTo(el).then(() => {
208
+ el.click();
209
+ if (verb.text) options.onExplain(verb.text);
210
+ });
147
211
  return;
148
212
  }
149
213
  if (verb.target) (options.onMiss ?? logMiss)({ attempted: verb.target, route });
@@ -188,26 +252,48 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
188
252
  return;
189
253
  }
190
254
  highlightElement(el);
191
- el.click();
192
- options.onToolStep?.({ verb: "click", target: verb.target, ok: true, observation: "Clicked it." });
255
+ void moveCursorTo(el).then(() => {
256
+ el.click();
257
+ // Real, live-found race this closes — see waitForDomSettle's own
258
+ // doc comment: a click can trigger an async re-render (a cart count
259
+ // updating, a filtered list refreshing) that hasn't happened yet the
260
+ // instant .click() returns. A subsequent read step in the same turn
261
+ // needs the SETTLED result, not whatever was on screen a moment ago.
262
+ void waitForDomSettle().then(() => {
263
+ options.onToolStep?.({ verb: "click", target: verb.target, ok: true, observation: "Clicked it." });
264
+ });
265
+ });
193
266
  return;
194
267
  }
195
268
 
196
269
  case "fill": {
197
270
  if (verb.text) options.onExplain(verb.text);
198
271
  const el = findElement(verb.target, options.liveElements);
199
- if (!el || !fillElement(el, verb.value)) {
272
+ if (!el) {
200
273
  (options.onMiss ?? logMiss)({ attempted: verb.target, route });
201
- options.onToolStep?.({
202
- verb: "fill",
203
- target: verb.target,
204
- ok: false,
205
- observation: el ? "That element isn't a real form field — can't type into it." : "Could not find that element on the page.",
206
- });
274
+ options.onToolStep?.({ verb: "fill", target: verb.target, ok: false, observation: "Could not find that element on the page." });
207
275
  return;
208
276
  }
209
277
  highlightElement(el);
210
- options.onToolStep?.({ verb: "fill", target: verb.target, ok: true, observation: `Typed "${verb.value}" into it.` });
278
+ // fillElement itself is both the "is this a real form field" check
279
+ // AND the commit (it sets the value the instant it returns true) —
280
+ // deliberately called from inside this .then(), not the synchronous
281
+ // miss-check above, so the cursor is genuinely seen arriving BEFORE
282
+ // any text appears, not simultaneously with it.
283
+ void moveCursorTo(el).then(() => {
284
+ if (!fillElement(el, verb.value)) {
285
+ (options.onMiss ?? logMiss)({ attempted: verb.target, route });
286
+ options.onToolStep?.({ verb: "fill", target: verb.target, ok: false, observation: "That element isn't a real form field — can't type into it." });
287
+ return;
288
+ }
289
+ // See the click case's own comment — the exact real bug found live:
290
+ // typing into a search box, then reading the still-unfiltered
291
+ // results a moment later and reporting a match the real, since-
292
+ // filtered page never actually showed.
293
+ void waitForDomSettle().then(() => {
294
+ options.onToolStep?.({ verb: "fill", target: verb.target, ok: true, observation: `Typed "${verb.value}" into it.` });
295
+ });
296
+ });
211
297
  return;
212
298
  }
213
299
 
@@ -219,18 +305,137 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
219
305
  options.onToolStep?.({ verb: "read", target: verb.target, ok: false, observation: "Could not find that element on the page." });
220
306
  return;
221
307
  }
222
- options.onToolStep?.({ verb: "read", target: verb.target, ok: true, observation: readElement(el) });
308
+ // No mutation here, but the cursor still visits what's being read —
309
+ // real visual proof of what the agent is actually looking at, not
310
+ // just a claim in the eventual reported observation.
311
+ void moveCursorTo(el).then(() => {
312
+ options.onToolStep?.({ verb: "read", target: verb.target, ok: true, observation: readElement(el) });
313
+ });
223
314
  return;
224
315
  }
225
316
 
226
317
  case "call_tool": {
227
318
  if (verb.text) options.onExplain(verb.text);
228
- void executeWebMcpTool(verb.name, verb.args).then((result) => {
319
+ void executeWebMcpTool(verb.name, verb.args, options.onConfirmTool).then((result) => {
229
320
  options.onToolStep?.({ verb: "call_tool", target: verb.name, ok: result.ok, observation: result.observation });
230
321
  });
231
322
  return;
232
323
  }
233
324
 
325
+ case "drag": {
326
+ if (verb.text) options.onExplain(verb.text);
327
+ const from = findElement(verb.target, options.liveElements);
328
+ const to = from ? findElement(verb.to, options.liveElements) : null;
329
+ if (!from || !to) {
330
+ (options.onMiss ?? logMiss)({ attempted: from ? verb.to : verb.target, route });
331
+ options.onToolStep?.({ verb: "drag", target: verb.target, ok: false, observation: from ? "Could not find the drop destination on the page." : "Could not find that element on the page." });
332
+ return;
333
+ }
334
+ highlightElement(from);
335
+ void moveCursorTo(from).then(() => {
336
+ dragElement(from, to);
337
+ // The cursor also glides to the drop point — fire-and-forget, not
338
+ // awaited, since it's purely a visual echo of a drag that already
339
+ // happened via real pointer events; nothing downstream depends on
340
+ // it finishing.
341
+ void moveCursorTo(to);
342
+ // Same real re-render race as click/fill — a drop can trigger an
343
+ // async re-render (a canvas connection line, a reordered list) that
344
+ // hasn't settled the instant the pointer sequence finishes.
345
+ void waitForDomSettle().then(() => {
346
+ options.onToolStep?.({ verb: "drag", target: verb.target, ok: true, observation: `Dragged it to ${verb.to}.` });
347
+ });
348
+ });
349
+ return;
350
+ }
351
+
352
+ case "select": {
353
+ if (verb.text) options.onExplain(verb.text);
354
+ const el = findElement(verb.target, options.liveElements);
355
+ if (!el) {
356
+ (options.onMiss ?? logMiss)({ attempted: verb.target, route });
357
+ options.onToolStep?.({ verb: "select", target: verb.target, ok: false, observation: "Could not find that element on the page." });
358
+ return;
359
+ }
360
+ highlightElement(el);
361
+ // Same reasoning as fill's own comment — selectOption is both the
362
+ // "does a matching option exist" check and the commit, so it's called
363
+ // from inside this .then(), after the cursor genuinely arrives.
364
+ void moveCursorTo(el).then(() => {
365
+ if (!selectOption(el, verb.value)) {
366
+ (options.onMiss ?? logMiss)({ attempted: verb.target, route });
367
+ options.onToolStep?.({ verb: "select", target: verb.target, ok: false, observation: `Could not find an option matching "${verb.value}".` });
368
+ return;
369
+ }
370
+ void waitForDomSettle().then(() => {
371
+ options.onToolStep?.({ verb: "select", target: verb.target, ok: true, observation: `Selected "${verb.value}".` });
372
+ });
373
+ });
374
+ return;
375
+ }
376
+
377
+ case "key": {
378
+ if (verb.text) options.onExplain(verb.text);
379
+ const el = verb.target ? findElement(verb.target, options.liveElements) : (document.activeElement as HTMLElement | null);
380
+ if (!el) {
381
+ if (verb.target) (options.onMiss ?? logMiss)({ attempted: verb.target, route });
382
+ options.onToolStep?.({ verb: "key", target: verb.target, ok: false, observation: "Could not find that element on the page." });
383
+ return;
384
+ }
385
+ const afterMove = () => {
386
+ pressKey(el, verb.key);
387
+ void waitForDomSettle().then(() => {
388
+ options.onToolStep?.({ verb: "key", target: verb.target, ok: true, observation: `Pressed ${verb.key}.` });
389
+ });
390
+ };
391
+ // With no explicit target ("whatever's currently focused"), there's
392
+ // nothing sensible for the cursor to glide to — call straight through
393
+ // instead of routing through a promise callback for no reason, so
394
+ // this path stays exactly as synchronous as it always was.
395
+ if (verb.target) {
396
+ void moveCursorTo(el).then(afterMove);
397
+ } else {
398
+ afterMove();
399
+ }
400
+ return;
401
+ }
402
+
403
+ case "scroll": {
404
+ if (verb.text) options.onExplain(verb.text);
405
+ const el = findElement(verb.target, options.liveElements);
406
+ if (!el) {
407
+ (options.onMiss ?? logMiss)({ attempted: verb.target, route });
408
+ options.onToolStep?.({ verb: "scroll", target: verb.target, ok: false, observation: "Could not find that element on the page." });
409
+ return;
410
+ }
411
+ // A real, already-known element (never a coordinate or something
412
+ // not yet discovered) — highlightElement's own scrollIntoView is
413
+ // exactly the real repositioning this verb exists for; the glow
414
+ // also gives the user a visible cue of where the agent just moved.
415
+ highlightElement(el);
416
+ void moveCursorTo(el).then(() => {
417
+ void waitForDomSettle().then(() => {
418
+ options.onToolStep?.({ verb: "scroll", target: verb.target, ok: true, observation: "Scrolled it into view." });
419
+ });
420
+ });
421
+ return;
422
+ }
423
+
424
+ case "wait_for": {
425
+ if (verb.text) options.onExplain(verb.text);
426
+ void findElementWithRetry(verb.target, options.liveElements, WAIT_FOR_ATTEMPTS, WAIT_FOR_DELAY_MS).then((el) => {
427
+ if (!el) {
428
+ (options.onMiss ?? logMiss)({ attempted: verb.target, route });
429
+ options.onToolStep?.({ verb: "wait_for", target: verb.target, ok: false, observation: "It never appeared." });
430
+ return;
431
+ }
432
+ void moveCursorTo(el).then(() => {
433
+ options.onToolStep?.({ verb: "wait_for", target: verb.target, ok: true, observation: "It appeared." });
434
+ });
435
+ });
436
+ return;
437
+ }
438
+
234
439
  // Several click/fill/read/call_tool steps in one round trip instead of
235
440
  // one each — server.ts's resolveVerb already validated every action's
236
441
  // target/name against real state before this ever arrived. Runs in
@@ -257,48 +462,130 @@ async function executeBatchActions(
257
462
  const steps: string[] = [];
258
463
  for (const action of actions) {
259
464
  const result = await executeOneBatchAction(action, route, options);
260
- steps.push(`${action.verb} ${"target" in action ? action.target : action.name}: ${result.observation}`);
465
+ const label = ("target" in action && action.target) || ("name" in action && action.name) || "(focused element)";
466
+ steps.push(`${action.verb} ${label}: ${result.observation}`);
261
467
  if (!result.ok) return { ok: false, observation: steps.join(" | ") };
262
468
  }
263
469
  return { ok: true, observation: steps.join(" | ") };
264
470
  }
265
471
 
472
+ // Phase 3 step 4 — real, bounded, LLM-free retry latitude for the
473
+ // Executor's own lookups (CODA's own point: the Executor stays
474
+ // opinion-free; anything requiring judgment escalates to the Critic,
475
+ // which now genuinely exists as of step 3). Scoped to batch specifically,
476
+ // per the plan's own build order — a batch's later steps are the ones
477
+ // most likely to race a DOM update the batch's OWN earlier step just
478
+ // triggered, which is exactly the "stale re-render" case this recovers
479
+ // from; single-step click/fill/read stay unchanged (findElement, no
480
+ // retry) rather than widening scope beyond what was actually planned.
266
481
  async function executeOneBatchAction(action: BatchAction, route: string, options: VerbExecutorOptions): Promise<{ ok: boolean; observation: string }> {
267
482
  switch (action.verb) {
268
483
  case "click": {
269
- const el = findElement(action.target, options.liveElements);
484
+ const el = await findElementWithRetry(action.target, options.liveElements);
270
485
  if (!el) {
271
486
  (options.onMiss ?? logMiss)({ attempted: action.target, route });
272
487
  return { ok: false, observation: "Could not find that element on the page." };
273
488
  }
274
489
  highlightElement(el);
490
+ await moveCursorTo(el);
275
491
  el.click();
492
+ // Same real race as the single-step case (see waitForDomSettle's own
493
+ // doc comment) — arguably MORE likely here, since a batch's next
494
+ // step often deliberately reads what THIS step just changed.
495
+ await waitForDomSettle();
276
496
  return { ok: true, observation: "Clicked it." };
277
497
  }
278
498
  case "fill": {
279
- const el = findElement(action.target, options.liveElements);
280
- if (!el || !fillElement(el, action.value)) {
499
+ const el = await findElementWithRetry(action.target, options.liveElements);
500
+ if (!el) {
281
501
  (options.onMiss ?? logMiss)({ attempted: action.target, route });
282
- return {
283
- ok: false,
284
- observation: el ? "That element isn't a real form field — can't type into it." : "Could not find that element on the page.",
285
- };
502
+ return { ok: false, observation: "Could not find that element on the page." };
286
503
  }
287
504
  highlightElement(el);
505
+ // See the single-step fill case's own comment — fillElement is both
506
+ // the field-type check and the commit, called after the cursor
507
+ // genuinely arrives rather than in the synchronous miss-check.
508
+ await moveCursorTo(el);
509
+ if (!fillElement(el, action.value)) {
510
+ (options.onMiss ?? logMiss)({ attempted: action.target, route });
511
+ return { ok: false, observation: "That element isn't a real form field — can't type into it." };
512
+ }
513
+ await waitForDomSettle();
288
514
  return { ok: true, observation: `Typed "${action.value}" into it.` };
289
515
  }
290
516
  case "read": {
291
- const el = findElement(action.target, options.liveElements);
517
+ const el = await findElementWithRetry(action.target, options.liveElements);
292
518
  if (!el) {
293
519
  (options.onMiss ?? logMiss)({ attempted: action.target, route });
294
520
  return { ok: false, observation: "Could not find that element on the page." };
295
521
  }
522
+ await moveCursorTo(el);
296
523
  return { ok: true, observation: readElement(el) };
297
524
  }
298
525
  case "call_tool": {
299
- const result = await executeWebMcpTool(action.name, action.args);
526
+ const result = await executeWebMcpTool(action.name, action.args, options.onConfirmTool);
300
527
  return { ok: result.ok, observation: result.observation };
301
528
  }
529
+ case "drag": {
530
+ const from = await findElementWithRetry(action.target, options.liveElements);
531
+ const to = from ? await findElementWithRetry(action.to, options.liveElements) : null;
532
+ if (!from || !to) {
533
+ (options.onMiss ?? logMiss)({ attempted: from ? action.to : action.target, route });
534
+ return { ok: false, observation: from ? "Could not find the drop destination on the page." : "Could not find that element on the page." };
535
+ }
536
+ highlightElement(from);
537
+ await moveCursorTo(from);
538
+ dragElement(from, to);
539
+ void moveCursorTo(to); // visual echo of the drop point — not awaited, purely decorative
540
+ await waitForDomSettle();
541
+ return { ok: true, observation: `Dragged it to ${action.to}.` };
542
+ }
543
+ case "select": {
544
+ const el = await findElementWithRetry(action.target, options.liveElements);
545
+ if (!el) {
546
+ (options.onMiss ?? logMiss)({ attempted: action.target, route });
547
+ return { ok: false, observation: "Could not find that element on the page." };
548
+ }
549
+ highlightElement(el);
550
+ await moveCursorTo(el);
551
+ if (!selectOption(el, action.value)) {
552
+ (options.onMiss ?? logMiss)({ attempted: action.target, route });
553
+ return { ok: false, observation: `Could not find an option matching "${action.value}".` };
554
+ }
555
+ await waitForDomSettle();
556
+ return { ok: true, observation: `Selected "${action.value}".` };
557
+ }
558
+ case "key": {
559
+ const el = action.target ? await findElementWithRetry(action.target, options.liveElements) : (document.activeElement as HTMLElement | null);
560
+ if (!el) {
561
+ if (action.target) (options.onMiss ?? logMiss)({ attempted: action.target, route });
562
+ return { ok: false, observation: "Could not find that element on the page." };
563
+ }
564
+ if (action.target) await moveCursorTo(el);
565
+ pressKey(el, action.key);
566
+ await waitForDomSettle();
567
+ return { ok: true, observation: `Pressed ${action.key}.` };
568
+ }
569
+ case "scroll": {
570
+ const el = await findElementWithRetry(action.target, options.liveElements);
571
+ if (!el) {
572
+ (options.onMiss ?? logMiss)({ attempted: action.target, route });
573
+ return { ok: false, observation: "Could not find that element on the page." };
574
+ }
575
+ highlightElement(el);
576
+ await moveCursorTo(el);
577
+ await waitForDomSettle();
578
+ return { ok: true, observation: "Scrolled it into view." };
579
+ }
580
+ case "wait_for": {
581
+ const el = await findElementWithRetry(action.target, options.liveElements, WAIT_FOR_ATTEMPTS, WAIT_FOR_DELAY_MS);
582
+ if (!el) {
583
+ (options.onMiss ?? logMiss)({ attempted: action.target, route });
584
+ return { ok: false, observation: "It never appeared." };
585
+ }
586
+ await moveCursorTo(el);
587
+ return { ok: true, observation: "It appeared." };
588
+ }
302
589
  }
303
590
  }
304
591