@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
@@ -0,0 +1,222 @@
1
+ // The shared skeleton behind both agent-loop drivers — index.tsx's
2
+ // runTypedAgentLoop (HTTP/typed transport) and realtime-server.ts's
3
+ // finalizeTurn (WebSocket/voice relay) independently re-implemented the
4
+ // exact same "ask, check terminal, execute a continuing step for real,
5
+ // fold the result into working history, ask again, up to a hard
6
+ // iteration cap" shape — a real, live duplication risk (any future fix
7
+ // to one had to be remembered and re-applied to the other by hand).
8
+ // This module is the first step of the Phase 3 multi-agent redesign
9
+ // (see DEVELOPMENT.md/the plan file's "Phase 3" entry): extract exactly
10
+ // that shared shape, with ZERO behavior change, so Planner/Critic
11
+ // wiring in later steps has one real place to attach to instead of two.
12
+ //
13
+ // Deliberately does NOT own transport-specific side effects — sending a
14
+ // message to a client, speaking, committing to a connection's real
15
+ // cross-turn memory, barge-in cancellation timing. Those stay in each
16
+ // transport's own getNextStep/onStep/onStepResult/executeStep closures,
17
+ // and in what the caller does with this function's return value, exactly
18
+ // as before this extraction. Plain TypeScript only (no JSX, no Node
19
+ // built-ins) — imported as raw source by index.tsx's browser bundle AND
20
+ // compiled to dist/ for realtime-server.ts's Node build.
21
+
22
+ import { isTerminalVerb, type AgentEvent, type CriticVerdict, type HistoryTurn, type VerbResponse } from "@cairnvibe/core";
23
+
24
+ /** 4 exchanges — matches the cap both original drivers independently used. */
25
+ export const MAX_HISTORY_TURNS = 8;
26
+
27
+ /**
28
+ * Architecture Pillar 4 — a cheap, LOCAL signal for "this goal probably
29
+ * needs more than one real step," checked BEFORE the first step even
30
+ * runs, so a caller can start the Planner call in PARALLEL with the
31
+ * first getNextStep instead of only after that first step already came
32
+ * back non-terminal (the "lazy gate" the plan singles out for
33
+ * replacement — realtime-server.ts's own onStep used to build planPromise
34
+ * only once `!terminal && iteration === 0` was already true, one full
35
+ * model round trip later than it needed to be). Deliberately
36
+ * conservative, on purpose: a false negative here just falls back to
37
+ * that same lazy-after-step-1 behavior — unchanged, zero regression —
38
+ * while a false positive costs one Planner call that would have started
39
+ * a moment later anyway, never a wrong answer. Genuine UI-pattern-aware
40
+ * classification (Pillar 2, not built yet) can replace this heuristic
41
+ * later without changing what calls it. Lives here (not server.ts) so
42
+ * BOTH transports can use the exact same check: this file is plain,
43
+ * dependency-free TypeScript imported as raw source by index.tsx's
44
+ * browser bundle AND compiled for realtime-server.ts's Node build — a
45
+ * server-only file (server.ts imports the Anthropic/Groq SDKs) can never
46
+ * be imported from the client widget.
47
+ */
48
+ const MULTI_STEP_SIGNAL = /\b(then|after that|once (you|it|that|i)|and then|next,|first[,.]? .*\bthen\b)\b/;
49
+ export function looksMultiStep(question: string): boolean {
50
+ return MULTI_STEP_SIGNAL.test(question.toLowerCase());
51
+ }
52
+
53
+ export function summarizeVerbForHistory(verb: VerbResponse): string {
54
+ if ("text" in verb && verb.text) return verb.text;
55
+ switch (verb.verb) {
56
+ case "highlight":
57
+ case "open":
58
+ return `(highlighted ${verb.target})`;
59
+ case "navigate":
60
+ return `(navigated to ${verb.route})`;
61
+ case "do":
62
+ return `(ran ${verb.action}${verb.target ? ` on ${verb.target}` : ""})`;
63
+ case "tour":
64
+ return verb.steps.map((s) => s.text).join(" ");
65
+ case "click":
66
+ return `(clicked ${verb.target})`;
67
+ case "fill":
68
+ return `(typed "${verb.value}" into ${verb.target})`;
69
+ case "read":
70
+ return `(read ${verb.target})`;
71
+ case "call_tool":
72
+ return `(called ${verb.name})`;
73
+ case "drag":
74
+ return `(dragged ${verb.target} to ${verb.to})`;
75
+ case "select":
76
+ return `(selected "${verb.value}" in ${verb.target})`;
77
+ case "key":
78
+ return `(pressed ${verb.key}${verb.target ? ` on ${verb.target}` : ""})`;
79
+ case "batch":
80
+ return `(${verb.actions.length} steps: ${verb.actions.map((a) => a.verb).join(", ")})`;
81
+ default:
82
+ return "(no response)";
83
+ }
84
+ }
85
+
86
+ export interface AgentLoopStepEvent {
87
+ verb: VerbResponse;
88
+ /** 0-based. */
89
+ iteration: number;
90
+ /** True if isTerminalVerb(verb) says this ends the loop right after
91
+ * this hook returns (TERMINAL_VERBS membership, except a navigate
92
+ * marked continueAfter — see isTerminalVerb's own doc comment). Lets a
93
+ * caller act differently for a continuing vs. final step without
94
+ * re-deriving that check itself. */
95
+ terminal: boolean;
96
+ }
97
+
98
+ export interface AgentLoopStepResultEvent {
99
+ verb: VerbResponse;
100
+ iteration: number;
101
+ observation: string | null | undefined;
102
+ }
103
+
104
+ export interface AgentLoopDeps {
105
+ /** Resolve the next step given the CURRENT working history. Return
106
+ * `null` for a response that failed to parse/validate — the HTTP
107
+ * path's own real case (a raw fetch response might not conform);
108
+ * realtime's in-process resolveVerb never produces this, since it
109
+ * always returns a valid VerbResponse itself. A null return ends the
110
+ * loop immediately with outcome "unparseable". */
111
+ getNextStep(loopHistory: HistoryTurn[], iteration: number): Promise<VerbResponse | null>;
112
+ /** Fires immediately after getNextStep resolves, before the terminal/
113
+ * continuing branch is decided — the real-time side-effect point (send
114
+ * the verb to a client, trigger an ack on the first continuing step,
115
+ * check for a superseding barge-in). Returning true aborts the loop
116
+ * immediately: no further side effects, outcome "aborted". */
117
+ onStep?(event: AgentLoopStepEvent): boolean | Promise<boolean>;
118
+ /** Execute a continuing verb (click/fill/read/call_tool/batch) for
119
+ * real; return its observation text (or null/undefined for "no
120
+ * result", folded into history as "no result" exactly like both
121
+ * original drivers already did). */
122
+ executeStep(verb: VerbResponse, iteration: number): Promise<string | null | undefined>;
123
+ /** Fires after executeStep resolves, before folding the observation
124
+ * into working history — a second real-time abort checkpoint (e.g. a
125
+ * barge-in generation check after awaiting a real tool result, which
126
+ * can itself take a while). Returning true aborts the loop with
127
+ * outcome "aborted", discarding this step's observation. */
128
+ onStepResult?(event: AgentLoopStepResultEvent): boolean | Promise<boolean>;
129
+ /**
130
+ * Phase 3 step 3 — a genuinely separate pass over the step's REAL
131
+ * observation, decoupled from the Executor/model's own self-report
132
+ * (the direct fix for the diagnosed bug: a batch succeeded and the
133
+ * model kept looping instead of recognizing it). Fires after
134
+ * onStepResult/the history fold. Returning a "task_complete" or
135
+ * "give_up" verdict ends the loop right here — even though the
136
+ * model's own verb was never a TERMINAL_VERBS member — instead of
137
+ * asking the model again and hoping it notices. Returning "continue"
138
+ * (including after the caller's own closure has silently handled a
139
+ * "replan" by fetching a fresh Plan — driveAgentLoop itself has no
140
+ * concept of a Plan, only of "keep going or stop") keeps the loop
141
+ * going exactly as if this hook were absent. Returning null/undefined
142
+ * behaves the same as "continue" — a caller can choose not to run the
143
+ * Critic on a particular step without a special no-op verdict shape.
144
+ */
145
+ runCritic?(event: AgentLoopStepResultEvent): Promise<CriticVerdict | null | undefined>;
146
+ /**
147
+ * Phase 3 step 5 — a pure, fire-and-forget event consumer for a
148
+ * Talker-style narration layer ("Revisable by Design"'s pattern):
149
+ * never awaited, never able to affect control flow. driveAgentLoop
150
+ * itself emits "act" (right after a step's onStep/abort check passes —
151
+ * only for a verb that's actually going to execute, never a discarded
152
+ * one) and "obs" (right after onStepResult's own abort check passes),
153
+ * since it already has that data at exactly those points. A caller's
154
+ * own onStep/runCritic closures can call this SAME callback directly —
155
+ * it's just a plain reference they already have via the deps object
156
+ * they constructed — to emit "thk" (Critic reasoning) or "inj"
157
+ * (injected filler narration, e.g. a Talker ack phrase) events too;
158
+ * driveAgentLoop has no opinion on those.
159
+ */
160
+ onEvent?(event: AgentEvent): void;
161
+ /** Defaults to 6 — a hard cap, not a target, matching both original drivers. */
162
+ maxIterations?: number;
163
+ }
164
+
165
+ export type AgentLoopOutcome =
166
+ | { outcome: "terminal"; finalVerb: VerbResponse; workingHistory: HistoryTurn[] }
167
+ /** The Critic independently confirmed the (last) task's doneContract
168
+ * is satisfied — the real fix for the diagnosed bug. The caller
169
+ * synthesizes its own terminal-shaped response (e.g. `{verb: "explain",
170
+ * text: verdict.reasoning}`) from `verdict`, same as it would for a
171
+ * model-produced terminal verb. */
172
+ | { outcome: "critic-complete"; verdict: CriticVerdict; workingHistory: HistoryTurn[] }
173
+ /** The Critic (or the harness's own stall-count fail-safe, inside the
174
+ * caller's runCritic closure) decided continuing wouldn't help. */
175
+ | { outcome: "critic-give-up"; verdict: CriticVerdict; workingHistory: HistoryTurn[] }
176
+ | { outcome: "unparseable"; workingHistory: HistoryTurn[] }
177
+ | { outcome: "gave-up"; workingHistory: HistoryTurn[] }
178
+ | { outcome: "aborted"; workingHistory: HistoryTurn[] };
179
+
180
+ export async function driveAgentLoop(initialHistory: HistoryTurn[], deps: AgentLoopDeps): Promise<AgentLoopOutcome> {
181
+ const maxIterations = deps.maxIterations ?? 6;
182
+ let loopHistory = initialHistory;
183
+
184
+ for (let i = 0; i < maxIterations; i++) {
185
+ const verb = await deps.getNextStep(loopHistory, i);
186
+ if (verb === null) return { outcome: "unparseable", workingHistory: loopHistory };
187
+
188
+ const terminal = isTerminalVerb(verb);
189
+ if (deps.onStep) {
190
+ const abort = await deps.onStep({ verb, iteration: i, terminal });
191
+ if (abort) return { outcome: "aborted", workingHistory: loopHistory };
192
+ }
193
+ deps.onEvent?.({ type: "act", verb, at: Date.now() });
194
+
195
+ if (terminal) {
196
+ return { outcome: "terminal", finalVerb: verb, workingHistory: loopHistory };
197
+ }
198
+
199
+ const observation = await deps.executeStep(verb, i);
200
+ if (deps.onStepResult) {
201
+ const abort = await deps.onStepResult({ verb, iteration: i, observation });
202
+ if (abort) return { outcome: "aborted", workingHistory: loopHistory };
203
+ }
204
+ deps.onEvent?.({ type: "obs", observation: observation ?? "no result", ok: observation !== null && observation !== undefined, at: Date.now() });
205
+
206
+ loopHistory = [
207
+ ...loopHistory,
208
+ { role: "assistant" as const, text: `${summarizeVerbForHistory(verb)}. Result: ${observation ?? "no result"}` },
209
+ ].slice(-MAX_HISTORY_TURNS);
210
+
211
+ if (deps.runCritic) {
212
+ const verdict = await deps.runCritic({ verb, iteration: i, observation });
213
+ if (verdict?.verdict === "task_complete") return { outcome: "critic-complete", verdict, workingHistory: loopHistory };
214
+ if (verdict?.verdict === "give_up") return { outcome: "critic-give-up", verdict, workingHistory: loopHistory };
215
+ // "continue", "replan" (already handled inside the caller's own
216
+ // runCritic closure — see this field's own doc comment), or no
217
+ // verdict at all: fall through and keep looping, unchanged.
218
+ }
219
+ }
220
+
221
+ return { outcome: "gave-up", workingHistory: loopHistory };
222
+ }
@@ -0,0 +1,130 @@
1
+ // A visible, animated synthetic cursor that glides to whatever element the
2
+ // agent is about to act on, and genuinely arrives — before anything actually
3
+ // happens on screen — rather than a click just occurring with no visible
4
+ // lead-up. Real, watchable proof of what the agent resolved, the same way
5
+ // watching a person's own mouse move tells you where they're about to click
6
+ // before it happens; matches this SDK's own "verified, not trusted"
7
+ // discipline in a form a user can literally see, not just read.
8
+ //
9
+ // Purely additive and deliberately decoupled from highlightElement
10
+ // (element-ladder.ts) — that function's own scroll+glow behavior is
11
+ // unchanged and still called by every site that used it before. This module
12
+ // only adds the moving cursor itself; a caller awaits moveCursorTo(el)
13
+ // before firing the real action so the cursor is seen arriving first, never
14
+ // after the fact — see verb-executor.ts's own call sites for the exact
15
+ // sequencing.
16
+
17
+ const CURSOR_ID = "cairn-cursor";
18
+ const MOVE_MS = 550;
19
+ const ARRIVE_PAUSE_MS = 160;
20
+ // The CSS side already disables the cursor's transition/animation under
21
+ // prefers-reduced-motion (see #cairn-cursor in the injected <style> block),
22
+ // which makes it jump instead of glide — but without this, the real delay
23
+ // before the action fires would stay the full ~710ms even though there's
24
+ // nothing left to watch. Mirrors the visual change with a real timing one.
25
+ const REDUCED_MOVE_MS = 60;
26
+ const REDUCED_ARRIVE_PAUSE_MS = 40;
27
+
28
+ function prefersReducedMotion(): boolean {
29
+ return (
30
+ typeof window !== "undefined" &&
31
+ typeof window.matchMedia === "function" &&
32
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
33
+ );
34
+ }
35
+
36
+ // Module-scope, not per-call — the whole point is a SINGLE cursor that
37
+ // glides from wherever it last was, the way a real mouse never teleports
38
+ // between two unrelated screen positions.
39
+ let lastX: number | null = null;
40
+ let lastY: number | null = null;
41
+
42
+ function ensureCursorEl(): HTMLElement | null {
43
+ if (typeof document === "undefined" || !document.body) return null;
44
+ let el = document.getElementById(CURSOR_ID);
45
+ if (el) return el;
46
+ el = document.createElement("div");
47
+ el.id = CURSOR_ID;
48
+ el.setAttribute("aria-hidden", "true");
49
+ // A simple filled pointer shape — matches the widget's own ember accent,
50
+ // with a thin dark stroke so it reads clearly on light AND dark pages
51
+ // (the host app's own background is never something this SDK controls).
52
+ el.innerHTML =
53
+ '<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">' +
54
+ '<path d="M2 1.5 L2 18.2 L6.3 14.4 L9.1 20.6 L11.7 19.4 L8.9 13.3 L14.6 13.1 Z" fill="#E07A3F" stroke="#1B1815" stroke-width="1.1" stroke-linejoin="round"/>' +
55
+ "</svg>";
56
+ el.style.cssText =
57
+ "position:fixed;left:0;top:0;z-index:2147483001;pointer-events:none;opacity:0;transition:opacity 180ms ease;will-change:transform;filter:drop-shadow(0 3px 6px rgba(0,0,0,0.35));";
58
+ document.body.appendChild(el);
59
+ return el;
60
+ }
61
+
62
+ /**
63
+ * Animates the synthetic cursor to `el`'s center and resolves once it has
64
+ * genuinely arrived (plus a brief real hover pause) — callers await this
65
+ * BEFORE performing the real action, so the cursor is seen gliding there
66
+ * first. Deliberately timer-driven (`window.setTimeout`), not
67
+ * `transitionend`/`Element.animate().finished`-driven — this repo's test
68
+ * environment is plain Node, not a real browser (see waitForDomSettle's own
69
+ * doc comment for the same discipline), and a fixed, known duration is what
70
+ * makes this testable with fake timers instead of needing real animation-
71
+ * completion events that a headless/no-DOM environment may never fire.
72
+ *
73
+ * SSR/no-DOM safe — same defensive guard `waitForDomSettle` already uses —
74
+ * so a caller never needs its own environment check before calling this.
75
+ */
76
+ export function moveCursorTo(el: HTMLElement): Promise<void> {
77
+ if (typeof document === "undefined" || typeof window === "undefined" || typeof el.getBoundingClientRect !== "function") {
78
+ return Promise.resolve();
79
+ }
80
+ const cursor = ensureCursorEl();
81
+ if (!cursor) return Promise.resolve();
82
+
83
+ const rect = el.getBoundingClientRect();
84
+ const x = rect.left + rect.width / 2;
85
+ const y = rect.top + rect.height / 2;
86
+
87
+ if (lastX === null || lastY === null) {
88
+ // The very first move of the session starts from the widget's own
89
+ // corner (bottom-right, where the FAB lives) instead of materializing
90
+ // at (0,0) — reads as "coming from Cairn," not appearing from nowhere.
91
+ lastX = window.innerWidth - 40;
92
+ lastY = window.innerHeight - 40;
93
+ cursor.style.transform = `translate(${lastX}px, ${lastY}px)`;
94
+ }
95
+
96
+ const reduced = prefersReducedMotion();
97
+ const moveMs = reduced ? REDUCED_MOVE_MS : MOVE_MS;
98
+ const arrivePauseMs = reduced ? REDUCED_ARRIVE_PAUSE_MS : ARRIVE_PAUSE_MS;
99
+
100
+ cursor.style.transition = `transform ${moveMs}ms cubic-bezier(.4,0,.2,1), opacity 180ms ease`;
101
+ cursor.style.opacity = "1";
102
+ // Forces a style flush so the browser animates FROM the current position
103
+ // TO the new one instead of jumping straight there — reading a layout
104
+ // property is the standard, harmless way to force this without a real
105
+ // animation API (which, per this function's own doc comment, this
106
+ // deliberately avoids depending on for its completion signal anyway).
107
+ void cursor.offsetHeight;
108
+ cursor.style.transform = `translate(${x}px, ${y}px)`;
109
+ lastX = x;
110
+ lastY = y;
111
+
112
+ return new Promise((resolve) => {
113
+ window.setTimeout(() => {
114
+ cursor.classList.add("cairn-cursor-hover");
115
+ window.setTimeout(() => {
116
+ cursor.classList.remove("cairn-cursor-hover");
117
+ resolve();
118
+ }, arrivePauseMs);
119
+ }, moveMs);
120
+ });
121
+ }
122
+
123
+ /** Fades the synthetic cursor out — called once the widget itself closes or
124
+ * unmounts, so it doesn't sit visible on screen after the conversation
125
+ * ends. Safe to call even if the cursor was never created. */
126
+ export function hideCursor(): void {
127
+ if (typeof document === "undefined") return;
128
+ const el = document.getElementById(CURSOR_ID);
129
+ if (el) el.style.opacity = "0";
130
+ }
@@ -40,6 +40,94 @@ export function findElement(target: string, liveElements?: Map<string, HTMLEleme
40
40
  return null;
41
41
  }
