@forsion/tangu-computer-use 0.5.8
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/CHANGELOG.md +223 -0
- package/LICENSE +26 -0
- package/LICENSE.upstream +21 -0
- package/README.md +169 -0
- package/UPSTREAM.md +277 -0
- package/check.mjs +369 -0
- package/icon.png +0 -0
- package/install.sh +112 -0
- package/main.js +541 -0
- package/manifest.json +47 -0
- package/native/linux/bridge-rs/Cargo.lock +1204 -0
- package/native/linux/bridge-rs/Cargo.toml +18 -0
- package/native/linux/bridge-rs/src/atspi.rs +640 -0
- package/native/linux/bridge-rs/src/error.rs +65 -0
- package/native/linux/bridge-rs/src/lib.rs +11 -0
- package/native/linux/bridge-rs/src/main.rs +1074 -0
- package/native/linux/bridge-rs/src/protocol.rs +49 -0
- package/native/linux/bridge-rs/src/state.rs +293 -0
- package/native/linux/bridge-rs/src/wayland.rs +89 -0
- package/native/linux/bridge-rs/src/x11.rs +909 -0
- package/native/linux/bridge-rs/tests/protocol_tests.rs +73 -0
- package/native/macos/agent_cursor.swift +184 -0
- package/native/macos/agent_cursor_motion.swift +252 -0
- package/native/macos/agent_cursor_tests.swift +128 -0
- package/native/macos/agent_highlight.swift +229 -0
- package/native/macos/agent_highlight_tests.swift +99 -0
- package/native/macos/bridge.swift +3852 -0
- package/native/macos/foreground_activity.swift +57 -0
- package/native/macos/foreground_activity_tests.swift +31 -0
- package/native/macos/live_stream.swift +289 -0
- package/native/windows/bridge-rs/Cargo.lock +396 -0
- package/native/windows/bridge-rs/Cargo.toml +30 -0
- package/native/windows/bridge-rs/src/capture.rs +518 -0
- package/native/windows/bridge-rs/src/error.rs +81 -0
- package/native/windows/bridge-rs/src/input.rs +501 -0
- package/native/windows/bridge-rs/src/lib.rs +16 -0
- package/native/windows/bridge-rs/src/main.rs +1683 -0
- package/native/windows/bridge-rs/src/protocol.rs +53 -0
- package/native/windows/bridge-rs/src/refs.rs +237 -0
- package/native/windows/bridge-rs/src/state.rs +55 -0
- package/native/windows/bridge-rs/src/uia.rs +1252 -0
- package/native/windows/bridge-rs/src/window.rs +718 -0
- package/native/windows/bridge-rs/tests/protocol_tests.rs +252 -0
- package/native/windows/bridge-rs/tests/refs_tests.rs +86 -0
- package/native/windows/bridge-rs/tests/state_tests.rs +33 -0
- package/package.json +74 -0
- package/prebuilt/linux/arm64/linux-bridge +0 -0
- package/prebuilt/linux/x64/linux-bridge +0 -0
- package/prebuilt/macos/arm64/bridge +0 -0
- package/prebuilt/macos/arm64/tangu-computer-use.app.json +10 -0
- package/prebuilt/macos/arm64/tangu-computer-use.app.zip +0 -0
- package/prebuilt/macos/x64/bridge +0 -0
- package/prebuilt/macos/x64/tangu-computer-use.app.json +10 -0
- package/prebuilt/macos/x64/tangu-computer-use.app.zip +0 -0
- package/prebuilt/windows/windows-bridge.exe +0 -0
- package/scripts/blind-click.check.mjs +128 -0
- package/scripts/build-native.mjs +302 -0
- package/scripts/build.mjs +84 -0
- package/scripts/calc-fixture.mjs +78 -0
- package/scripts/cli-exit.check.mjs +49 -0
- package/scripts/helper-path.check.mjs +82 -0
- package/scripts/helper-refresh.check.mjs +62 -0
- package/scripts/helper-signal.check.mjs +59 -0
- package/scripts/highlight.check.mjs +32 -0
- package/scripts/keychain-free-install.check.mjs +140 -0
- package/scripts/live-view.check.mjs +124 -0
- package/scripts/macos-bundle.d.mts +1 -0
- package/scripts/macos-bundle.mjs +119 -0
- package/scripts/make-signing-cert.sh +57 -0
- package/scripts/mini-foreground.check.mjs +11 -0
- package/scripts/no-foreground.check.mjs +81 -0
- package/scripts/overlay-visible.check.mjs +221 -0
- package/scripts/package-macos-app.mjs +39 -0
- package/scripts/permissions.check.mjs +199 -0
- package/scripts/platform-contract.check.mjs +30 -0
- package/scripts/setup-helper.mjs +154 -0
- package/scripts/tangu-computer-use.entitlements +10 -0
- package/scripts/verify-macos-bundles.mjs +25 -0
- package/scripts/verify-package.mjs +74 -0
- package/skills/computer-use/SKILL.md +110 -0
- package/src/foregroundNote.ts +43 -0
- package/src/helperState.ts +31 -0
- package/src/index.ts +63 -0
- package/src/onboarding.ts +198 -0
- package/src/pi-compat.ts +49 -0
- package/src/settings.ts +11 -0
- package/src/setup.ts +97 -0
- package/src/tools.ts +289 -0
- package/src/vendor/actions.ts +130 -0
- package/src/vendor/bridge.ts +2405 -0
- package/src/vendor/cdp.ts +658 -0
- package/src/vendor/config.ts +113 -0
- package/src/vendor/contract.ts +104 -0
- package/src/vendor/note.ts +195 -0
- package/src/vendor/outline.ts +651 -0
- package/src/vendor/output.ts +134 -0
- package/src/vendor/permissions.ts +111 -0
- package/src/vendor/platform/architecture.ts +23 -0
- package/src/vendor/platform/coerce.ts +16 -0
- package/src/vendor/platform/index.ts +59 -0
- package/src/vendor/platform/linux/backend.ts +186 -0
- package/src/vendor/platform/linux/helper.ts +238 -0
- package/src/vendor/platform/macos/backend.ts +131 -0
- package/src/vendor/platform/macos/browser.ts +110 -0
- package/src/vendor/platform/macos/helper-path.d.mts +9 -0
- package/src/vendor/platform/macos/helper-path.mjs +34 -0
- package/src/vendor/platform/macos/helper.ts +291 -0
- package/src/vendor/platform/macos/permissions.ts +146 -0
- package/src/vendor/platform/types.ts +222 -0
- package/src/vendor/platform/windows/backend.ts +142 -0
- package/src/vendor/platform/windows/helper.ts +140 -0
- package/src/vendor/root-selection.ts +25 -0
- package/src/vendor/runtime.ts +129 -0
- package/src/vendor/state.ts +146 -0
- package/src/vendor/view.ts +147 -0
- package/tangu-plugins/computer-use/dist/foregroundNote.js +19 -0
- package/tangu-plugins/computer-use/dist/index.js +5672 -0
- package/tangu-plugins/computer-use/tangu-plugin.json +9 -0
- package/tsconfig.json +22 -0
- package/types/tangu-agent.d.ts +142 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
// Minimal Chrome DevTools Protocol client.
|
|
2
|
+
//
|
|
3
|
+
// Opt-in: set PI_COMPUTER_USE_CDP_PORT to the --remote-debugging-port of a
|
|
4
|
+
// running Chromium-family browser. When active, navigate_browser uses
|
|
5
|
+
// Page.navigate (event-driven, no AppleScript) and recent console messages
|
|
6
|
+
// and uncaught exceptions are attached to tool results. Everything else
|
|
7
|
+
// keeps the AX/CGEvent path, so with the env var unset this module is inert.
|
|
8
|
+
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { parseLookResponse, serializeOutline, type SerializedOutline } from "./outline.ts";
|
|
11
|
+
|
|
12
|
+
export interface CdpConsoleEntry {
|
|
13
|
+
level: string;
|
|
14
|
+
text: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CdpPageContext {
|
|
18
|
+
contextId: string;
|
|
19
|
+
targetId: string;
|
|
20
|
+
title: string;
|
|
21
|
+
url: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CdpSnapshotTarget {
|
|
25
|
+
ref: string;
|
|
26
|
+
source: "browser_ax";
|
|
27
|
+
role: string;
|
|
28
|
+
name: string;
|
|
29
|
+
value?: string;
|
|
30
|
+
actions: string[];
|
|
31
|
+
backendNodeId?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CdpPageSnapshot {
|
|
35
|
+
contextId: string;
|
|
36
|
+
snapshotId: string;
|
|
37
|
+
targetId: string;
|
|
38
|
+
title: string;
|
|
39
|
+
url: string;
|
|
40
|
+
capturedAt: number;
|
|
41
|
+
text: string;
|
|
42
|
+
targets: CdpSnapshotTarget[];
|
|
43
|
+
outline: SerializedOutline;
|
|
44
|
+
diagnostics: {
|
|
45
|
+
cdp: "connected";
|
|
46
|
+
targetCount: number;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CdpEvaluationResult {
|
|
51
|
+
contextId: string;
|
|
52
|
+
value: unknown;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Window frame in screen points, as reported by the AX side. */
|
|
56
|
+
export interface WindowFrame {
|
|
57
|
+
x: number;
|
|
58
|
+
y: number;
|
|
59
|
+
w: number;
|
|
60
|
+
h: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const COMMAND_TIMEOUT_MS = 5_000;
|
|
64
|
+
const CDP_CONTEXT_PREFIX = "browser:";
|
|
65
|
+
const NAVIGATE_LOAD_TIMEOUT_MS = 10_000;
|
|
66
|
+
const CONNECT_FAILURE_RETRY_MS = 5_000;
|
|
67
|
+
const CONSOLE_BUFFER_LIMIT = 20;
|
|
68
|
+
|
|
69
|
+
export class CdpTab {
|
|
70
|
+
private nextId = 1;
|
|
71
|
+
private readonly pending = new Map<number, { resolve: (result: any) => void; reject: (error: Error) => void }>();
|
|
72
|
+
private consoleBuffer: CdpConsoleEntry[] = [];
|
|
73
|
+
private loadFired: (() => void) | undefined;
|
|
74
|
+
|
|
75
|
+
private constructor(
|
|
76
|
+
private readonly ws: WebSocket,
|
|
77
|
+
readonly targetId: string,
|
|
78
|
+
public title: string,
|
|
79
|
+
) {}
|
|
80
|
+
|
|
81
|
+
static async connect(wsUrl: string, targetId: string, title: string): Promise<CdpTab> {
|
|
82
|
+
const ws = new WebSocket(wsUrl);
|
|
83
|
+
try {
|
|
84
|
+
await new Promise<void>((resolve, reject) => {
|
|
85
|
+
const timer = setTimeout(() => reject(new Error(`Timed out connecting to CDP target at ${wsUrl}`)), COMMAND_TIMEOUT_MS);
|
|
86
|
+
ws.onopen = () => {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
resolve();
|
|
89
|
+
};
|
|
90
|
+
ws.onerror = () => {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
reject(new Error(`Failed to connect to CDP target at ${wsUrl}`));
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const tab = new CdpTab(ws, targetId, title);
|
|
97
|
+
ws.onmessage = (event) => tab.handleMessage(String(event.data));
|
|
98
|
+
ws.onclose = () => tab.rejectAllPending(new Error("CDP connection closed."));
|
|
99
|
+
ws.onerror = () => tab.rejectAllPending(new Error("CDP connection error."));
|
|
100
|
+
await tab.send("Runtime.enable");
|
|
101
|
+
await tab.send("Page.enable");
|
|
102
|
+
return tab;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
try {
|
|
105
|
+
ws.close();
|
|
106
|
+
} catch {
|
|
107
|
+
// already closed
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
get isOpen(): boolean {
|
|
114
|
+
return this.ws.readyState === WebSocket.OPEN;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
close(): void {
|
|
118
|
+
this.loadFired?.();
|
|
119
|
+
this.loadFired = undefined;
|
|
120
|
+
this.rejectAllPending(new Error("CDP connection closed."));
|
|
121
|
+
try {
|
|
122
|
+
this.ws.close();
|
|
123
|
+
} catch {
|
|
124
|
+
// already closed
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Evaluates a JS expression in the page and returns its primitive value. */
|
|
129
|
+
async evaluate(expression: string): Promise<unknown> {
|
|
130
|
+
const result = await this.send("Runtime.evaluate", { expression, returnByValue: true, timeout: COMMAND_TIMEOUT_MS, awaitPromise: true });
|
|
131
|
+
return result?.result?.value;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async accessibilityTree(): Promise<unknown[]> {
|
|
135
|
+
const result = await this.send("Accessibility.getFullAXTree");
|
|
136
|
+
return Array.isArray(result?.nodes) ? result.nodes : [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async navigate(url: string): Promise<void> {
|
|
140
|
+
const loaded = new Promise<void>((resolve) => {
|
|
141
|
+
this.loadFired = resolve;
|
|
142
|
+
});
|
|
143
|
+
try {
|
|
144
|
+
await this.send("Page.navigate", { url });
|
|
145
|
+
// SPAs and slow pages may never fire load; cap the wait and move on.
|
|
146
|
+
await Promise.race([loaded, new Promise<void>((resolve) => setTimeout(resolve, NAVIGATE_LOAD_TIMEOUT_MS))]);
|
|
147
|
+
} finally {
|
|
148
|
+
this.loadFired = undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async clickBackendNode(backendNodeId: number): Promise<void> {
|
|
153
|
+
await this.withBackendNode(backendNodeId, "function(){ this.scrollIntoView({block:'center', inline:'center'}); this.click(); }");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async typeIntoBackendNode(backendNodeId: number, text: string, replace: boolean): Promise<void> {
|
|
157
|
+
await this.withBackendNode(backendNodeId, "function(text, replace){ this.scrollIntoView({block:'center', inline:'center'}); this.focus(); if (replace) { if ('value' in this) this.value = ''; else this.textContent = ''; } if ('value' in this) this.value += text; else this.textContent = (this.textContent || '') + text; this.dispatchEvent(new InputEvent('input', {bubbles:true, inputType:'insertText', data:text})); this.dispatchEvent(new Event('change', {bubbles:true})); }", [text, replace]);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async scrollBy(deltaX: number, deltaY: number, backendNodeId?: number): Promise<void> {
|
|
161
|
+
if (backendNodeId) {
|
|
162
|
+
await this.withBackendNode(backendNodeId, "function(dx, dy){ this.scrollIntoView({block:'center', inline:'center'}); this.scrollBy(dx, dy); }", [deltaX, deltaY]);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
await this.send("Runtime.evaluate", { expression: `window.scrollBy(${JSON.stringify(deltaX)}, ${JSON.stringify(deltaY)})` });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async typeIntoFocused(text: string): Promise<void> {
|
|
169
|
+
await this.send("Input.insertText", { text });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async keypress(keys: string[]): Promise<void> {
|
|
173
|
+
const modifierBits: Record<string, number> = { alt: 1, option: 1, control: 2, ctrl: 2, meta: 4, command: 4, cmd: 4, shift: 8 };
|
|
174
|
+
const modifiers = keys.reduce((bits, key) => bits | (modifierBits[key.toLowerCase()] ?? 0), 0);
|
|
175
|
+
for (const key of keys.filter((candidate) => modifierBits[candidate.toLowerCase()] === undefined)) {
|
|
176
|
+
await this.send("Input.dispatchKeyEvent", { type: "keyDown", key, code: key, text: key.length === 1 && modifiers === 0 ? key : undefined, modifiers });
|
|
177
|
+
await this.send("Input.dispatchKeyEvent", { type: "keyUp", key, code: key, modifiers });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async mouseAt(x: number, y: number, type: "mouseMoved" | "mousePressed" | "mouseReleased", button: "left" | "right" | "middle" = "left", clickCount = 1): Promise<void> {
|
|
182
|
+
await this.send("Input.dispatchMouseEvent", { type, x, y, button: type === "mouseMoved" ? "none" : button, clickCount });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async dragPath(path: Array<{ x: number; y: number }>): Promise<void> {
|
|
186
|
+
if (path.length < 2) throw new Error("CDP drag requires at least two points.");
|
|
187
|
+
await this.mouseAt(path[0].x, path[0].y, "mousePressed");
|
|
188
|
+
for (const point of path.slice(1)) await this.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: point.x, y: point.y, button: "left", buttons: 1 });
|
|
189
|
+
const end = path[path.length - 1];
|
|
190
|
+
await this.mouseAt(end.x, end.y, "mouseReleased");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private async withBackendNode(backendNodeId: number, functionDeclaration: string, args: unknown[] = []): Promise<void> {
|
|
194
|
+
const resolved = await this.send("DOM.resolveNode", { backendNodeId });
|
|
195
|
+
const objectId = resolved?.object?.objectId;
|
|
196
|
+
if (typeof objectId !== "string") throw new Error(`CDP could not resolve backend node ${backendNodeId}.`);
|
|
197
|
+
await this.send("Runtime.callFunctionOn", {
|
|
198
|
+
objectId,
|
|
199
|
+
functionDeclaration,
|
|
200
|
+
arguments: args.map((value) => ({ value })),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Screen bounds of the browser window containing this tab. */
|
|
205
|
+
async windowBounds(): Promise<WindowFrame | undefined> {
|
|
206
|
+
const result = await this.send("Browser.getWindowForTarget", { targetId: this.targetId });
|
|
207
|
+
const bounds = result?.bounds;
|
|
208
|
+
if (typeof bounds?.left !== "number" || typeof bounds?.width !== "number") return undefined;
|
|
209
|
+
return { x: bounds.left, y: bounds.top, w: bounds.width, h: bounds.height };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Returns buffered console messages/exceptions and clears the buffer. */
|
|
213
|
+
drainConsole(): CdpConsoleEntry[] {
|
|
214
|
+
const entries = this.consoleBuffer;
|
|
215
|
+
this.consoleBuffer = [];
|
|
216
|
+
return entries;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private send(method: string, params: Record<string, unknown> = {}): Promise<any> {
|
|
220
|
+
const id = this.nextId++;
|
|
221
|
+
return new Promise((resolve, reject) => {
|
|
222
|
+
const timer = setTimeout(() => {
|
|
223
|
+
this.pending.delete(id);
|
|
224
|
+
reject(new Error(`CDP command '${method}' timed out after ${COMMAND_TIMEOUT_MS}ms.`));
|
|
225
|
+
}, COMMAND_TIMEOUT_MS);
|
|
226
|
+
this.pending.set(id, {
|
|
227
|
+
resolve: (result) => {
|
|
228
|
+
clearTimeout(timer);
|
|
229
|
+
resolve(result);
|
|
230
|
+
},
|
|
231
|
+
reject: (error) => {
|
|
232
|
+
clearTimeout(timer);
|
|
233
|
+
reject(error);
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
try {
|
|
237
|
+
this.ws.send(JSON.stringify({ id, method, params }));
|
|
238
|
+
} catch (error) {
|
|
239
|
+
clearTimeout(timer);
|
|
240
|
+
this.pending.delete(id);
|
|
241
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private handleMessage(raw: string): void {
|
|
247
|
+
let message: any;
|
|
248
|
+
try {
|
|
249
|
+
message = JSON.parse(raw);
|
|
250
|
+
} catch {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (typeof message.id === "number") {
|
|
255
|
+
const pending = this.pending.get(message.id);
|
|
256
|
+
if (!pending) return;
|
|
257
|
+
this.pending.delete(message.id);
|
|
258
|
+
if (message.error) {
|
|
259
|
+
pending.reject(new Error(`CDP error: ${message.error.message ?? "unknown"}`));
|
|
260
|
+
} else {
|
|
261
|
+
pending.resolve(message.result);
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
switch (message.method) {
|
|
267
|
+
case "Page.loadEventFired":
|
|
268
|
+
this.loadFired?.();
|
|
269
|
+
break;
|
|
270
|
+
case "Runtime.consoleAPICalled": {
|
|
271
|
+
const args = Array.isArray(message.params?.args) ? message.params.args : [];
|
|
272
|
+
const text = args
|
|
273
|
+
.map((arg: any) => (arg?.value !== undefined ? String(arg.value) : (arg?.description ?? "")))
|
|
274
|
+
.filter(Boolean)
|
|
275
|
+
.join(" ");
|
|
276
|
+
this.pushConsole({ level: String(message.params?.type ?? "log"), text });
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
case "Runtime.exceptionThrown": {
|
|
280
|
+
const details = message.params?.exceptionDetails;
|
|
281
|
+
const text = details?.exception?.description ?? details?.text ?? "Uncaught exception";
|
|
282
|
+
this.pushConsole({ level: "exception", text: String(text) });
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private pushConsole(entry: CdpConsoleEntry): void {
|
|
289
|
+
if (!entry.text) return;
|
|
290
|
+
this.consoleBuffer.push(entry);
|
|
291
|
+
if (this.consoleBuffer.length > CONSOLE_BUFFER_LIMIT) {
|
|
292
|
+
this.consoleBuffer.shift();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private rejectAllPending(error: Error): void {
|
|
297
|
+
for (const pending of this.pending.values()) {
|
|
298
|
+
pending.reject(error);
|
|
299
|
+
}
|
|
300
|
+
this.pending.clear();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const connectedTabs = new Map<string, CdpTab>();
|
|
305
|
+
const connectingTabs = new Map<string, Promise<CdpTab>>();
|
|
306
|
+
let lastConnectFailureAt = 0;
|
|
307
|
+
|
|
308
|
+
/** Close session-owned CDP state without affecting the browser process. */
|
|
309
|
+
export function disconnectCdp(): void {
|
|
310
|
+
for (const tab of connectedTabs.values()) tab.close();
|
|
311
|
+
connectedTabs.clear();
|
|
312
|
+
connectingTabs.clear();
|
|
313
|
+
lastConnectFailureAt = 0;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function cdpEnabled(): boolean {
|
|
317
|
+
const rawPort = process.env.PI_COMPUTER_USE_CDP_PORT ?? "";
|
|
318
|
+
if (!/^\d+$/.test(rawPort)) return false;
|
|
319
|
+
const port = Number(rawPort);
|
|
320
|
+
return Number.isInteger(port) && port > 0 && port <= 65535 && typeof WebSocket !== "undefined";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Returns a CDP connection to the tab matching the controlled window's title
|
|
325
|
+
* (and, when provided, the window's screen frame), or undefined when CDP is
|
|
326
|
+
* disabled, unreachable, or no tab matches. Reuses the cached connection
|
|
327
|
+
* while it still matches; failures are cached briefly so an unreachable
|
|
328
|
+
* endpoint never adds per-call latency.
|
|
329
|
+
*/
|
|
330
|
+
export async function cdpTabForWindow(windowTitle: string, frame?: WindowFrame): Promise<CdpTab | undefined> {
|
|
331
|
+
if (!cdpEnabled()) return undefined;
|
|
332
|
+
if (Date.now() - lastConnectFailureAt < CONNECT_FAILURE_RETRY_MS) return undefined;
|
|
333
|
+
|
|
334
|
+
for (const tab of connectedTabs.values()) {
|
|
335
|
+
if (tab.isOpen && titlesMatch(tab.title, windowTitle) && (await tabMatchesFrame(tab, frame))) return tab;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
const pages = await cdpPages();
|
|
340
|
+
const match = await pickTab(pages, windowTitle, frame);
|
|
341
|
+
if (!match) return undefined;
|
|
342
|
+
|
|
343
|
+
const existing = connectedTabs.get(match.id);
|
|
344
|
+
if (existing?.isOpen) {
|
|
345
|
+
existing.title = match.title;
|
|
346
|
+
return existing;
|
|
347
|
+
}
|
|
348
|
+
let connecting = connectingTabs.get(match.id);
|
|
349
|
+
if (!connecting) {
|
|
350
|
+
connecting = CdpTab.connect(match.webSocketDebuggerUrl!, match.id, match.title);
|
|
351
|
+
connectingTabs.set(match.id, connecting);
|
|
352
|
+
}
|
|
353
|
+
let connected: CdpTab;
|
|
354
|
+
try {
|
|
355
|
+
connected = await connecting;
|
|
356
|
+
} finally {
|
|
357
|
+
connectingTabs.delete(match.id);
|
|
358
|
+
}
|
|
359
|
+
connectedTabs.set(match.id, connected);
|
|
360
|
+
return connected;
|
|
361
|
+
} catch {
|
|
362
|
+
lastConnectFailureAt = Date.now();
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
interface CdpPageTarget {
|
|
368
|
+
id: string;
|
|
369
|
+
type: string;
|
|
370
|
+
title: string;
|
|
371
|
+
url?: string;
|
|
372
|
+
webSocketDebuggerUrl?: string;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function listCdpPageContexts(): Promise<CdpPageContext[]> {
|
|
376
|
+
const pages = await cdpPages();
|
|
377
|
+
return pages.map((page) => ({
|
|
378
|
+
contextId: cdpContextId(page.id),
|
|
379
|
+
targetId: page.id,
|
|
380
|
+
title: page.title,
|
|
381
|
+
url: page.url ?? "",
|
|
382
|
+
}));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export async function cdpClickForContext(contextId: string, backendNodeId: number): Promise<boolean> {
|
|
386
|
+
return (await withCdpContextTab(contextId, async (tab) => {
|
|
387
|
+
await tab.clickBackendNode(backendNodeId);
|
|
388
|
+
return true;
|
|
389
|
+
})) === true;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export async function cdpTypeForContext(contextId: string, backendNodeId: number, text: string, replace: boolean): Promise<boolean> {
|
|
393
|
+
return (await withCdpContextTab(contextId, async (tab) => {
|
|
394
|
+
await tab.typeIntoBackendNode(backendNodeId, text, replace);
|
|
395
|
+
return true;
|
|
396
|
+
})) === true;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function cdpScrollForContext(contextId: string, deltaX: number, deltaY: number, backendNodeId?: number): Promise<boolean> {
|
|
400
|
+
return (await withCdpContextTab(contextId, async (tab) => {
|
|
401
|
+
await tab.scrollBy(deltaX, deltaY, backendNodeId);
|
|
402
|
+
return true;
|
|
403
|
+
})) === true;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export async function cdpTypeFocusedForContext(contextId: string, text: string): Promise<boolean> {
|
|
407
|
+
return (await withCdpContextTab(contextId, async (tab) => { await tab.typeIntoFocused(text); return true; })) === true;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export async function cdpKeypressForContext(contextId: string, keys: string[]): Promise<boolean> {
|
|
411
|
+
return (await withCdpContextTab(contextId, async (tab) => { await tab.keypress(keys); return true; })) === true;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export async function cdpMouseForContext(contextId: string, x: number, y: number, type: "mouseMoved" | "mousePressed" | "mouseReleased", button: "left" | "right" | "middle" = "left", clickCount = 1): Promise<boolean> {
|
|
415
|
+
return (await withCdpContextTab(contextId, async (tab) => { await tab.mouseAt(x, y, type, button, clickCount); return true; })) === true;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export async function cdpDragForContext(contextId: string, path: Array<{ x: number; y: number }>): Promise<boolean> {
|
|
419
|
+
return (await withCdpContextTab(contextId, async (tab) => { await tab.dragPath(path); return true; })) === true;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export async function cdpNavigateContext(contextId: string, url: string): Promise<boolean> {
|
|
423
|
+
return (await withCdpContextTab(contextId, async (tab) => {
|
|
424
|
+
await tab.navigate(url);
|
|
425
|
+
return true;
|
|
426
|
+
})) === true;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export async function cdpEvaluateForContext(contextId: string, expression: string): Promise<CdpEvaluationResult | undefined> {
|
|
430
|
+
const page = await cdpPageForContext(contextId);
|
|
431
|
+
if (!page?.webSocketDebuggerUrl) return undefined;
|
|
432
|
+
const tab = await CdpTab.connect(page.webSocketDebuggerUrl, page.id, page.title);
|
|
433
|
+
try {
|
|
434
|
+
return { contextId, value: await tab.evaluate(expression) };
|
|
435
|
+
} finally {
|
|
436
|
+
tab.close();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export async function cdpSnapshotForContext(contextId: string): Promise<CdpPageSnapshot | undefined> {
|
|
441
|
+
const page = await cdpPageForContext(contextId);
|
|
442
|
+
if (!page?.webSocketDebuggerUrl) return undefined;
|
|
443
|
+
|
|
444
|
+
const tab = await CdpTab.connect(page.webSocketDebuggerUrl, page.id, page.title);
|
|
445
|
+
try {
|
|
446
|
+
const [textValue, nodes] = await Promise.all([
|
|
447
|
+
tab.evaluate("document.body ? document.body.innerText : ''").catch(() => ""),
|
|
448
|
+
tab.accessibilityTree().catch(() => []),
|
|
449
|
+
]);
|
|
450
|
+
const snapshotId = randomUUID();
|
|
451
|
+
const { targets, outline } = cdpSnapshotOutline(snapshotId, nodes);
|
|
452
|
+
return {
|
|
453
|
+
contextId,
|
|
454
|
+
snapshotId,
|
|
455
|
+
targetId: page.id,
|
|
456
|
+
title: page.title,
|
|
457
|
+
url: page.url ?? "",
|
|
458
|
+
capturedAt: Date.now(),
|
|
459
|
+
text: typeof textValue === "string" ? textValue : String(textValue ?? ""),
|
|
460
|
+
targets,
|
|
461
|
+
outline,
|
|
462
|
+
diagnostics: { cdp: "connected", targetCount: targets.length },
|
|
463
|
+
};
|
|
464
|
+
} finally {
|
|
465
|
+
tab.close();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function withCdpContextTab<T>(contextId: string, run: (tab: CdpTab) => Promise<T>): Promise<T | undefined> {
|
|
470
|
+
const page = await cdpPageForContext(contextId);
|
|
471
|
+
if (!page?.webSocketDebuggerUrl) return undefined;
|
|
472
|
+
const tab = await CdpTab.connect(page.webSocketDebuggerUrl, page.id, page.title);
|
|
473
|
+
try {
|
|
474
|
+
return await run(tab);
|
|
475
|
+
} finally {
|
|
476
|
+
tab.close();
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function cdpPageForContext(contextId: string): Promise<CdpPageTarget | undefined> {
|
|
481
|
+
if (!contextId.startsWith(CDP_CONTEXT_PREFIX)) return undefined;
|
|
482
|
+
const targetId = contextId.slice(CDP_CONTEXT_PREFIX.length);
|
|
483
|
+
const pages = await cdpPages();
|
|
484
|
+
return pages.find((candidate) => candidate.id === targetId);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function cdpPages(): Promise<CdpPageTarget[]> {
|
|
488
|
+
if (!cdpEnabled()) return [];
|
|
489
|
+
const port = process.env.PI_COMPUTER_USE_CDP_PORT;
|
|
490
|
+
const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(2_000) });
|
|
491
|
+
const targets = (await response.json()) as CdpPageTarget[];
|
|
492
|
+
return targets.filter((target) =>
|
|
493
|
+
target.type === "page" && target.webSocketDebuggerUrl && isLocalDebuggerWebSocket(target.webSocketDebuggerUrl, port),
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function cdpContextId(targetId: string): string {
|
|
498
|
+
return `${CDP_CONTEXT_PREFIX}${targetId}`;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function axString(raw: any): string {
|
|
502
|
+
const value = raw?.value ?? raw;
|
|
503
|
+
return typeof value === "string" ? value.trim() : "";
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function cdpSnapshotOutline(snapshotId: string, nodes: unknown[]): { targets: CdpSnapshotTarget[]; outline: SerializedOutline } {
|
|
507
|
+
const records = new Map<string, any>();
|
|
508
|
+
for (const raw of nodes as any[]) {
|
|
509
|
+
const nodeId = String(raw?.nodeId ?? "");
|
|
510
|
+
if (nodeId) records.set(nodeId, raw);
|
|
511
|
+
}
|
|
512
|
+
const targets: CdpSnapshotTarget[] = [];
|
|
513
|
+
const build = (raw: any, seen: Set<string>): any => {
|
|
514
|
+
const nodeId = String(raw?.nodeId ?? randomUUID());
|
|
515
|
+
if (seen.has(nodeId)) return undefined;
|
|
516
|
+
seen.add(nodeId);
|
|
517
|
+
const role = axString(raw?.role);
|
|
518
|
+
const name = axString(raw?.name);
|
|
519
|
+
const actions = browserActionsForAxRole(role);
|
|
520
|
+
const backendNodeId = Number.isFinite(raw?.backendDOMNodeId) ? Math.trunc(raw.backendDOMNodeId) : undefined;
|
|
521
|
+
const wireRef = `cdp:${nodeId}`;
|
|
522
|
+
if (actions.length > 0 && name && (!actions.includes("click") || backendNodeId)) {
|
|
523
|
+
targets.push({ ref: wireRef, source: "browser_ax", role, name, value: axString(raw?.value) || undefined, actions, backendNodeId });
|
|
524
|
+
}
|
|
525
|
+
const childIds: string[] = Array.isArray(raw?.childIds) ? raw.childIds.map(String) : [];
|
|
526
|
+
return {
|
|
527
|
+
ref: wireRef,
|
|
528
|
+
role,
|
|
529
|
+
subrole: "",
|
|
530
|
+
identifier: "",
|
|
531
|
+
title: name,
|
|
532
|
+
description: axString(raw?.description),
|
|
533
|
+
value: axString(raw?.value),
|
|
534
|
+
actions,
|
|
535
|
+
canPress: actions.includes("click"),
|
|
536
|
+
canFocus: actions.length > 0,
|
|
537
|
+
canSetValue: actions.includes("set_text"),
|
|
538
|
+
canScroll: false,
|
|
539
|
+
canIncrement: false,
|
|
540
|
+
canDecrement: false,
|
|
541
|
+
isTextInput: actions.includes("set_text"),
|
|
542
|
+
rect: { x: 0, y: 0, w: 0, h: 0 },
|
|
543
|
+
children: childIds.map((id: string) => records.get(id)).filter(Boolean).map((child: any) => build(child, seen)).filter(Boolean),
|
|
544
|
+
};
|
|
545
|
+
};
|
|
546
|
+
const roots = (nodes as any[]).filter((raw) => !raw?.parentId || !records.has(String(raw.parentId)));
|
|
547
|
+
const children = roots.map((root) => build(root, new Set())).filter(Boolean);
|
|
548
|
+
const rawOutline = children.length === 1 ? children[0] : {
|
|
549
|
+
ref: `cdp:root:${snapshotId}`,
|
|
550
|
+
role: "document",
|
|
551
|
+
subrole: "",
|
|
552
|
+
identifier: "",
|
|
553
|
+
title: "Browser page",
|
|
554
|
+
description: "",
|
|
555
|
+
value: "",
|
|
556
|
+
actions: [],
|
|
557
|
+
canPress: false,
|
|
558
|
+
canFocus: false,
|
|
559
|
+
canSetValue: false,
|
|
560
|
+
canScroll: false,
|
|
561
|
+
canIncrement: false,
|
|
562
|
+
canDecrement: false,
|
|
563
|
+
isTextInput: false,
|
|
564
|
+
rect: { x: 0, y: 0, w: 0, h: 0 },
|
|
565
|
+
children,
|
|
566
|
+
};
|
|
567
|
+
const parsed = parseLookResponse({
|
|
568
|
+
lookId: snapshotId,
|
|
569
|
+
capturedAt: Date.now() / 1000,
|
|
570
|
+
window: { windowId: 0, framePoints: { x: 0, y: 0, w: 1, h: 1 }, scaleFactor: 1, isModal: false, role: "document", subrole: "" },
|
|
571
|
+
outline: rawOutline,
|
|
572
|
+
timings: {},
|
|
573
|
+
}).parsedOutline!;
|
|
574
|
+
const modelRefByWire = parsed.wireRefToRef;
|
|
575
|
+
for (const target of targets) target.ref = modelRefByWire.get(target.ref) ?? target.ref;
|
|
576
|
+
return { targets, outline: serializeOutline(parsed) };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function browserActionsForAxRole(role: string): string[] {
|
|
580
|
+
const normalized = role.toLowerCase();
|
|
581
|
+
if (["button", "link", "checkbox", "radio", "menuitem", "tab"].includes(normalized)) return ["click"];
|
|
582
|
+
if (["textbox", "searchbox", "combobox"].includes(normalized)) return ["click", "set_text"];
|
|
583
|
+
if (["listbox", "slider", "spinbutton"].includes(normalized)) return ["click"];
|
|
584
|
+
return [];
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function isLocalDebuggerWebSocket(wsUrl: string, expectedPort: string | undefined): boolean {
|
|
588
|
+
try {
|
|
589
|
+
const parsed = new URL(wsUrl);
|
|
590
|
+
const localHosts = new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
591
|
+
return (parsed.protocol === "ws:" || parsed.protocol === "wss:") && localHosts.has(parsed.hostname) && parsed.port === expectedPort;
|
|
592
|
+
} catch {
|
|
593
|
+
return false;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Picks the tab for a window title. Disambiguation order, applied only while
|
|
599
|
+
* more than one candidate remains:
|
|
600
|
+
* 1. exact title matches beat prefix matches;
|
|
601
|
+
* 2. the tab whose browser window frame matches the controlled window
|
|
602
|
+
* (separates same-titled tabs in different windows);
|
|
603
|
+
* 3. the visible tab (separates same-titled tabs in one window — the
|
|
604
|
+
* active tab is "visible", background tabs are "hidden").
|
|
605
|
+
* /json/list ordering is never trusted; it is an undocumented MRU detail.
|
|
606
|
+
*/
|
|
607
|
+
async function pickTab(pages: CdpPageTarget[], windowTitle: string, frame?: WindowFrame): Promise<CdpPageTarget | undefined> {
|
|
608
|
+
const matches = pages.filter((target) => titlesMatch(target.title, windowTitle));
|
|
609
|
+
if (matches.length === 0) return pages.length === 1 ? pages[0] : undefined;
|
|
610
|
+
if (matches.length === 1) return matches[0];
|
|
611
|
+
|
|
612
|
+
const wanted = windowTitle.trim().toLowerCase();
|
|
613
|
+
const exact = matches.filter((target) => target.title.trim().toLowerCase() === wanted);
|
|
614
|
+
const pool = exact.length > 0 ? exact : matches;
|
|
615
|
+
if (pool.length === 1) return pool[0];
|
|
616
|
+
|
|
617
|
+
let visibleFallback: CdpPageTarget | undefined;
|
|
618
|
+
for (const candidate of pool) {
|
|
619
|
+
try {
|
|
620
|
+
const tab = await CdpTab.connect(candidate.webSocketDebuggerUrl!, candidate.id, candidate.title);
|
|
621
|
+
const inFrame = await tabMatchesFrame(tab, frame, false);
|
|
622
|
+
const visibility = await tab.evaluate("document.visibilityState").catch(() => undefined);
|
|
623
|
+
tab.close();
|
|
624
|
+
if (frame && inFrame && visibility === "visible") return candidate;
|
|
625
|
+
if (frame && inFrame && !visibleFallback) visibleFallback = candidate;
|
|
626
|
+
if (!frame && visibility === "visible") return candidate;
|
|
627
|
+
} catch {
|
|
628
|
+
// candidate unreachable; try the next one
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return visibleFallback ?? pool[0];
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Whether the tab's browser window frame matches the AX window frame.
|
|
636
|
+
* `trustOnUnknown` controls the answer when bounds cannot be read: cache
|
|
637
|
+
* verification trusts the existing connection, candidate selection does not.
|
|
638
|
+
*/
|
|
639
|
+
async function tabMatchesFrame(tab: CdpTab, frame: WindowFrame | undefined, trustOnUnknown = true): Promise<boolean> {
|
|
640
|
+
if (!frame) return true;
|
|
641
|
+
const bounds = await tab.windowBounds().catch(() => undefined);
|
|
642
|
+
if (!bounds) return trustOnUnknown;
|
|
643
|
+
const tolerance = 50;
|
|
644
|
+
return (
|
|
645
|
+
Math.abs(bounds.x + bounds.w / 2 - (frame.x + frame.w / 2)) <= tolerance &&
|
|
646
|
+
Math.abs(bounds.y + bounds.h / 2 - (frame.y + frame.h / 2)) <= tolerance
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// The AX window title for a Chrome-family browser is usually the active tab
|
|
651
|
+
// title, sometimes suffixed (" - Google Chrome", profile name), so compare
|
|
652
|
+
// by prefix in both directions.
|
|
653
|
+
function titlesMatch(tabTitle: string, windowTitle: string): boolean {
|
|
654
|
+
const tab = tabTitle.trim().toLowerCase();
|
|
655
|
+
const win = windowTitle.trim().toLowerCase();
|
|
656
|
+
if (!tab || !win) return false;
|
|
657
|
+
return tab === win || win.startsWith(tab) || tab.startsWith(win);
|
|
658
|
+
}
|