@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,142 @@
|
|
|
1
|
+
import { parseLookResponse, type LookResponse } from "../../outline.ts";
|
|
2
|
+
import { toBoolean, toFiniteNumber, toOptionalString } from "../coerce.ts";
|
|
3
|
+
import type { ComputerUsePlatformBackend, FramePoints, HelperActResult, PlatformActRequest, PlatformApp, PlatformFocusWindowResult, PlatformFrontmostResult, PlatformObserveRequest, PlatformReadTextRequest, PlatformReadTextResponse, PlatformReadyState, PlatformRoot, PlatformRootKind, PlatformRootQuery, PlatformTarget, PlatformWaitForRequest, PlatformWaitForResponse } from "../types.ts";
|
|
4
|
+
import { WINDOWS_HELPER_PROTOCOL_VERSION, windowsHelper } from "./helper.ts";
|
|
5
|
+
import { assertPlatformArchitecture } from "../architecture.ts";
|
|
6
|
+
|
|
7
|
+
function normalizedProcessName(appName: string): string {
|
|
8
|
+
return appName.toLowerCase().replace(/\.exe$/i, "");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function classifyBrowser(appName: string): false | "chrome" | "edge" | "brave" | "firefox" | "vivaldi" | "opera" {
|
|
12
|
+
switch (normalizedProcessName(appName)) {
|
|
13
|
+
case "chrome":
|
|
14
|
+
case "chromium":
|
|
15
|
+
return "chrome";
|
|
16
|
+
case "msedge":
|
|
17
|
+
case "edge":
|
|
18
|
+
return "edge";
|
|
19
|
+
case "brave":
|
|
20
|
+
case "brave-browser":
|
|
21
|
+
return "brave";
|
|
22
|
+
case "firefox":
|
|
23
|
+
return "firefox";
|
|
24
|
+
case "vivaldi":
|
|
25
|
+
return "vivaldi";
|
|
26
|
+
case "opera":
|
|
27
|
+
case "opera_gx":
|
|
28
|
+
return "opera";
|
|
29
|
+
default:
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseFramePoints(raw: unknown): FramePoints {
|
|
35
|
+
const frame = (raw as any)?.framePoints ?? (raw as any)?.bounds ?? {};
|
|
36
|
+
return {
|
|
37
|
+
x: toFiniteNumber(frame.x, 0),
|
|
38
|
+
y: toFiniteNumber(frame.y, 0),
|
|
39
|
+
w: Math.max(1, toFiniteNumber(frame.w ?? frame.width, 1)),
|
|
40
|
+
h: Math.max(1, toFiniteNumber(frame.h ?? frame.height, 1)),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseRootKind(raw: unknown): PlatformRootKind {
|
|
45
|
+
return raw === "menu" || raw === "dialog" || raw === "popover" || raw === "window" ? raw : "window";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseRoots(result: unknown): PlatformRoot[] {
|
|
49
|
+
const array = Array.isArray(result) ? result : (result as any)?.roots;
|
|
50
|
+
if (!Array.isArray(array)) return [];
|
|
51
|
+
return array.map((raw: any, index): PlatformRoot => {
|
|
52
|
+
return {
|
|
53
|
+
kind: parseRootKind(raw?.kind),
|
|
54
|
+
rootRef: toOptionalString(raw?.rootRef ?? raw?.windowRef ?? raw?.ref),
|
|
55
|
+
windowRef: toOptionalString(raw?.windowRef ?? raw?.rootRef ?? raw?.ref),
|
|
56
|
+
windowId: Number.isFinite(raw?.windowId) ? Math.trunc(raw.windowId) : undefined,
|
|
57
|
+
pid: Number.isFinite(raw?.pid) ? Math.trunc(raw.pid) : undefined,
|
|
58
|
+
appName: toOptionalString(raw?.appName ?? raw?.processName),
|
|
59
|
+
bundleId: toOptionalString(raw?.bundleId),
|
|
60
|
+
title: toOptionalString(raw?.title) ?? "",
|
|
61
|
+
role: toOptionalString(raw?.role),
|
|
62
|
+
subrole: toOptionalString(raw?.subrole),
|
|
63
|
+
zOrder: Math.trunc(toFiniteNumber(raw?.zOrder, index)),
|
|
64
|
+
framePoints: parseFramePoints(raw),
|
|
65
|
+
scaleFactor: Math.max(1, toFiniteNumber(raw?.scaleFactor, 1)),
|
|
66
|
+
isOnscreen: raw?.isOnscreen === undefined ? true : toBoolean(raw?.isOnscreen),
|
|
67
|
+
isFocused: toBoolean(raw?.isFocused),
|
|
68
|
+
isMinimized: toBoolean(raw?.isMinimized),
|
|
69
|
+
isMain: toBoolean(raw?.isMain ?? raw?.isFocused),
|
|
70
|
+
isModal: toBoolean(raw?.isModal),
|
|
71
|
+
metadata: raw?.metadata,
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function appsFromRoots(roots: PlatformRoot[]): PlatformApp[] {
|
|
77
|
+
const seen = new Set<number>();
|
|
78
|
+
return roots.flatMap((root) => {
|
|
79
|
+
if (!root.pid || seen.has(root.pid)) return [];
|
|
80
|
+
seen.add(root.pid);
|
|
81
|
+
return [{ appName: root.appName ?? "Unknown", bundleId: root.bundleId, pid: root.pid, isFrontmost: root.isFocused }];
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function helperAction(request: PlatformActRequest): Record<string, unknown> {
|
|
86
|
+
if (!("focus" in request.target)) return { ...request };
|
|
87
|
+
return { ...request, target: request.target.focus, params: { ...request.params, preserveFocus: true } };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function ensureReady(_ctx: unknown, state: PlatformReadyState, signal?: AbortSignal): Promise<PlatformReadyState> {
|
|
91
|
+
await windowsHelper.ensureInstalled(signal);
|
|
92
|
+
const diagnostics = await windowsHelper.command<any>("diagnostics", {}, { signal, timeoutMs: 5_000 });
|
|
93
|
+
if (diagnostics?.protocolVersion !== WINDOWS_HELPER_PROTOCOL_VERSION) {
|
|
94
|
+
throw new Error(`Windows helper protocol mismatch: expected ${WINDOWS_HELPER_PROTOCOL_VERSION}, got ${diagnostics?.protocolVersion ?? "unknown"}. Restart Tangu to use the installed helper.`);
|
|
95
|
+
}
|
|
96
|
+
assertPlatformArchitecture("Windows", diagnostics);
|
|
97
|
+
return { ...state, lastPermissionCheckAt: Date.now(), helperDiagnostics: diagnostics };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const windowsBackend: ComputerUsePlatformBackend = {
|
|
101
|
+
name: "windows",
|
|
102
|
+
shutdown(): void { windowsHelper.dispose(); },
|
|
103
|
+
ensureReady,
|
|
104
|
+
async listApps(signal?: AbortSignal): Promise<PlatformApp[]> {
|
|
105
|
+
return appsFromRoots(parseRoots(await windowsHelper.command("listRoots", {}, { signal })));
|
|
106
|
+
},
|
|
107
|
+
async listRoots(query: PlatformRootQuery, signal?: AbortSignal): Promise<PlatformRoot[]> {
|
|
108
|
+
const roots = parseRoots(await windowsHelper.command("listRoots", Number.isFinite(query.pid) ? { pid: Math.trunc(query.pid!) } : {}, { signal }));
|
|
109
|
+
const title = query.title?.trim().toLowerCase();
|
|
110
|
+
return title ? roots.filter((root) => root.title.trim().toLowerCase().includes(title)) : roots;
|
|
111
|
+
},
|
|
112
|
+
async getFrontmost(signal?: AbortSignal): Promise<PlatformFrontmostResult> {
|
|
113
|
+
const roots = parseRoots(await windowsHelper.command("listRoots", {}, { signal }));
|
|
114
|
+
const focused = roots.find((root) => root.isFocused) ?? roots[0];
|
|
115
|
+
if (!focused?.pid) throw new Error("No frontmost window was available.");
|
|
116
|
+
return { appName: focused.appName ?? "Unknown", bundleId: focused.bundleId, pid: focused.pid, windowTitle: focused.title, windowId: focused.windowId, rootRef: focused.rootRef };
|
|
117
|
+
},
|
|
118
|
+
async focusWindow(target: PlatformTarget, signal?: AbortSignal): Promise<PlatformFocusWindowResult> {
|
|
119
|
+
return await windowsHelper.command<PlatformFocusWindowResult>("focusWindow", { ...target }, { signal });
|
|
120
|
+
},
|
|
121
|
+
async observe(request: PlatformObserveRequest, options?: { timeoutMs?: number; signal?: AbortSignal }): Promise<LookResponse> {
|
|
122
|
+
return parseLookResponse(await windowsHelper.command("look", { ...request.target, baseLookId: request.baseLookId, maxDimension: request.maxDimension, readText: request.readText, scopeRef: request.scopeRef, includeImage: request.includeImage }, options));
|
|
123
|
+
},
|
|
124
|
+
async act(request: PlatformActRequest, options?: { timeoutMs?: number; signal?: AbortSignal }): Promise<HelperActResult> {
|
|
125
|
+
return await windowsHelper.command<HelperActResult>("act", helperAction(request), options);
|
|
126
|
+
},
|
|
127
|
+
async actBatch(requests: PlatformActRequest[], options?: { timeoutMs?: number; signal?: AbortSignal }): Promise<HelperActResult> {
|
|
128
|
+
return await windowsHelper.command<HelperActResult>("actBatch", { actions: requests.map(helperAction) }, options);
|
|
129
|
+
},
|
|
130
|
+
async readText(args: PlatformReadTextRequest, options?: { timeoutMs?: number; signal?: AbortSignal }): Promise<PlatformReadTextResponse> {
|
|
131
|
+
return await windowsHelper.command("uiaReadText", { ...args }, options);
|
|
132
|
+
},
|
|
133
|
+
async waitFor(args: PlatformWaitForRequest, options?: { timeoutMs?: number; signal?: AbortSignal }): Promise<PlatformWaitForResponse> {
|
|
134
|
+
return await windowsHelper.command("uiaWaitFor", { ...args }, options);
|
|
135
|
+
},
|
|
136
|
+
isBrowserApp(appName: string): boolean { return classifyBrowser(appName) !== false; },
|
|
137
|
+
isChromeFamilyApp(appName: string): boolean { return classifyBrowser(appName) === "chrome" || classifyBrowser(appName) === "edge" || classifyBrowser(appName) === "brave"; },
|
|
138
|
+
async openBrowserLocation(target: { appName: string; bundleId?: string }, url: string, signal?: AbortSignal): Promise<boolean> {
|
|
139
|
+
await windowsHelper.command("openBrowserLocation", { ...target, url }, { signal, timeoutMs: 10_000 });
|
|
140
|
+
return true;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { constants as fsConstants } from "node:fs";
|
|
4
|
+
import { access } from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
10
|
+
const SETUP_HELPER_SCRIPT = path.join(PACKAGE_ROOT, "scripts", "setup-helper.mjs");
|
|
11
|
+
const HELPER_SETUP_TIMEOUT_MS = 60_000;
|
|
12
|
+
const COMMAND_TIMEOUT_MS = 15_000;
|
|
13
|
+
|
|
14
|
+
export const WINDOWS_HELPER_PROTOCOL_VERSION = 4;
|
|
15
|
+
export const WINDOWS_HELPER_PATH = process.env.PI_COMPUTER_USE_WINDOWS_HELPER_PATH || path.join(os.homedir(), ".pi", "agent", "helpers", "tangu-computer-use", "windows-bridge.exe");
|
|
16
|
+
|
|
17
|
+
interface Pending<T> {
|
|
18
|
+
resolve(value: T): void;
|
|
19
|
+
reject(error: Error): void;
|
|
20
|
+
timer: NodeJS.Timeout;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function isExecutable(filePath: string): Promise<boolean> {
|
|
24
|
+
try { await access(filePath, fsConstants.X_OK); return true; } catch { return false; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function runProcess(command: string, args: string[], timeoutMs: number, signal?: AbortSignal, env?: NodeJS.ProcessEnv): Promise<void> {
|
|
28
|
+
if (signal?.aborted) throw new Error("Operation aborted.");
|
|
29
|
+
await new Promise<void>((resolve, reject) => {
|
|
30
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], env });
|
|
31
|
+
let stdout = "";
|
|
32
|
+
let stderr = "";
|
|
33
|
+
const cleanup = () => { clearTimeout(timer); signal?.removeEventListener("abort", onAbort); };
|
|
34
|
+
const timer = setTimeout(() => { child.kill("SIGTERM"); cleanup(); reject(new Error(`Command timed out after ${timeoutMs}ms: ${command} ${args.join(" ")}`)); }, timeoutMs);
|
|
35
|
+
const onAbort = () => { child.kill("SIGTERM"); cleanup(); reject(new Error("Operation aborted.")); };
|
|
36
|
+
child.stdout.on("data", (chunk) => { stdout += String(chunk); });
|
|
37
|
+
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
|
38
|
+
child.on("error", (error) => { cleanup(); reject(error); });
|
|
39
|
+
child.on("close", (code) => {
|
|
40
|
+
cleanup();
|
|
41
|
+
if (code === 0) return resolve();
|
|
42
|
+
const output = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
|
|
43
|
+
reject(new Error(`Command failed (${code}): ${command} ${args.join(" ")}\n${output}`.trim()));
|
|
44
|
+
});
|
|
45
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class WindowsHelperClient {
|
|
50
|
+
private installChecked = false;
|
|
51
|
+
private child?: ChildProcessWithoutNullStreams;
|
|
52
|
+
private buffer = "";
|
|
53
|
+
private pending = new Map<string, Pending<unknown>>();
|
|
54
|
+
|
|
55
|
+
dispose(): void {
|
|
56
|
+
const error = new Error("Windows helper closed because the Tangu session ended.");
|
|
57
|
+
for (const pending of this.pending.values()) {
|
|
58
|
+
clearTimeout(pending.timer);
|
|
59
|
+
pending.reject(error);
|
|
60
|
+
}
|
|
61
|
+
this.pending.clear();
|
|
62
|
+
this.buffer = "";
|
|
63
|
+
|
|
64
|
+
const child = this.child;
|
|
65
|
+
this.child = undefined;
|
|
66
|
+
if (!child) return;
|
|
67
|
+
child.stdin.destroy();
|
|
68
|
+
child.stdout.destroy();
|
|
69
|
+
child.stderr.destroy();
|
|
70
|
+
child.kill("SIGTERM");
|
|
71
|
+
child.unref();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async ensureInstalled(signal?: AbortSignal): Promise<void> {
|
|
75
|
+
if ((await isExecutable(WINDOWS_HELPER_PATH)) && this.installChecked) return;
|
|
76
|
+
// Re-enter Electron and Bun standalone hosts as their JavaScript runtimes.
|
|
77
|
+
await runProcess(process.execPath, [SETUP_HELPER_SCRIPT, "--platform", "windows", "--runtime"], HELPER_SETUP_TIMEOUT_MS, signal, { ...process.env, ELECTRON_RUN_AS_NODE: "1", BUN_BE_BUN: "1" });
|
|
78
|
+
this.installChecked = true;
|
|
79
|
+
if (!(await isExecutable(WINDOWS_HELPER_PATH))) throw new Error(`Failed to install Windows helper at ${WINDOWS_HELPER_PATH}.`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private async process(signal?: AbortSignal): Promise<ChildProcessWithoutNullStreams> {
|
|
83
|
+
await this.ensureInstalled(signal);
|
|
84
|
+
if (this.child && this.child.exitCode === null && !this.child.killed) return this.child;
|
|
85
|
+
const child = spawn(WINDOWS_HELPER_PATH, [], { stdio: ["pipe", "pipe", "pipe"] });
|
|
86
|
+
child.stdout.setEncoding("utf8");
|
|
87
|
+
child.stderr.setEncoding("utf8");
|
|
88
|
+
child.stdin.setDefaultEncoding("utf8");
|
|
89
|
+
child.stdout.on("data", (chunk: string) => this.onStdout(chunk));
|
|
90
|
+
child.on("exit", () => { if (this.child === child) this.child = undefined; });
|
|
91
|
+
child.on("error", () => { if (this.child === child) this.child = undefined; });
|
|
92
|
+
this.child = child;
|
|
93
|
+
this.buffer = "";
|
|
94
|
+
return child;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private onStdout(chunk: string): void {
|
|
98
|
+
this.buffer += chunk;
|
|
99
|
+
for (;;) {
|
|
100
|
+
const newline = this.buffer.indexOf("\n");
|
|
101
|
+
if (newline < 0) return;
|
|
102
|
+
const line = this.buffer.slice(0, newline).trim();
|
|
103
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
104
|
+
if (!line) continue;
|
|
105
|
+
let parsed: any;
|
|
106
|
+
try { parsed = JSON.parse(line); } catch { continue; }
|
|
107
|
+
const pending = this.pending.get(parsed.id);
|
|
108
|
+
if (!pending) continue;
|
|
109
|
+
this.pending.delete(parsed.id);
|
|
110
|
+
clearTimeout(pending.timer);
|
|
111
|
+
if (parsed.protocolVersion !== WINDOWS_HELPER_PROTOCOL_VERSION) {
|
|
112
|
+
pending.reject(new Error(`Windows helper protocol mismatch: expected ${WINDOWS_HELPER_PROTOCOL_VERSION}, got ${parsed.protocolVersion ?? "unknown"}. Restart Tangu to use the installed helper.`));
|
|
113
|
+
} else if (parsed.ok === true) {
|
|
114
|
+
pending.resolve(parsed.result);
|
|
115
|
+
} else {
|
|
116
|
+
const error = new Error(parsed.error?.message ?? "Windows helper command failed.") as Error & { code?: string };
|
|
117
|
+
error.code = parsed.error?.code;
|
|
118
|
+
pending.reject(error);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async command<T>(cmd: string, args: Record<string, unknown> = {}, options?: { timeoutMs?: number; signal?: AbortSignal }): Promise<T> {
|
|
124
|
+
const child = await this.process(options?.signal);
|
|
125
|
+
const id = randomUUID();
|
|
126
|
+
const timeoutMs = options?.timeoutMs ?? COMMAND_TIMEOUT_MS;
|
|
127
|
+
return await new Promise<T>((resolve, reject) => {
|
|
128
|
+
const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`Helper command '${cmd}' timed out after ${timeoutMs}ms.`)); }, timeoutMs);
|
|
129
|
+
this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer });
|
|
130
|
+
child.stdin.write(`${JSON.stringify({ protocolVersion: WINDOWS_HELPER_PROTOCOL_VERSION, id, cmd, args })}\n`, (error) => {
|
|
131
|
+
if (!error) return;
|
|
132
|
+
this.pending.delete(id);
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
reject(error);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const windowsHelper = new WindowsHelperClient();
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PlatformRoot } from "./platform/types.ts";
|
|
2
|
+
|
|
3
|
+
type RankedRoot = Pick<PlatformRoot,
|
|
4
|
+
"windowId" | "windowRef" | "isModal" | "isFocused" | "isMain" | "isMinimized" | "isOnscreen" | "zOrder" | "title"
|
|
5
|
+
>;
|
|
6
|
+
|
|
7
|
+
export function scoreWindow(window: RankedRoot): number {
|
|
8
|
+
let score = 0;
|
|
9
|
+
if (window.isModal) score += 180;
|
|
10
|
+
if (window.isFocused) score += 100;
|
|
11
|
+
if (window.isMain) score += 80;
|
|
12
|
+
if (!window.isMinimized) score += 40;
|
|
13
|
+
if (window.isOnscreen) score += 20;
|
|
14
|
+
if (window.windowId && window.windowId > 0) score += 10;
|
|
15
|
+
if (window.title.trim().length > 0) score += 2;
|
|
16
|
+
return score;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function shouldPreferForegroundModalWindow(current: RankedRoot, candidate: RankedRoot): boolean {
|
|
20
|
+
if (candidate.windowId === current.windowId && candidate.windowRef === current.windowRef) return false;
|
|
21
|
+
if (!candidate.isOnscreen || candidate.isMinimized || !candidate.isModal) return false;
|
|
22
|
+
// AXDialog can describe a background main window, so modality alone is insufficient.
|
|
23
|
+
const candidateIsInFront = candidate.isFocused || candidate.zOrder < current.zOrder;
|
|
24
|
+
return candidateIsInFront && scoreWindow(candidate) >= scoreWindow(current);
|
|
25
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export interface StoredState<T> {
|
|
4
|
+
stateId: string;
|
|
5
|
+
resourceKey: string;
|
|
6
|
+
epoch: number;
|
|
7
|
+
value: T;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class StaleResourceStateError extends Error {
|
|
11
|
+
constructor(readonly resourceKey: string, readonly expectedEpoch: number, readonly actualEpoch: number) {
|
|
12
|
+
super(`State is stale for ${resourceKey}: expected epoch ${expectedEpoch}, current epoch ${actualEpoch}.`);
|
|
13
|
+
this.name = "StaleResourceStateError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Bounded insertion-ordered store for immutable agent-facing observations. */
|
|
18
|
+
export class StateStore<T> {
|
|
19
|
+
private readonly records = new Map<string, StoredState<T>>();
|
|
20
|
+
|
|
21
|
+
constructor(private readonly limit = 128) {}
|
|
22
|
+
|
|
23
|
+
create(resourceKey: string, epoch: number, value: T): StoredState<T> {
|
|
24
|
+
const record = { stateId: randomUUID(), resourceKey, epoch, value };
|
|
25
|
+
this.set(record);
|
|
26
|
+
return record;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
set(record: StoredState<T>): void {
|
|
30
|
+
this.records.delete(record.stateId);
|
|
31
|
+
this.records.set(record.stateId, record);
|
|
32
|
+
while (this.records.size > this.limit) {
|
|
33
|
+
const oldest = this.records.keys().next().value as string | undefined;
|
|
34
|
+
if (!oldest) break;
|
|
35
|
+
this.records.delete(oldest);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get(stateId: string): StoredState<T> | undefined {
|
|
40
|
+
return this.records.get(stateId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
clear(): void {
|
|
44
|
+
this.records.clear();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get size(): number {
|
|
48
|
+
return this.records.size;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface ResourceRecord {
|
|
53
|
+
epoch: number;
|
|
54
|
+
tail: Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Orders live operations per physical resource while allowing unrelated
|
|
59
|
+
* resources to overlap. Cached state queries bypass this scheduler entirely.
|
|
60
|
+
*/
|
|
61
|
+
export class ResourceScheduler {
|
|
62
|
+
private readonly resources = new Map<string, ResourceRecord>();
|
|
63
|
+
private closed = false;
|
|
64
|
+
|
|
65
|
+
epoch(resourceKey: string): number {
|
|
66
|
+
return this.resource(resourceKey).epoch;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
restoreEpoch(resourceKey: string, epoch: number): void {
|
|
70
|
+
const record = this.resource(resourceKey);
|
|
71
|
+
record.epoch = Math.max(record.epoch, Math.max(0, Math.trunc(epoch)));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async read<T>(resourceKey: string, work: (epoch: number) => Promise<T>): Promise<{ value: T; epoch: number }> {
|
|
75
|
+
return await this.enqueue(resourceKey, async (record) => ({ value: await work(record.epoch), epoch: record.epoch }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async readAt<T>(resourceKey: string, expectedEpoch: number, work: (epoch: number) => Promise<T>): Promise<{ value: T; epoch: number }> {
|
|
79
|
+
return await this.enqueue(resourceKey, async (record) => {
|
|
80
|
+
if (record.epoch !== expectedEpoch) throw new StaleResourceStateError(resourceKey, expectedEpoch, record.epoch);
|
|
81
|
+
return { value: await work(record.epoch), epoch: record.epoch };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async write<T>(resourceKey: string, baseEpoch: number, work: (nextEpoch: number) => Promise<T>): Promise<{ value: T; epoch: number }> {
|
|
86
|
+
return await this.enqueue(resourceKey, async (record) => {
|
|
87
|
+
if (record.epoch !== baseEpoch) throw new StaleResourceStateError(resourceKey, baseEpoch, record.epoch);
|
|
88
|
+
const nextEpoch = record.epoch + 1;
|
|
89
|
+
// Invalidate the base state before dispatch. If native execution becomes
|
|
90
|
+
// uncertain or throws after a partial effect, later writes still fail safe.
|
|
91
|
+
record.epoch = nextEpoch;
|
|
92
|
+
return { value: await work(nextEpoch), epoch: nextEpoch };
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async drain(): Promise<void> {
|
|
97
|
+
await Promise.all([...this.resources.values()].map((record) => record.tail.catch(() => undefined)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async close(): Promise<void> {
|
|
101
|
+
this.closed = true;
|
|
102
|
+
await this.drain();
|
|
103
|
+
this.resources.clear();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private resource(resourceKey: string): ResourceRecord {
|
|
107
|
+
let record = this.resources.get(resourceKey);
|
|
108
|
+
if (!record) {
|
|
109
|
+
record = { epoch: 0, tail: Promise.resolve() };
|
|
110
|
+
this.resources.set(resourceKey, record);
|
|
111
|
+
}
|
|
112
|
+
return record;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private async enqueue<T>(resourceKey: string, work: (record: ResourceRecord) => Promise<T>): Promise<T> {
|
|
116
|
+
if (this.closed) throw new Error("Computer-use session is shutting down.");
|
|
117
|
+
const record = this.resource(resourceKey);
|
|
118
|
+
const previous = record.tail;
|
|
119
|
+
let release!: () => void;
|
|
120
|
+
const next = new Promise<void>((resolve) => { release = resolve; });
|
|
121
|
+
record.tail = previous.catch(() => undefined).then(() => next);
|
|
122
|
+
await previous.catch(() => undefined);
|
|
123
|
+
try {
|
|
124
|
+
return await work(record);
|
|
125
|
+
} finally {
|
|
126
|
+
release();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import type { CdpPageSnapshot } from "./cdp.ts";
|
|
3
|
+
import type { ImageMode } from "./contract.ts";
|
|
4
|
+
import type { WindowNote } from "./note.ts";
|
|
5
|
+
import { restoreOutline, serializeOutline, type LookResponse, type Outline, type SerializedOutline } from "./outline.ts";
|
|
6
|
+
import { StateStore, type StoredState } from "./runtime.ts";
|
|
7
|
+
|
|
8
|
+
interface StateTargetSnapshot {
|
|
9
|
+
pid: number;
|
|
10
|
+
windowId: number;
|
|
11
|
+
windowRef?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CurrentTarget {
|
|
15
|
+
appName: string;
|
|
16
|
+
bundleId?: string;
|
|
17
|
+
pid: number;
|
|
18
|
+
windowTitle: string;
|
|
19
|
+
windowId: number;
|
|
20
|
+
windowRef?: string;
|
|
21
|
+
nativeWindowRef?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CurrentCapture {
|
|
25
|
+
stateId: string;
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
scaleFactor: number;
|
|
29
|
+
timestamp: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface OperationState {
|
|
33
|
+
currentTarget?: CurrentTarget;
|
|
34
|
+
currentCapture?: CurrentCapture;
|
|
35
|
+
currentStateTarget?: StateTargetSnapshot;
|
|
36
|
+
currentImageMode?: ImageMode;
|
|
37
|
+
currentLook?: LookResponse;
|
|
38
|
+
currentOutline?: Outline;
|
|
39
|
+
currentNote?: WindowNote;
|
|
40
|
+
resourceKey?: string;
|
|
41
|
+
epoch?: number;
|
|
42
|
+
lastSearchOcrEscalatedLookId?: string;
|
|
43
|
+
browserSnapshot?: CdpPageSnapshot;
|
|
44
|
+
contextId?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface DesktopObservation {
|
|
48
|
+
kind: "desktop";
|
|
49
|
+
target: CurrentTarget;
|
|
50
|
+
capture: CurrentCapture;
|
|
51
|
+
look: Omit<LookResponse, "parsedOutline" | "outline">;
|
|
52
|
+
outline: SerializedOutline;
|
|
53
|
+
note?: WindowNote;
|
|
54
|
+
imageMode?: ImageMode;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface BrowserObservation {
|
|
58
|
+
kind: "browser";
|
|
59
|
+
snapshot: CdpPageSnapshot;
|
|
60
|
+
outline: SerializedOutline;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type UiObservation = DesktopObservation | BrowserObservation;
|
|
64
|
+
|
|
65
|
+
export class SavedStates {
|
|
66
|
+
readonly store = new StateStore<UiObservation>(128);
|
|
67
|
+
readonly operations = new AsyncLocalStorage<OperationState>();
|
|
68
|
+
|
|
69
|
+
current(): OperationState {
|
|
70
|
+
const state = this.operations.getStore();
|
|
71
|
+
if (!state) throw new Error("Computer-use operation state is unavailable.");
|
|
72
|
+
return state;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get(stateId: string): StoredState<UiObservation> | undefined {
|
|
76
|
+
return this.store.get(stateId);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
set(record: StoredState<UiObservation>): void {
|
|
80
|
+
this.store.set(record);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
clear(): void {
|
|
84
|
+
this.store.clear();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
hydrate(record: StoredState<UiObservation> | undefined): OperationState {
|
|
88
|
+
if (!record) return {};
|
|
89
|
+
if (record.value.kind === "browser") {
|
|
90
|
+
const outline = restoreOutline(record.value.outline);
|
|
91
|
+
return {
|
|
92
|
+
currentCapture: { stateId: record.stateId, width: 0, height: 0, scaleFactor: 1, timestamp: record.value.snapshot.capturedAt },
|
|
93
|
+
currentLook: {
|
|
94
|
+
lookId: record.value.snapshot.snapshotId,
|
|
95
|
+
capturedAt: record.value.snapshot.capturedAt / 1000,
|
|
96
|
+
window: { windowId: 0, framePoints: { x: 0, y: 0, w: 1, h: 1 }, scaleFactor: 1, isModal: false, role: "document", subrole: "" },
|
|
97
|
+
outline: outline.root,
|
|
98
|
+
timings: {},
|
|
99
|
+
parsedOutline: outline,
|
|
100
|
+
},
|
|
101
|
+
currentOutline: outline,
|
|
102
|
+
resourceKey: record.resourceKey,
|
|
103
|
+
epoch: record.epoch,
|
|
104
|
+
browserSnapshot: record.value.snapshot,
|
|
105
|
+
contextId: record.value.snapshot.contextId,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const outline = restoreOutline(record.value.outline);
|
|
109
|
+
return {
|
|
110
|
+
currentTarget: { ...record.value.target },
|
|
111
|
+
currentCapture: { ...record.value.capture },
|
|
112
|
+
currentStateTarget: { pid: record.value.target.pid, windowId: record.value.target.windowId, windowRef: record.value.target.windowRef },
|
|
113
|
+
currentImageMode: record.value.imageMode,
|
|
114
|
+
currentLook: { ...record.value.look, outline: outline.root, parsedOutline: outline },
|
|
115
|
+
currentOutline: outline,
|
|
116
|
+
currentNote: record.value.note ? structuredClone(record.value.note) : undefined,
|
|
117
|
+
resourceKey: record.resourceKey,
|
|
118
|
+
epoch: record.epoch,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
saveDesktop(state: OperationState, resourceKey: string, epoch: number): void {
|
|
123
|
+
if (!state.currentTarget || !state.currentCapture || !state.currentLook || !state.currentOutline) return;
|
|
124
|
+
this.store.set({
|
|
125
|
+
stateId: state.currentCapture.stateId,
|
|
126
|
+
resourceKey,
|
|
127
|
+
epoch,
|
|
128
|
+
value: {
|
|
129
|
+
kind: "desktop",
|
|
130
|
+
target: { ...state.currentTarget },
|
|
131
|
+
capture: { ...state.currentCapture },
|
|
132
|
+
look: {
|
|
133
|
+
lookId: state.currentLook.lookId,
|
|
134
|
+
capturedAt: state.currentLook.capturedAt,
|
|
135
|
+
window: structuredClone(state.currentLook.window),
|
|
136
|
+
image: state.currentLook.image ? { ...state.currentLook.image } : undefined,
|
|
137
|
+
timings: { ...state.currentLook.timings },
|
|
138
|
+
readText: state.currentLook.readText ? { ...state.currentLook.readText } : undefined,
|
|
139
|
+
},
|
|
140
|
+
outline: serializeOutline(state.currentOutline),
|
|
141
|
+
note: state.currentNote ? structuredClone(state.currentNote) : undefined,
|
|
142
|
+
imageMode: state.currentImageMode,
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|