@testdriverai/mcp 7.10.0-canary.0 → 7.10.1-test
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.
- package/.claude-plugin/marketplace.json +17 -0
- package/agent/index.js +116 -71
- package/agent/interface.js +13 -1
- package/agent/lib/commands.js +35 -15
- package/agent/lib/provision-commands.js +92 -11
- package/agent/lib/sandbox.js +263 -30
- package/agent/lib/sdk.js +16 -12
- package/agent/lib/system.js +107 -31
- package/ai/.claude-plugin/plugin.json +89 -4
- package/ai/agents/testdriver.md +4 -8
- package/ai/skills/testdriver-assert/SKILL.md +0 -1
- package/ai/skills/testdriver-aws-setup/SKILL.md +1 -1
- package/ai/skills/testdriver-caching/SKILL.md +13 -6
- package/ai/skills/testdriver-ci-cd/SKILL.md +57 -14
- package/ai/skills/testdriver-client/SKILL.md +10 -6
- package/ai/skills/testdriver-customizing-devices/SKILL.md +7 -5
- package/ai/skills/testdriver-dashcam/SKILL.md +36 -3
- package/ai/skills/testdriver-debugging-with-screenshots/SKILL.md +28 -4
- package/ai/skills/testdriver-device-config/SKILL.md +5 -5
- package/ai/skills/testdriver-find/SKILL.md +50 -11
- package/ai/skills/testdriver-generating-tests/SKILL.md +163 -8
- package/ai/skills/testdriver-hosted/SKILL.md +1 -1
- package/ai/skills/testdriver-machine-setup/SKILL.md +331 -0
- package/ai/skills/testdriver-making-assertions/SKILL.md +78 -2
- package/ai/skills/testdriver-mcp-workflow/SKILL.md +2 -2
- package/ai/skills/testdriver-parse/SKILL.md +1 -1
- package/ai/skills/testdriver-performing-actions/SKILL.md +97 -3
- package/ai/skills/testdriver-provision/SKILL.md +0 -20
- package/ai/skills/testdriver-quickstart/SKILL.md +299 -21
- package/ai/skills/testdriver-reusable-code/SKILL.md +3 -3
- package/ai/skills/testdriver-running-tests/SKILL.md +1 -1
- package/ai/skills/testdriver-screenshot/SKILL.md +5 -5
- package/ai/skills/testdriver-scroll/SKILL.md +30 -160
- package/ai/skills/testdriver-test-writer/SKILL.md +3 -5
- package/ai/skills/testdriver-testdriver/SKILL.md +3 -7
- package/ai/skills/testdriver-wait/SKILL.md +1 -1
- package/{docs/v7/ai.mdx → ai/skills/testdriver:ai/SKILL.md} +3 -4
- package/ai/skills/testdriver:assert/SKILL.md +315 -0
- package/ai/skills/testdriver:aws-setup/SKILL.md +448 -0
- package/ai/skills/testdriver:cache/SKILL.md +221 -0
- package/ai/skills/testdriver:caching/SKILL.md +124 -0
- package/ai/skills/testdriver:captcha/SKILL.md +158 -0
- package/ai/skills/testdriver:ci-cd/SKILL.md +659 -0
- package/ai/skills/testdriver:click/SKILL.md +286 -0
- package/ai/skills/testdriver:client/SKILL.md +477 -0
- package/ai/skills/testdriver:customizing-devices/SKILL.md +319 -0
- package/ai/skills/testdriver:dashcam/SKILL.md +451 -0
- package/ai/skills/testdriver:debugging-with-screenshots/SKILL.md +415 -0
- package/{docs/v7/device-config.mdx → ai/skills/testdriver:device-config/SKILL.md} +8 -8
- package/ai/skills/testdriver:double-click/SKILL.md +102 -0
- package/ai/skills/testdriver:elements/SKILL.md +605 -0
- package/ai/skills/testdriver:enterprise/SKILL.md +7 -0
- package/ai/skills/testdriver:errors/SKILL.md +246 -0
- package/ai/skills/testdriver:events/SKILL.md +356 -0
- package/ai/skills/testdriver:exec/SKILL.md +317 -0
- package/ai/skills/testdriver:find/SKILL.md +860 -0
- package/ai/skills/testdriver:focus-application/SKILL.md +293 -0
- package/ai/skills/testdriver:generating-tests/SKILL.md +25 -0
- package/ai/skills/testdriver:hosted/SKILL.md +156 -0
- package/ai/skills/testdriver:hover/SKILL.md +278 -0
- package/{docs/v7/locating-elements.mdx → ai/skills/testdriver:locating-elements/SKILL.md} +3 -3
- package/ai/skills/testdriver:making-assertions/SKILL.md +32 -0
- package/ai/skills/testdriver:mcp/SKILL.md +7 -0
- package/ai/skills/testdriver:mouse-down/SKILL.md +161 -0
- package/ai/skills/testdriver:mouse-up/SKILL.md +164 -0
- package/ai/skills/testdriver:parse/SKILL.md +236 -0
- package/ai/skills/testdriver:performing-actions/SKILL.md +53 -0
- package/ai/skills/testdriver:press-keys/SKILL.md +348 -0
- package/ai/skills/testdriver:provision/SKILL.md +331 -0
- package/ai/skills/testdriver:quickstart/SKILL.md +172 -0
- package/ai/skills/testdriver:redraw/SKILL.md +214 -0
- package/ai/skills/testdriver:reusable-code/SKILL.md +249 -0
- package/ai/skills/testdriver:right-click/SKILL.md +123 -0
- package/{docs/v7/running-tests.mdx → ai/skills/testdriver:running-tests/SKILL.md} +4 -4
- package/ai/skills/testdriver:screenshot/SKILL.md +248 -0
- package/ai/skills/testdriver:screenshots/SKILL.md +184 -0
- package/ai/skills/testdriver:scroll/SKILL.md +205 -0
- package/ai/skills/testdriver:secrets/SKILL.md +115 -0
- package/ai/skills/testdriver:self-hosted/SKILL.md +147 -0
- package/ai/skills/testdriver:test-results-json/SKILL.md +257 -0
- package/ai/skills/testdriver:testdriver/SKILL.md +624 -0
- package/ai/skills/testdriver:type/SKILL.md +357 -0
- package/ai/skills/testdriver:variables/SKILL.md +111 -0
- package/ai/skills/testdriver:wait/SKILL.md +50 -0
- package/{docs/v7/waiting-for-elements.mdx → ai/skills/testdriver:waiting-for-elements/SKILL.md} +3 -3
- package/ai/skills/testdriver:what-is-testdriver/SKILL.md +54 -0
- package/debugger/index.html +12 -0
- package/docs/_data/examples-manifest.json +54 -46
- package/docs/_data/examples-manifest.schema.json +12 -2
- package/docs/_scripts/extract-example-urls.js +21 -3
- package/docs/_scripts/generate-examples.js +64 -16
- package/docs/changelog.mdx +29 -2
- package/docs/claude-mcp-plugin.mdx +65 -18
- package/docs/docs.json +16 -33
- package/docs/snippets/tests/scroll-yaml.mdx +2 -2
- package/docs/v6/commands/scroll-until-image.mdx +1 -1
- package/docs/v6/commands/scroll-until-text.mdx +2 -2
- package/docs/v6/commands/scroll.mdx +2 -2
- package/docs/v7/_drafts/agents.mdx +0 -4
- package/docs/v7/_drafts/best-practices.mdx +0 -12
- package/docs/v7/_drafts/commands/scroll-until-image.mdx +1 -1
- package/docs/v7/_drafts/commands/scroll-until-text.mdx +2 -2
- package/docs/v7/_drafts/commands/scroll.mdx +1 -1
- package/docs/v7/_drafts/configuration.mdx +0 -12
- package/docs/v7/_drafts/faq.mdx +2 -10
- package/docs/v7/_drafts/readme.mdx +0 -1
- package/docs/v7/_drafts/troubleshooting.mdx +1 -4
- package/docs/v7/assert.mdx +0 -1
- package/docs/v7/aws-setup.mdx +1 -1
- package/docs/v7/caching.mdx +14 -10
- package/docs/v7/ci-cd.mdx +98 -14
- package/docs/v7/client.mdx +10 -6
- package/docs/v7/copilot/auto-healing.mdx +167 -18
- package/docs/v7/copilot/running-tests.mdx +915 -54
- package/docs/v7/customizing-devices.mdx +7 -5
- package/docs/v7/dashcam.mdx +36 -3
- package/docs/v7/debugging-with-screenshots.mdx +31 -7
- package/docs/v7/examples/ai.mdx +13 -8
- package/docs/v7/examples/assert.mdx +1 -1
- package/docs/v7/examples/chrome-extension.mdx +30 -13
- package/docs/v7/examples/element-not-found.mdx +1 -1
- package/docs/v7/examples/findall-coffee-icons.mdx +3 -2
- package/docs/v7/examples/formatted-logging.mdx +74 -0
- package/docs/v7/examples/hover-image.mdx +1 -1
- package/docs/v7/examples/hover-text-with-description.mdx +2 -2
- package/docs/v7/examples/hover-text.mdx +1 -1
- package/docs/v7/examples/installer.mdx +1 -1
- package/docs/v7/examples/launch-vscode-linux.mdx +27 -27
- package/docs/v7/examples/match-image.mdx +1 -1
- package/docs/v7/examples/parse.mdx +3 -2
- package/docs/v7/examples/press-keys.mdx +2 -2
- package/docs/v7/examples/scroll-keyboard.mdx +2 -2
- package/docs/v7/examples/scroll-until-text.mdx +1 -1
- package/docs/v7/examples/scroll.mdx +1 -1
- package/docs/v7/examples/type.mdx +1 -1
- package/docs/v7/find.mdx +50 -11
- package/docs/v7/generating-tests.mdx +166 -10
- package/docs/v7/hosted.mdx +1 -1
- package/docs/v7/making-assertions.mdx +81 -4
- package/docs/v7/parse.mdx +1 -1
- package/docs/v7/performing-actions.mdx +100 -5
- package/docs/v7/provision.mdx +0 -20
- package/docs/v7/quickstart.mdx +299 -21
- package/docs/v7/reusable-code.mdx +3 -3
- package/docs/v7/screenshot.mdx +5 -5
- package/docs/v7/scroll.mdx +30 -160
- package/docs/v7/wait.mdx +1 -1
- package/examples/chrome-extension.test.mjs +29 -12
- package/examples/config.mjs +1 -1
- package/examples/launch-vscode-linux.test.mjs +25 -25
- package/interfaces/cli/commands/init.js +84 -2
- package/interfaces/vitest-plugin.mjs +51 -61
- package/lib/core/Dashcam.js +43 -2
- package/lib/core/index.d.ts +13 -1
- package/lib/github-comment.mjs +11 -5
- package/lib/init-project.js +98 -44
- package/lib/install-clients.js +470 -0
- package/lib/provision.js +749 -0
- package/lib/resolve-channel.js +2 -2
- package/lib/sentry.js +15 -2
- package/lib/vitest/hooks.mjs +44 -34
- package/mcp-server/README.md +32 -0
- package/mcp-server/dist/core/actions.d.ts +176 -0
- package/mcp-server/dist/core/actions.js +753 -0
- package/mcp-server/dist/env-utils.d.ts +45 -0
- package/mcp-server/dist/env-utils.js +63 -0
- package/mcp-server/dist/provision-types.d.ts +6 -2
- package/mcp-server/dist/provision-types.js +3 -1
- package/mcp-server/dist/server.mjs +449 -751
- package/mcp-server/package-lock.json +384 -1
- package/mcp-server/package.json +5 -2
- package/mcp-server/src/core/actions.ts +912 -0
- package/mcp-server/src/env-utils.test.ts +82 -0
- package/mcp-server/src/env-utils.ts +77 -0
- package/mcp-server/src/provision-types.ts +4 -1
- package/mcp-server/src/server.ts +533 -954
- package/mcp-server/tsconfig.json +1 -1
- package/mcp-server/vitest.config.ts +7 -0
- package/package.json +15 -3
- package/sdk.d.ts +11 -9
- package/sdk.js +116 -746
- package/setup/aws/spawn-runner.sh +24 -3
- package/docs/v7/_drafts/caching-ai.mdx +0 -215
- package/docs/v7/_drafts/caching.mdx +0 -366
- package/docs/v7/_drafts/prompt-cache.mdx +0 -200
- package/docs/v7/copilot/creating-tests.mdx +0 -156
- package/docs/v7/copilot/github.mdx +0 -143
- package/docs/v7/copilot/setup.mdx +0 -143
- package/docs/v7/examples/exec-output.mdx +0 -84
- package/docs/v7/examples/exec-pwsh.mdx +0 -82
- package/docs/v7/examples/focus-window.mdx +0 -61
- package/docs/v7/examples/scroll-until-image.mdx +0 -82
- package/docs/v7/examples/windows-installer.mdx +0 -95
- package/examples/formatted-logging.test.mjs +0 -27
- package/examples/match-image.test.mjs +0 -55
- package/manual/exec-stream-logs.test.mjs +0 -25
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TestDriver action core — framework-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* This module owns the actual computer-use logic: it holds the singleton SDK,
|
|
5
|
+
* the element-reference map, and the "last screenshot" used by `check`, and
|
|
6
|
+
* exposes one async function per action. Every function returns a neutral
|
|
7
|
+
* {@link ActionResult} — no MCP types, no eve types — so it can be wrapped by:
|
|
8
|
+
*
|
|
9
|
+
* - the MCP server (`server.ts`), which turns images into resource URIs and
|
|
10
|
+
* calls `createToolResult`, and
|
|
11
|
+
* - eve `defineTool()` files, which return the text/code/images as eve content.
|
|
12
|
+
*
|
|
13
|
+
* Keep MCP- and eve-specific concerns (Sentry, progress heartbeats, abort
|
|
14
|
+
* racing, image stores, UI resources) in the adapters. This file only knows
|
|
15
|
+
* about the TestDriver SDK and `generateActionCode`.
|
|
16
|
+
*
|
|
17
|
+
* State is process-global and single-session by design: one sandbox at a time,
|
|
18
|
+
* mirroring how the stdio MCP server has always behaved.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { generateActionCode } from "../codegen.js";
|
|
22
|
+
import { getProvisionOptions, SessionStartInputSchema, type SessionStartInput } from "../provision-types.js";
|
|
23
|
+
import { sessionManager, type SessionState } from "../session.js";
|
|
24
|
+
|
|
25
|
+
// Re-export the input contract so adapters (MCP server, eve tools) share one
|
|
26
|
+
// source of truth for the session_start schema instead of redeclaring it.
|
|
27
|
+
export { SessionStartInputSchema, type SessionStartInput };
|
|
28
|
+
|
|
29
|
+
// =============================================================================
|
|
30
|
+
// Neutral result shape
|
|
31
|
+
// =============================================================================
|
|
32
|
+
|
|
33
|
+
export interface ActionImage {
|
|
34
|
+
kind: "screenshot" | "cropped";
|
|
35
|
+
/** Raw base64 (no `data:` prefix). */
|
|
36
|
+
base64: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ActionResult {
|
|
40
|
+
/** Whether the action succeeded (assertions/finds may legitimately be false). */
|
|
41
|
+
ok: boolean;
|
|
42
|
+
/** Human/agent-readable summary line(s). */
|
|
43
|
+
text: string;
|
|
44
|
+
/** Structured fields for programmatic consumers and UIs. */
|
|
45
|
+
data: Record<string, unknown>;
|
|
46
|
+
/** Code to append to the test file, when the action produces test code. */
|
|
47
|
+
code?: string;
|
|
48
|
+
/** Images produced by the action (screenshots / cropped element shots). */
|
|
49
|
+
images?: ActionImage[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Thrown when an action runs without an active/valid session. */
|
|
53
|
+
export class NoActiveSessionError extends Error {
|
|
54
|
+
readonly code: "NO_SESSION" | "SESSION_EXPIRED";
|
|
55
|
+
readonly expiredSessionId?: string;
|
|
56
|
+
constructor(code: "NO_SESSION" | "SESSION_EXPIRED", message: string, expiredSessionId?: string) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = "NoActiveSessionError";
|
|
59
|
+
this.code = code;
|
|
60
|
+
this.expiredSessionId = expiredSessionId;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// =============================================================================
|
|
65
|
+
// Singleton state (single sandbox per process)
|
|
66
|
+
// =============================================================================
|
|
67
|
+
|
|
68
|
+
let sdk: any = null;
|
|
69
|
+
let lastScreenshotBase64: string | null = null;
|
|
70
|
+
|
|
71
|
+
/**
|
|
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.
|
|
76
|
+
*
|
|
77
|
+
* Why a hook instead of mcp-core owning the handle: the durable facts (sandbox
|
|
78
|
+
* 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
|
|
80
|
+
* from. So the *durable owner* (eve's per-session state) registers a resolver
|
|
81
|
+
* that reads its own durable store and re-resolves the key per call. Hosts with
|
|
82
|
+
* one long-lived process (stdio MCP server, CLI) register nothing: the resolver
|
|
83
|
+
* stays null and behavior is unchanged — `requireActiveSession` throws the same
|
|
84
|
+
* NO_SESSION/SESSION_EXPIRED as before.
|
|
85
|
+
*
|
|
86
|
+
* The resolver returns `null` when it has nothing to recover from (no sandbox
|
|
87
|
+
* 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),
|
|
89
|
+
* so a stale closure can't outlive the process it was bound to.
|
|
90
|
+
*/
|
|
91
|
+
export type ReconnectResolver = () => Promise<ReconnectParams | null>;
|
|
92
|
+
let reconnectResolver: ReconnectResolver | null = null;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Register (or clear, with `null`) the recovery hook. Durable adapters call this
|
|
96
|
+
* at the start of each step with a resolver bound to the current tool context.
|
|
97
|
+
*/
|
|
98
|
+
export function setReconnectResolver(resolver: ReconnectResolver | null): void {
|
|
99
|
+
reconnectResolver = resolver;
|
|
100
|
+
}
|
|
101
|
+
|
|
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
|
+
/** Expose internals the adapters legitimately need (read-only intent). */
|
|
112
|
+
export function getSdk(): any {
|
|
113
|
+
return sdk;
|
|
114
|
+
}
|
|
115
|
+
export function getLastScreenshotBase64(): string | null {
|
|
116
|
+
return lastScreenshotBase64;
|
|
117
|
+
}
|
|
118
|
+
export function getElementRef(ref: string) {
|
|
119
|
+
return elementRefs.get(ref);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Cheap, side-effect-free check for a usable in-process session: a live SDK and
|
|
124
|
+
* a current session that hasn't expired. Durable adapters call this *before*
|
|
125
|
+
* doing any expensive reconnect prep (e.g. re-resolving an API key), so the
|
|
126
|
+
* common warm-process path stays free. Note: unlike `requireActiveSession`, this
|
|
127
|
+
* does NOT refresh the keepAlive window — it only reports liveness.
|
|
128
|
+
*/
|
|
129
|
+
export function hasLiveSession(): boolean {
|
|
130
|
+
const session = sessionManager.getCurrentSession();
|
|
131
|
+
return !!sdk && !!session && sessionManager.isSessionValid(session.sessionId);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// =============================================================================
|
|
135
|
+
// Helpers
|
|
136
|
+
// =============================================================================
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Load the TestDriver SDK, working in two very different hosts:
|
|
140
|
+
*
|
|
141
|
+
* - eve's dev-runtime / build: it externalizes the `testdriverai` package, so
|
|
142
|
+
* the bare specifier resolves from eve's node_modules and is NOT pulled into
|
|
143
|
+
* the bundle/snapshot (which would drag in sdk.js's monorepo-relative
|
|
144
|
+
* `require('../shared/load-env')` and fail).
|
|
145
|
+
* - the standalone MCP server (stdio/CLI): it is a separate package nested in
|
|
146
|
+
* `sdk/`, so `testdriverai` is not self-referenceable there; fall back to the
|
|
147
|
+
* relative `../../../sdk.js` it has always used.
|
|
148
|
+
*
|
|
149
|
+
* Both specifiers are computed/indirected so a static bundler can't follow them.
|
|
150
|
+
*/
|
|
151
|
+
async function loadTestDriverSdk(): Promise<{ default: any }> {
|
|
152
|
+
const pkg = "testdriverai";
|
|
153
|
+
try {
|
|
154
|
+
return (await import(/* @vite-ignore */ pkg)) as { default: any };
|
|
155
|
+
} catch {
|
|
156
|
+
const rel = ["..", "..", "..", "sdk.js"].join("/");
|
|
157
|
+
return (await import(/* @vite-ignore */ rel)) as { default: any };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Strip the large/derived fields the SDK attaches to a response so they don't
|
|
163
|
+
* bloat the agent's context. Returns a shallow clone safe to mutate further.
|
|
164
|
+
*/
|
|
165
|
+
function leanResponse(raw: any): Record<string, unknown> {
|
|
166
|
+
const r = { ...(raw || {}) } as Record<string, unknown>;
|
|
167
|
+
delete r.croppedImage;
|
|
168
|
+
delete r.extractedText;
|
|
169
|
+
delete r.pixelDiffImage;
|
|
170
|
+
return r;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Normalize a possibly-`data:`-prefixed base64 string to bare base64. */
|
|
174
|
+
function bareBase64(s: string): string {
|
|
175
|
+
return s.startsWith("data:") ? s.replace(/^data:image\/\w+;base64,/, "") : s;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Whether the live SDK's realtime (Ably) transport is actually usable right now.
|
|
180
|
+
*
|
|
181
|
+
* A recycled host gives us a brand-new singleton (`sdk === null`) — caught by the
|
|
182
|
+
* `!sdk` check below. But a *parked* host is subtler: the process froze between
|
|
183
|
+
* steps, Ably's idle timer tore down the transport ("No activity seen from
|
|
184
|
+
* realtime…"), and on thaw the singleton + session still look valid while the
|
|
185
|
+
* socket underneath is dead. Sending over it doesn't fail fast — it hangs for the
|
|
186
|
+
* SDK's full command timeout. So we also treat any non-`connected` Ably state as
|
|
187
|
+
* "needs reconnect". `_ably` is SDK-internal, so every hop is optional-chained
|
|
188
|
+
* and a throw counts as unhealthy: a false negative costs a cheap reconnect, a
|
|
189
|
+
* false positive costs a multi-minute hang, so we fail safe toward reconnecting.
|
|
190
|
+
*/
|
|
191
|
+
function realtimeIsHealthy(s: any): boolean {
|
|
192
|
+
try {
|
|
193
|
+
return s?.sandbox?._ably?.connection?.state === "connected";
|
|
194
|
+
} catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Attempt to rebuild the singleton from the adapter-registered recovery hook.
|
|
201
|
+
* Returns true if a usable session now exists, false if there was nothing to
|
|
202
|
+
* recover from (no resolver, or it returned null). Throws SESSION_EXPIRED when a
|
|
203
|
+
* recovery was attempted but the sandbox is genuinely gone.
|
|
204
|
+
*
|
|
205
|
+
* Concurrent actions in one step share a single in-flight rebuild via
|
|
206
|
+
* {@link reconnecting} so a parked socket isn't reconnected N times in parallel.
|
|
207
|
+
*/
|
|
208
|
+
async function tryRecoverSession(): Promise<boolean> {
|
|
209
|
+
if (!reconnectResolver) return false;
|
|
210
|
+
if (!reconnecting) {
|
|
211
|
+
const resolver = reconnectResolver;
|
|
212
|
+
reconnecting = (async () => {
|
|
213
|
+
const params = await resolver();
|
|
214
|
+
if (!params?.sandboxId) return; // nothing provisioned this session
|
|
215
|
+
// Close any half-dead socket on the outgoing SDK before replacing it, so a
|
|
216
|
+
// parked-then-rebuilt session doesn't leak an orphaned Ably connection.
|
|
217
|
+
try {
|
|
218
|
+
sdk?.sandbox?._ably?.close?.();
|
|
219
|
+
} catch {
|
|
220
|
+
/* best effort — reconnectSession installs a fresh SDK regardless */
|
|
221
|
+
}
|
|
222
|
+
await reconnectSession(params);
|
|
223
|
+
})().finally(() => {
|
|
224
|
+
reconnecting = null;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
await reconnecting;
|
|
229
|
+
} catch (err) {
|
|
230
|
+
sdk = null;
|
|
231
|
+
if (err instanceof NoActiveSessionError) throw err;
|
|
232
|
+
throw new NoActiveSessionError(
|
|
233
|
+
"SESSION_EXPIRED",
|
|
234
|
+
`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
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const session = sessionManager.getCurrentSession();
|
|
238
|
+
return !!sdk && !!session && sessionManager.isSessionValid(session.sessionId);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Validate the active session and auto-extend it (active use keeps it alive),
|
|
243
|
+
* mirroring the MCP server's `requireActiveSession`. When the in-process
|
|
244
|
+
* connection was lost (host recycle wiped the singletons, or a park killed the
|
|
245
|
+
* realtime socket), this first tries to self-heal via the adapter-registered
|
|
246
|
+
* recovery hook ({@link setReconnectResolver}) — so every action recovers at one
|
|
247
|
+
* chokepoint instead of each tool wrapping its own reconnect. Throws
|
|
248
|
+
* {@link NoActiveSessionError} only when there is no usable sandbox AND nothing
|
|
249
|
+
* to recover from.
|
|
250
|
+
*/
|
|
251
|
+
async function requireActiveSession(): Promise<void> {
|
|
252
|
+
const session = sessionManager.getCurrentSession();
|
|
253
|
+
|
|
254
|
+
// Recoverable conditions:
|
|
255
|
+
// - singletons wiped by a host recycle (`!sdk || !session`), or
|
|
256
|
+
// - the SDK exists but its realtime socket died during a park
|
|
257
|
+
// (`!realtimeIsHealthy`). We only treat a dead socket as recoverable when a
|
|
258
|
+
// recovery hook is registered — i.e. a durable host (eve) that actually
|
|
259
|
+
// parks. Long-lived hosts (stdio MCP, CLI) register no resolver, so a
|
|
260
|
+
// 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;
|
|
264
|
+
if (needsRecovery) {
|
|
265
|
+
const recovered = await tryRecoverSession();
|
|
266
|
+
if (!recovered) {
|
|
267
|
+
// Nothing to recover from (no resolver, or it had no sandbox to rebuild).
|
|
268
|
+
// If the socket was dead, don't send the action into the void — surface
|
|
269
|
+
// SESSION_EXPIRED so the agent re-provisions instead of hanging.
|
|
270
|
+
if (socketDead) {
|
|
271
|
+
sdk = null;
|
|
272
|
+
throw new NoActiveSessionError(
|
|
273
|
+
"SESSION_EXPIRED",
|
|
274
|
+
"The sandbox connection was lost and could not be restored. Call session_start again to create a new sandbox session.",
|
|
275
|
+
session?.sessionId
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
// Otherwise the singleton was simply absent — the standard NO_SESSION.
|
|
279
|
+
throw new NoActiveSessionError(
|
|
280
|
+
"NO_SESSION",
|
|
281
|
+
"No active session. Call session_start first to create a sandbox before using any other tools."
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const current = sessionManager.getCurrentSession();
|
|
287
|
+
if (!current || !sessionManager.isSessionValid(current.sessionId)) {
|
|
288
|
+
sdk = null;
|
|
289
|
+
throw new NoActiveSessionError(
|
|
290
|
+
"SESSION_EXPIRED",
|
|
291
|
+
"Session has expired or timed out. Call session_start again to create a new sandbox session.",
|
|
292
|
+
current?.sessionId
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Reset the keepAlive timer on each command so active use doesn't expire.
|
|
297
|
+
sessionManager.refreshSession(current.sessionId);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Capture a fresh full-screen screenshot as bare base64 (or null on failure). */
|
|
301
|
+
async function captureScreen(): Promise<string | null> {
|
|
302
|
+
try {
|
|
303
|
+
const b64 = await sdk.agent.system.captureScreenBase64(1, false, true);
|
|
304
|
+
return b64 || null;
|
|
305
|
+
} catch {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// =============================================================================
|
|
311
|
+
// Session lifecycle
|
|
312
|
+
// =============================================================================
|
|
313
|
+
|
|
314
|
+
export interface SessionStartHooks {
|
|
315
|
+
/** Called before a long await; return a stop fn. Lets adapters heartbeat. */
|
|
316
|
+
onProgress?: (message: string) => void;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Start a session and provision a sandbox. The adapter is responsible for
|
|
321
|
+
* progress/heartbeat and abort; this performs the provisioning and returns the
|
|
322
|
+
* neutral result (including the initial screenshot and provision code).
|
|
323
|
+
*/
|
|
324
|
+
export async function sessionStart(
|
|
325
|
+
params: SessionStartInput,
|
|
326
|
+
resolved: { os: "linux" | "windows"; e2bTemplateId?: string; apiKey?: string },
|
|
327
|
+
hooks: SessionStartHooks = {}
|
|
328
|
+
): Promise<ActionResult> {
|
|
329
|
+
const progress = hooks.onProgress ?? (() => {});
|
|
330
|
+
|
|
331
|
+
// Validate required fields for specific provision types (unless reconnecting).
|
|
332
|
+
if (!params.sandboxId) {
|
|
333
|
+
if (params.type === "installer" && !params.installerUrl) {
|
|
334
|
+
return { ok: false, text: "installer type requires 'installerUrl' parameter", data: { error: "Missing required parameter: installerUrl" } };
|
|
335
|
+
}
|
|
336
|
+
if (params.type === "electron" && !params.appPath) {
|
|
337
|
+
return { ok: false, text: "electron type requires 'appPath' parameter", data: { error: "Missing required parameter: appPath" } };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const newSession = sessionManager.createSession({
|
|
342
|
+
os: resolved.os,
|
|
343
|
+
keepAlive: params.keepAlive,
|
|
344
|
+
testFile: params.testFile,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const apiRoot = params.apiRoot || process.env.TD_API_ROOT || "https://api.testdriver.ai";
|
|
348
|
+
const previewMode = process.env.TD_PREVIEW || "ide";
|
|
349
|
+
const instanceIp = params.ip || process.env.TD_IP;
|
|
350
|
+
// An adapter may resolve a per-caller key out-of-band (e.g. the eve agent's
|
|
351
|
+
// browser OAuth device flow, where each user carries their own team key).
|
|
352
|
+
// Prefer that; otherwise fall back to the process-wide env keys the MCP/CLI
|
|
353
|
+
// host has always used.
|
|
354
|
+
const apiKey = resolved.apiKey || process.env.TD_API_KEY || process.env.COPILOT_MCP_TD_API_KEY || "";
|
|
355
|
+
|
|
356
|
+
if (!apiKey) {
|
|
357
|
+
return {
|
|
358
|
+
ok: false,
|
|
359
|
+
text: "No API key found. Please set TD_API_KEY or COPILOT_MCP_TD_API_KEY environment variable.",
|
|
360
|
+
data: {
|
|
361
|
+
error: "Missing API key",
|
|
362
|
+
action: "session_start",
|
|
363
|
+
hint: "For GitHub Copilot coding agent, create a Copilot environment secret named COPILOT_MCP_TD_API_KEY",
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const TestDriverSDK = (await loadTestDriverSdk()).default;
|
|
369
|
+
sdk = new TestDriverSDK(apiKey, {
|
|
370
|
+
os: resolved.os,
|
|
371
|
+
logging: false,
|
|
372
|
+
apiRoot,
|
|
373
|
+
preview: previewMode as "browser" | "ide" | "none",
|
|
374
|
+
ip: instanceIp,
|
|
375
|
+
e2bTemplateId: resolved.e2bTemplateId,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// Debug mode — attach to an existing sandbox, skip provisioning.
|
|
379
|
+
if (params.sandboxId) {
|
|
380
|
+
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);
|
|
384
|
+
|
|
385
|
+
progress("Capturing screenshot...");
|
|
386
|
+
const shot = await captureScreen();
|
|
387
|
+
if (shot) lastScreenshotBase64 = shot;
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
ok: true,
|
|
391
|
+
text: `Connected to existing sandbox (debug mode)\nSession: ${newSession.sessionId}\nSandbox: ${params.sandboxId}\n\nUse find, click, type, etc. to interact.`,
|
|
392
|
+
data: { action: "session_start", sessionId: newSession.sessionId, sandboxId: params.sandboxId, debugMode: true },
|
|
393
|
+
code: "// Connected to existing sandbox - no provision code needed",
|
|
394
|
+
images: shot ? [{ kind: "screenshot", base64: shot }] : undefined,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
progress(instanceIp ? `Connecting to self-hosted instance ${instanceIp}...` : "Connecting to cloud sandbox...");
|
|
399
|
+
await sdk.connect({ reconnect: params.reconnect, keepAlive: params.keepAlive, ip: instanceIp });
|
|
400
|
+
|
|
401
|
+
const instance = sdk.getInstance();
|
|
402
|
+
sessionManager.activateSession(newSession.sessionId, instance?.instanceId || "unknown");
|
|
403
|
+
|
|
404
|
+
const provisionOptions = getProvisionOptions(params);
|
|
405
|
+
let provisionCmd = "";
|
|
406
|
+
|
|
407
|
+
progress(`Provisioning ${params.type}...`);
|
|
408
|
+
switch (params.type) {
|
|
409
|
+
case "chrome":
|
|
410
|
+
await sdk.provision.chrome(provisionOptions);
|
|
411
|
+
provisionCmd = "provision.chrome";
|
|
412
|
+
break;
|
|
413
|
+
case "chromeExtension":
|
|
414
|
+
await sdk.provision.chromeExtension(provisionOptions);
|
|
415
|
+
provisionCmd = "provision.chromeExtension";
|
|
416
|
+
break;
|
|
417
|
+
case "vscode":
|
|
418
|
+
await sdk.provision.vscode(provisionOptions);
|
|
419
|
+
provisionCmd = "provision.vscode";
|
|
420
|
+
break;
|
|
421
|
+
case "installer":
|
|
422
|
+
await sdk.provision.installer(provisionOptions);
|
|
423
|
+
provisionCmd = "provision.installer";
|
|
424
|
+
break;
|
|
425
|
+
case "electron":
|
|
426
|
+
await sdk.provision.electron(provisionOptions);
|
|
427
|
+
provisionCmd = "provision.electron";
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
progress("Capturing screenshot...");
|
|
432
|
+
const shot = await captureScreen();
|
|
433
|
+
if (shot) lastScreenshotBase64 = shot;
|
|
434
|
+
|
|
435
|
+
const debuggerUrl = instance?.debuggerUrl || (instanceIp ? `http://${instanceIp}:9222` : null);
|
|
436
|
+
const connectionType = instanceIp ? `Self-hosted (${instanceIp})` : "Cloud";
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
ok: true,
|
|
440
|
+
text: `Session started: ${newSession.sessionId}\nConnection: ${connectionType}\nType: ${params.type}\nSandbox: ${instance?.instanceId}\nExpires in: ${Math.round(params.keepAlive / 1000)}s`,
|
|
441
|
+
data: {
|
|
442
|
+
action: "session_start",
|
|
443
|
+
sessionId: newSession.sessionId,
|
|
444
|
+
// The cloud sandbox id (`sb-…`). Surfaced so durable adapters (eve) can
|
|
445
|
+
// persist it and later reconnect the singleton after a process recycle —
|
|
446
|
+
// see `reconnectSession`/`ensureActiveSession` below.
|
|
447
|
+
sandboxId: instance?.instanceId,
|
|
448
|
+
provisionType: params.type,
|
|
449
|
+
selfHosted: !!instanceIp,
|
|
450
|
+
instanceIp: instanceIp || undefined,
|
|
451
|
+
debuggerUrl,
|
|
452
|
+
},
|
|
453
|
+
code: generateActionCode(provisionCmd, provisionOptions),
|
|
454
|
+
images: shot ? [{ kind: "screenshot", base64: shot }] : undefined,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// =============================================================================
|
|
459
|
+
// Reconnect (durable adapters)
|
|
460
|
+
// =============================================================================
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Parameters needed to rebuild the singleton SDK and reconnect to a sandbox
|
|
464
|
+
* that is still alive on the server but whose in-process connection was lost
|
|
465
|
+
* (e.g. the host process was recycled between durable workflow steps).
|
|
466
|
+
*/
|
|
467
|
+
export interface ReconnectParams {
|
|
468
|
+
/** Cloud sandbox id (`sb-…`) to reconnect to. */
|
|
469
|
+
sandboxId: string;
|
|
470
|
+
os: "linux" | "windows";
|
|
471
|
+
keepAlive: number;
|
|
472
|
+
apiKey: string;
|
|
473
|
+
apiRoot?: string;
|
|
474
|
+
e2bTemplateId?: string;
|
|
475
|
+
/** Self-hosted instance IP, when the original session used one. */
|
|
476
|
+
ip?: string;
|
|
477
|
+
testFile?: string;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Rebuild the process-global SDK and reconnect it to an existing sandbox by id,
|
|
482
|
+
* skipping provisioning. This is the recovery path for hosts that do NOT keep a
|
|
483
|
+
* single long-lived process: eve runs each turn as a durable workflow on Vercel
|
|
484
|
+
* and may park or recycle the process between steps, which drops the SDK's
|
|
485
|
+
* realtime connection and wipes this module's singletons. The sandbox itself can
|
|
486
|
+
* still be alive on the server (the API holds it for `keepAlive`/grace), so we
|
|
487
|
+
* re-`connect({ sandboxId })` and re-register a local session instead of failing
|
|
488
|
+
* the action with NO_SESSION.
|
|
489
|
+
*
|
|
490
|
+
* Returns the (re)activated session id. Throws if the reconnect fails (the
|
|
491
|
+
* sandbox is gone) — callers translate that into a SESSION_EXPIRED so the agent
|
|
492
|
+
* knows to call session_start again.
|
|
493
|
+
*/
|
|
494
|
+
export async function reconnectSession(params: ReconnectParams): Promise<string> {
|
|
495
|
+
const apiRoot = params.apiRoot || process.env.TD_API_ROOT || "https://api.testdriver.ai";
|
|
496
|
+
const previewMode = process.env.TD_PREVIEW || "ide";
|
|
497
|
+
|
|
498
|
+
const session = sessionManager.createSession({
|
|
499
|
+
os: params.os,
|
|
500
|
+
keepAlive: params.keepAlive,
|
|
501
|
+
testFile: params.testFile,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const TestDriverSDK = (await loadTestDriverSdk()).default;
|
|
505
|
+
sdk = new TestDriverSDK(params.apiKey, {
|
|
506
|
+
os: params.os,
|
|
507
|
+
logging: false,
|
|
508
|
+
apiRoot,
|
|
509
|
+
preview: previewMode as "browser" | "ide" | "none",
|
|
510
|
+
ip: params.ip,
|
|
511
|
+
e2bTemplateId: params.e2bTemplateId,
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
await sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
|
|
515
|
+
const instance = sdk.getInstance();
|
|
516
|
+
sessionManager.activateSession(session.sessionId, instance?.instanceId || params.sandboxId);
|
|
517
|
+
return session.sessionId;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Ensure there is a usable session before an action, reconnecting from durable
|
|
522
|
+
* state if the in-process singleton was lost. Returns true when a fresh
|
|
523
|
+
* reconnect happened (the caller may want to recapture context), false when the
|
|
524
|
+
* existing session was already usable.
|
|
525
|
+
*
|
|
526
|
+
* Durable adapters call this at the top of each tool, passing the sandbox id and
|
|
527
|
+
* config they persisted at session_start. Hosts with a single long-lived process
|
|
528
|
+
* (the stdio MCP server, the CLI) never persist a sandbox id, so they pass none
|
|
529
|
+
* and this is a no-op — `requireActiveSession()` inside each action still guards
|
|
530
|
+
* them exactly as before.
|
|
531
|
+
*/
|
|
532
|
+
export async function ensureActiveSession(params?: ReconnectParams): Promise<boolean> {
|
|
533
|
+
const session = sessionManager.getCurrentSession();
|
|
534
|
+
// Already have a live SDK + valid session — nothing to do.
|
|
535
|
+
if (sdk && session && sessionManager.isSessionValid(session.sessionId)) {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
// No durable handle to recover from — leave the (missing/expired) state as-is
|
|
539
|
+
// so the action's own requireActiveSession() throws the right NO_SESSION /
|
|
540
|
+
// SESSION_EXPIRED error.
|
|
541
|
+
if (!params?.sandboxId) {
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
await reconnectSession(params);
|
|
546
|
+
return true;
|
|
547
|
+
} catch (err) {
|
|
548
|
+
// The sandbox is gone (server killed it after grace/disconnect cap, or the
|
|
549
|
+
// id is stale). Surface a SESSION_EXPIRED so the adapter's mapper tells the
|
|
550
|
+
// agent to call session_start again, instead of a raw connect error.
|
|
551
|
+
sdk = null;
|
|
552
|
+
throw new NoActiveSessionError(
|
|
553
|
+
"SESSION_EXPIRED",
|
|
554
|
+
`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.`,
|
|
555
|
+
params.sandboxId
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Disconnect the SDK (best-effort); used by adapters on cancel/teardown. */
|
|
561
|
+
export async function disconnect(): Promise<void> {
|
|
562
|
+
try {
|
|
563
|
+
await sdk?.disconnect?.();
|
|
564
|
+
} catch {
|
|
565
|
+
/* best effort */
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export function sessionStatus(): ActionResult {
|
|
570
|
+
const session = sessionManager.getCurrentSession();
|
|
571
|
+
if (!session) {
|
|
572
|
+
return { ok: false, text: "No active session", data: { error: "No active session. Call session_start first." } };
|
|
573
|
+
}
|
|
574
|
+
const summary = sessionManager.getSessionSummary(session.sessionId);
|
|
575
|
+
return {
|
|
576
|
+
ok: true,
|
|
577
|
+
text: `Session: ${session.sessionId}\nStatus: ${session.status}\nTime remaining: ${Math.round((summary?.timeRemaining || 0) / 1000)}s`,
|
|
578
|
+
data: { action: "session_status", ...summary, sessionId: session.sessionId, status: session.status },
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function sessionExtend(additionalMs: number): ActionResult {
|
|
583
|
+
const session = sessionManager.getCurrentSession();
|
|
584
|
+
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);
|
|
587
|
+
return {
|
|
588
|
+
ok: true,
|
|
589
|
+
text: `Session extended by ${additionalMs / 1000}s. New expiry: ${Math.round(newExpiry / 1000)}s`,
|
|
590
|
+
data: { action: "session_extend", newExpiry },
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// =============================================================================
|
|
595
|
+
// Element location
|
|
596
|
+
// =============================================================================
|
|
597
|
+
|
|
598
|
+
export async function find(description: string, timeout?: number): Promise<ActionResult> {
|
|
599
|
+
await requireActiveSession();
|
|
600
|
+
const element = await sdk.find(description, timeout ? { timeout } : undefined);
|
|
601
|
+
const found = element.found();
|
|
602
|
+
const coords = element.getCoordinates();
|
|
603
|
+
|
|
604
|
+
const ref = `el-${Date.now()}`;
|
|
605
|
+
if (found && coords) {
|
|
606
|
+
elementRefs.set(ref, {
|
|
607
|
+
element,
|
|
608
|
+
description,
|
|
609
|
+
coords: { x: coords.x, y: coords.y, centerX: coords.centerX, centerY: coords.centerY },
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const raw = element._response || {};
|
|
614
|
+
const images: ActionImage[] = [];
|
|
615
|
+
if (raw.croppedImage) {
|
|
616
|
+
images.push({ kind: "cropped", base64: bareBase64(raw.croppedImage) });
|
|
617
|
+
} else if (!found) {
|
|
618
|
+
const shot = await captureScreen();
|
|
619
|
+
if (shot) images.push({ kind: "screenshot", base64: shot });
|
|
620
|
+
}
|
|
621
|
+
const data = leanResponse(raw);
|
|
622
|
+
|
|
623
|
+
return {
|
|
624
|
+
ok: found,
|
|
625
|
+
text: found
|
|
626
|
+
? `Found: "${description}" at (${(raw.coordinates as any)?.x}, ${(raw.coordinates as any)?.y})\nRef: ${ref}`
|
|
627
|
+
: `Element not found: "${description}"`,
|
|
628
|
+
data: {
|
|
629
|
+
...data,
|
|
630
|
+
action: "find",
|
|
631
|
+
element: found ? { description, centerX: coords?.centerX, centerY: coords?.centerY, confidence: element.confidence, ref } : undefined,
|
|
632
|
+
ref,
|
|
633
|
+
},
|
|
634
|
+
code: found ? generateActionCode("find", { description }) : undefined,
|
|
635
|
+
images: images.length ? images : undefined,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export async function findAll(description: string, timeout?: number): Promise<ActionResult> {
|
|
640
|
+
await requireActiveSession();
|
|
641
|
+
const elements = await sdk.findAll(description, timeout ? { timeout } : undefined);
|
|
642
|
+
const count = elements.length;
|
|
643
|
+
|
|
644
|
+
const refs: string[] = [];
|
|
645
|
+
const elementInfos: Array<{ ref: string; x: number; y: number; centerX: number; centerY: number; confidence: number }> = [];
|
|
646
|
+
for (let i = 0; i < elements.length; i++) {
|
|
647
|
+
const el = elements[i];
|
|
648
|
+
const coords = el.getCoordinates();
|
|
649
|
+
if (!coords) continue;
|
|
650
|
+
const ref = `el-${Date.now()}-${i}`;
|
|
651
|
+
elementRefs.set(ref, { element: el, description: `${description} [${i}]`, coords });
|
|
652
|
+
refs.push(ref);
|
|
653
|
+
elementInfos.push({ ref, x: coords.x, y: coords.y, centerX: coords.centerX, centerY: coords.centerY, confidence: el.confidence });
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const raw = elements[0]?._response || {};
|
|
657
|
+
const images: ActionImage[] = [];
|
|
658
|
+
if (raw.croppedImage) {
|
|
659
|
+
images.push({ kind: "cropped", base64: bareBase64(raw.croppedImage) });
|
|
660
|
+
} else if (count === 0) {
|
|
661
|
+
const shot = await captureScreen();
|
|
662
|
+
if (shot) images.push({ kind: "screenshot", base64: shot });
|
|
663
|
+
}
|
|
664
|
+
const data = leanResponse(raw);
|
|
665
|
+
|
|
666
|
+
return {
|
|
667
|
+
ok: count > 0,
|
|
668
|
+
text:
|
|
669
|
+
count > 0
|
|
670
|
+
? `Found ${count} elements matching "${description}":\n${refs.map((r, i) => ` [${i}] ${r}`).join("\n")}`
|
|
671
|
+
: `No elements found matching: "${description}"`,
|
|
672
|
+
data: { ...data, action: "findall", count, refs, elements: elementInfos },
|
|
673
|
+
code: count > 0 ? generateActionCode("findall", { description }) : undefined,
|
|
674
|
+
images: images.length ? images : undefined,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// =============================================================================
|
|
679
|
+
// Element interaction (require a ref from find/findall)
|
|
680
|
+
// =============================================================================
|
|
681
|
+
|
|
682
|
+
async function actOnRef(
|
|
683
|
+
ref: string,
|
|
684
|
+
verb: "click" | "double-click" | "right-click" | "hover",
|
|
685
|
+
action: "click" | "hover"
|
|
686
|
+
): Promise<ActionResult> {
|
|
687
|
+
await requireActiveSession();
|
|
688
|
+
const stored = elementRefs.get(ref);
|
|
689
|
+
if (!stored) {
|
|
690
|
+
return { ok: false, text: `Element reference "${ref}" not found. Use 'find' first to locate the element.`, data: { error: "Element reference not found" } };
|
|
691
|
+
}
|
|
692
|
+
const { element, description, coords } = stored;
|
|
693
|
+
|
|
694
|
+
if (verb === "click") await element.click();
|
|
695
|
+
else if (verb === "double-click") await element.doubleClick();
|
|
696
|
+
else if (verb === "right-click") await element.rightClick();
|
|
697
|
+
else if (verb === "hover") await element.hover();
|
|
698
|
+
|
|
699
|
+
const shot = await captureScreen();
|
|
700
|
+
if (shot) lastScreenshotBase64 = shot;
|
|
701
|
+
const data = leanResponse(element._response);
|
|
702
|
+
|
|
703
|
+
if (action === "hover") {
|
|
704
|
+
return {
|
|
705
|
+
ok: true,
|
|
706
|
+
text: `Hovered over "${description}"`,
|
|
707
|
+
data: { ...data, action: "hover" },
|
|
708
|
+
code: generateActionCode("hover", {}),
|
|
709
|
+
images: shot ? [{ kind: "screenshot", base64: shot }] : undefined,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
return {
|
|
713
|
+
ok: true,
|
|
714
|
+
text: `Clicked on "${description}"`,
|
|
715
|
+
data: { ...data, action: "click", clickAction: verb, clickPosition: coords },
|
|
716
|
+
code: generateActionCode("click", { action: verb }),
|
|
717
|
+
images: shot ? [{ kind: "screenshot", base64: shot }] : undefined,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export function click(ref: string, action: "click" | "double-click" | "right-click" = "click"): Promise<ActionResult> {
|
|
722
|
+
return actOnRef(ref, action, "click");
|
|
723
|
+
}
|
|
724
|
+
export function hover(ref: string): Promise<ActionResult> {
|
|
725
|
+
return actOnRef(ref, "hover", "hover");
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
export async function findAndClick(description: string, action: "click" | "double-click" | "right-click" = "click"): Promise<ActionResult> {
|
|
729
|
+
await requireActiveSession();
|
|
730
|
+
const element = await sdk.find(description);
|
|
731
|
+
const found = element.found();
|
|
732
|
+
|
|
733
|
+
if (!found) {
|
|
734
|
+
const raw = element._response || {};
|
|
735
|
+
const images: ActionImage[] = [];
|
|
736
|
+
if (raw.croppedImage) images.push({ kind: "screenshot", base64: bareBase64(raw.croppedImage) });
|
|
737
|
+
else {
|
|
738
|
+
const shot = await captureScreen();
|
|
739
|
+
if (shot) images.push({ kind: "screenshot", base64: shot });
|
|
740
|
+
}
|
|
741
|
+
return {
|
|
742
|
+
ok: false,
|
|
743
|
+
text: `Element not found: "${description}"`,
|
|
744
|
+
data: { ...leanResponse(raw), action: "find_and_click", error: "Element not found" },
|
|
745
|
+
images: images.length ? images : undefined,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const coords = element.getCoordinates();
|
|
750
|
+
const ref = `el-${Date.now()}`;
|
|
751
|
+
if (coords) elementRefs.set(ref, { element, description, coords });
|
|
752
|
+
|
|
753
|
+
if (action === "click") await element.click();
|
|
754
|
+
else if (action === "double-click") await element.doubleClick();
|
|
755
|
+
else if (action === "right-click") await element.rightClick();
|
|
756
|
+
|
|
757
|
+
const raw = element._response || {};
|
|
758
|
+
const images: ActionImage[] = [];
|
|
759
|
+
if (raw.croppedImage) images.push({ kind: "cropped", base64: bareBase64(raw.croppedImage) });
|
|
760
|
+
const data = leanResponse(raw);
|
|
761
|
+
|
|
762
|
+
return {
|
|
763
|
+
ok: true,
|
|
764
|
+
text: `Found and clicked: "${description}" at (${(raw.coordinates as any)?.x}, ${(raw.coordinates as any)?.y})\nRef: ${ref}`,
|
|
765
|
+
data: {
|
|
766
|
+
...data,
|
|
767
|
+
action: "find_and_click",
|
|
768
|
+
element: coords ? { description, centerX: coords.centerX, centerY: coords.centerY, confidence: element.confidence, ref } : undefined,
|
|
769
|
+
ref,
|
|
770
|
+
clickAction: action,
|
|
771
|
+
clickPosition: coords ? { x: coords.centerX, y: coords.centerY } : undefined,
|
|
772
|
+
},
|
|
773
|
+
code: generateActionCode("find_and_click", { description, action }),
|
|
774
|
+
images: images.length ? images : undefined,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// =============================================================================
|
|
779
|
+
// Input
|
|
780
|
+
// =============================================================================
|
|
781
|
+
|
|
782
|
+
export async function type(text: string, secret = false, delay?: number): Promise<ActionResult> {
|
|
783
|
+
await requireActiveSession();
|
|
784
|
+
await sdk.type(text, { secret, delay });
|
|
785
|
+
return {
|
|
786
|
+
ok: true,
|
|
787
|
+
text: `Typed: ${secret ? "[secret text]" : `"${text}"`}`,
|
|
788
|
+
data: { action: "type", text: secret ? "[SECRET]" : text },
|
|
789
|
+
code: generateActionCode("type", { text, secret }),
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
export async function pressKeys(keys: string[]): Promise<ActionResult> {
|
|
794
|
+
await requireActiveSession();
|
|
795
|
+
await sdk.pressKeys(keys);
|
|
796
|
+
return {
|
|
797
|
+
ok: true,
|
|
798
|
+
text: `Pressed keys: ${keys.join(" + ")}`,
|
|
799
|
+
data: { action: "press_keys", keys },
|
|
800
|
+
code: generateActionCode("press_keys", { keys }),
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
export async function scroll(direction: "up" | "down" | "left" | "right" = "down", amount?: number): Promise<ActionResult> {
|
|
805
|
+
await requireActiveSession();
|
|
806
|
+
await sdk.scroll(direction, amount ? { amount } : undefined);
|
|
807
|
+
return {
|
|
808
|
+
ok: true,
|
|
809
|
+
text: `Scrolled ${direction}${amount ? ` by ${amount}px` : ""}`,
|
|
810
|
+
data: { action: "scroll", scrollDirection: direction, direction, amount },
|
|
811
|
+
code: generateActionCode("scroll", { direction, amount }),
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
export async function focusApplication(name: string): Promise<ActionResult> {
|
|
816
|
+
await requireActiveSession();
|
|
817
|
+
await sdk.focusApplication(name);
|
|
818
|
+
return {
|
|
819
|
+
ok: true,
|
|
820
|
+
text: `Focused application: "${name}"`,
|
|
821
|
+
data: { action: "focus", name },
|
|
822
|
+
code: generateActionCode("focus_application", { name }),
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
export async function wait(timeout: number): Promise<ActionResult> {
|
|
827
|
+
await requireActiveSession();
|
|
828
|
+
await sdk.wait(timeout);
|
|
829
|
+
return {
|
|
830
|
+
ok: true,
|
|
831
|
+
text: `Waited for ${timeout}ms`,
|
|
832
|
+
data: { action: "wait", timeout },
|
|
833
|
+
code: generateActionCode("wait", { timeout }),
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
export async function exec(language: "sh" | "pwsh", code: string, timeout = 30000): Promise<ActionResult> {
|
|
838
|
+
await requireActiveSession();
|
|
839
|
+
const output = await sdk.exec(language, code, timeout);
|
|
840
|
+
return {
|
|
841
|
+
ok: true,
|
|
842
|
+
text: `Executed ${language} code:\n${output || "(no output)"}`,
|
|
843
|
+
data: { action: "exec", language, output },
|
|
844
|
+
code: generateActionCode("exec", { language, code, timeout }),
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// =============================================================================
|
|
849
|
+
// Verification
|
|
850
|
+
// =============================================================================
|
|
851
|
+
|
|
852
|
+
export async function assert(assertion: string): Promise<ActionResult> {
|
|
853
|
+
await requireActiveSession();
|
|
854
|
+
const result = await sdk.assert(assertion);
|
|
855
|
+
return {
|
|
856
|
+
ok: result,
|
|
857
|
+
text: result ? `✓ Assertion passed: "${assertion}"` : `✗ Assertion failed: "${assertion}"`,
|
|
858
|
+
data: { action: "assert", assertion, passed: result },
|
|
859
|
+
code: generateActionCode("assert", { assertion }),
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* AI analysis of the current screen vs. a "before" state. Does NOT generate
|
|
865
|
+
* code. `referenceImage` (bare base64), if provided, is used as the before
|
|
866
|
+
* image instead of the last captured screenshot.
|
|
867
|
+
*/
|
|
868
|
+
export async function check(task: string, referenceImage?: string): Promise<ActionResult> {
|
|
869
|
+
await requireActiveSession();
|
|
870
|
+
const currentScreenshot = await sdk.agent.system.captureScreenBase64(1, false, true);
|
|
871
|
+
const beforeScreenshot = referenceImage || lastScreenshotBase64 || currentScreenshot;
|
|
872
|
+
lastScreenshotBase64 = currentScreenshot;
|
|
873
|
+
|
|
874
|
+
const mousePosition = await sdk.agent.system.getMousePosition();
|
|
875
|
+
const activeWindow = await sdk.agent.system.activeWin();
|
|
876
|
+
|
|
877
|
+
const response = await sdk.agent.sdk.req("check", {
|
|
878
|
+
tasks: [task],
|
|
879
|
+
images: [beforeScreenshot, currentScreenshot],
|
|
880
|
+
mousePosition,
|
|
881
|
+
activeWindow,
|
|
882
|
+
});
|
|
883
|
+
const aiResponse = response.data;
|
|
884
|
+
|
|
885
|
+
const hasCodeBlocks =
|
|
886
|
+
aiResponse && (aiResponse.includes("```yml") || aiResponse.includes("```yaml") || aiResponse.includes("- command:"));
|
|
887
|
+
const isComplete = !hasCodeBlocks;
|
|
888
|
+
|
|
889
|
+
return {
|
|
890
|
+
ok: isComplete,
|
|
891
|
+
text: isComplete
|
|
892
|
+
? `✓ Task appears complete: "${task}"\n\nAI Analysis:\n${aiResponse}`
|
|
893
|
+
: `⚠ Task may not be complete: "${task}"\n\nAI Analysis:\n${aiResponse}`,
|
|
894
|
+
data: { action: "check", task, complete: isComplete, aiResponse },
|
|
895
|
+
images: currentScreenshot ? [{ kind: "screenshot", base64: currentScreenshot }] : undefined,
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** Capture a screenshot for the user (cursor visible). */
|
|
900
|
+
export async function screenshot(): Promise<ActionResult> {
|
|
901
|
+
await requireActiveSession();
|
|
902
|
+
const shot = await sdk.agent.system.captureScreenBase64(1, false, true);
|
|
903
|
+
if (shot) lastScreenshotBase64 = shot;
|
|
904
|
+
return {
|
|
905
|
+
ok: !!shot,
|
|
906
|
+
text: shot ? "Captured screenshot" : "Failed to capture screenshot",
|
|
907
|
+
data: { action: "screenshot" },
|
|
908
|
+
images: shot ? [{ kind: "screenshot", base64: shot }] : undefined,
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
export type { SessionState };
|