42
42
 
43
+ /**
44
+ * Phase 3 step 4 (see DEVELOPMENT.md/the plan file) — CODA's own point:
45
+ * the Executor gets real local retry latitude for a genuinely MECHANICAL
46
+ * miss (a re-render replaced the DOM node the frozen liveElements snapshot
47
+ * pointed at; an animation/async render hadn't settled yet) before a
48
+ * failure escalates all the way to the Critic/a replan. Deliberately NOT
49
+ * a second LLM call — the Executor stays opinion-free, exactly re-running
50
+ * the SAME real lookup (which, past the liveElements-map check, already
51
+ * queries the LIVE DOM directly — a stale snapshot doesn't matter to that
52
+ * part) after a short real wait. A target that's genuinely not on the
53
+ * page still fails after `attempts`, surfacing as a real miss — this
54
+ * never silently invents success.
55
+ */
56
+ export async function findElementWithRetry(
57
+ target: string,
58
+ liveElements?: Map<string, HTMLElement>,
59
+ attempts = 2,
60
+ delayMs = 300,
61
+ ): Promise<HTMLElement | null> {
62
+ for (let i = 0; i < attempts; i++) {
63
+ const el = findElement(target, liveElements);
64
+ if (el) return el;
65
+ if (i < attempts - 1) await new Promise((resolve) => setTimeout(resolve, delayMs));
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Real, live-found bug this closes: a `fill`/`click` step reported itself
72
+ * "done" the instant its DOM event was dispatched — but the app's own
73
+ * reaction to that event (a filtered search-results grid re-rendering, a
74
+ * cart count updating) can be an unbounded-latency async round trip (a
75
+ * Next.js App Router `router.push` re-fetching a server component, for
76
+ * example — not a fixed debounce with a known delay to just sleep past).
77
+ * A `read` step immediately after saw STALE content and the agent
78
+ * confidently reported findings that didn't match what the page actually,
79
+ * eventually, showed — confirmed live: typing "book" into a search box,
80
+ * then reading the still-unfiltered product grid a moment later, and
81
+ * reporting a match ("Novel: The Long Way") the REAL, since-filtered page
82
+ * went on to show zero results for.
83
+ *
84
+ * Waits for real DOM mutations instead of guessing a sleep duration: if
85
+ * nothing starts mutating within `initialWaitMs`, resolves immediately
86
+ * (the action had no async effect at all — no reason to add latency to
87
+ * the common case); once mutations start, waits for `quietMs` of no
88
+ * further mutations before considering the page settled; a hard
89
+ * `timeoutMs` ceiling means a page that never stops mutating (an
90
+ * animation, a polling widget) can't stall the agent loop forever.
91
+ */
92
+ export function waitForDomSettle(initialWaitMs = 100, quietMs = 200, timeoutMs = 1500): Promise<void> {
93
+ return new Promise((resolve) => {
94
+ // Real gap this closes: some callers stub a partial `document` (real
95
+ // tests in this repo do exactly that for other reasons — a fake
96
+ // WebMCP-tool document, for instance) without the rest of the DOM API
97
+ // surface — checking `document` alone isn't enough to guarantee
98
+ // MutationObserver (or document.body) actually exist too.
99
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined" || !document.body) {
100
+ resolve();
101
+ return;
102
+ }
103
+ let settled = false;
104
+ let sawMutation = false;
105
+ let quietTimer: ReturnType<typeof setTimeout> | null = null;
106
+
107
+ const finish = () => {
108
+ if (settled) return;
109
+ settled = true;
110
+ observer.disconnect();
111
+ if (quietTimer) clearTimeout(quietTimer);
112
+ clearTimeout(hardCap);
113
+ resolve();
114
+ };
115
+
116
+ const observer = new MutationObserver(() => {
117
+ sawMutation = true;
118
+ if (quietTimer) clearTimeout(quietTimer);
119
+ quietTimer = setTimeout(finish, quietMs);
120
+ });
121
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true });
122
+
123
+ const hardCap = setTimeout(finish, timeoutMs);
124
+
125
+ setTimeout(() => {
126
+ if (!sawMutation) finish(); // the action had no async effect — nothing to wait for
127
+ }, initialWaitMs);
128
+ });
129
+ }
130
+
43
131
  export function highlightElement(el: HTMLElement, glowMs = 4000): void {
44
132
  el.scrollIntoView({ behavior: "smooth", block: "center" });
45
133
  el.classList.add("cairn-glow");
@@ -87,6 +175,88 @@ export function readElement(el: HTMLElement): string {
87
175
  return trimmed.length > 500 ? `${trimmed.slice(0, 499)}…` : trimmed || "(empty)";
88
176
  }
89
177
 
178
+ /**
179
+ * Chooses a real `<option>` by its visible text — never a raw internal
180
+ * `value` the model could never actually see. Native `<select>` gets the
181
+ * direct path (set `.value` to the matching option's own value, then fire
182
+ * the same input/change pair fillElement uses so React notices). A custom
183
+ * listbox/combobox (role="listbox"/"option" — Radix, Headless UI, etc.)
184
+ * has no real `<option>` to set, so the fallback clicks the matching
185
+ * option-shaped descendant instead, the same "do the real user gesture"
186
+ * principle the do/click cases already follow.
187
+ */
188
+ export function selectOption(el: HTMLElement, visibleText: string): boolean {
189
+ if (el.tagName === "SELECT") {
190
+ const select = el as HTMLSelectElement;
191
+ const match = Array.from(select.options).find((o) => normalize(o.textContent ?? "") === normalize(visibleText)) ?? Array.from(select.options).find((o) => normalize(o.textContent ?? "").includes(normalize(visibleText)));
192
+ if (!match) return false;
193
+ select.value = match.value;
194
+ select.dispatchEvent(new Event("input", { bubbles: true }));
195
+ select.dispatchEvent(new Event("change", { bubbles: true }));
196
+ return true;
197
+ }
198
+
199
+ const candidates = el.querySelectorAll<HTMLElement>('[role="option"], option, li, [role="menuitem"]');
200
+ const match = Array.from(candidates).find((c) => normalize(c.textContent ?? "") === normalize(visibleText)) ?? Array.from(candidates).find((c) => normalize(c.textContent ?? "").includes(normalize(visibleText)));
201
+ if (!match) return false;
202
+ match.click();
203
+ return true;
204
+ }
205
+
206
+ /**
207
+ * A real multi-point pointer-event sequence — pointerdown on `from`'s
208
+ * center, several pointermove steps toward `to`'s center, pointerup on
209
+ * `to` — the same technique a real mouse drag produces, for canvas/kanban/
210
+ * sortable-list libraries (react-dnd, dnd-kit, n8n's own node canvas) that
211
+ * listen for pointer events rather than a single synthetic "drop". Mouse
212
+ * events are fired alongside (same coordinates) for the older libraries
213
+ * that still only listen for those. jsdom's getBoundingClientRect returns
214
+ * all-zero rects with no real layout engine — fine here, since what matters
215
+ * for a test is that the sequence fires with consistent coordinates, not
216
+ * that they reflect real pixels.
217
+ */
218
+ export function dragElement(from: HTMLElement, to: HTMLElement, steps = 5): void {
219
+ const fromRect = from.getBoundingClientRect();
220
+ const toRect = to.getBoundingClientRect();
221
+ const fromX = fromRect.left + fromRect.width / 2;
222
+ const fromY = fromRect.top + fromRect.height / 2;
223
+ const toX = toRect.left + toRect.width / 2;
224
+ const toY = toRect.top + toRect.height / 2;
225
+
226
+ const fire = (target: HTMLElement, type: string, x: number, y: number) => {
227
+ const opts = { bubbles: true, cancelable: true, clientX: x, clientY: y, view: typeof window !== "undefined" ? window : undefined };
228
+ if (typeof PointerEvent !== "undefined") target.dispatchEvent(new PointerEvent(type.replace("mouse", "pointer"), opts));
229
+ target.dispatchEvent(new MouseEvent(type, opts));
230
+ };
231
+
232
+ fire(from, "mousedown", fromX, fromY);
233
+ for (let i = 1; i <= steps; i++) {
234
+ const x = fromX + ((toX - fromX) * i) / steps;
235
+ const y = fromY + ((toY - fromY) * i) / steps;
236
+ fire(i === steps ? to : from, "mousemove", x, y);
237
+ }
238
+ fire(to, "mouseup", toX, toY);
239
+ }
240
+
241
+ const KEYS_WITH_PRINTABLE_CHAR = new Set(["Enter", "Tab"]);
242
+
243
+ /**
244
+ * Presses one real key on a target element — focuses it first (a real
245
+ * keypress always lands on whatever's focused; a component that reacts to
246
+ * Escape/Enter/arrows almost always keys off document-level or its own
247
+ * focus-scoped listener, so focus has to be real before the event fires).
248
+ * Fires keydown/keyup (and keypress only for the handful of keys that
249
+ * still expect one — Enter/Tab — matching a real browser's own behavior,
250
+ * which no longer fires keypress for pure navigation keys like arrows).
251
+ */
252
+ export function pressKey(el: HTMLElement, key: string): void {
253
+ if (typeof el.focus === "function") el.focus();
254
+ const opts = { bubbles: true, cancelable: true, key };
255
+ el.dispatchEvent(new KeyboardEvent("keydown", opts));
256
+ if (KEYS_WITH_PRINTABLE_CHAR.has(key)) el.dispatchEvent(new KeyboardEvent("keypress", opts));
257
+ el.dispatchEvent(new KeyboardEvent("keyup", opts));
258
+ }
259
+
90
260
  export interface MissContext {
91
261
  attempted: string;
92
262
  route: string;