@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,291 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import { access, mkdir, realpath } from "node:fs/promises";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { toBoolean, toFiniteNumber, toOptionalString } from "../coerce.ts";
|
|
9
|
+
import type { PlatformDiagnostics } from "../types.ts";
|
|
10
|
+
import { resolveMacosHelperAppPath } from "./helper-path.mjs";
|
|
11
|
+
|
|
12
|
+
const COMMAND_TIMEOUT_MS = 15_000;
|
|
13
|
+
const HELPER_PROTOCOL_VERSION = 12;
|
|
14
|
+
const HELPER_SETUP_TIMEOUT_MS = 60_000;
|
|
15
|
+
|
|
16
|
+
export const HELPER_BUNDLE_ID = "com.forsion.tangu-computer-use";
|
|
17
|
+
export const HELPER_APP_PATH = resolveMacosHelperAppPath();
|
|
18
|
+
export const HELPER_APP_EXECUTABLE_PATH = path.join(HELPER_APP_PATH, "Contents", "MacOS", "bridge");
|
|
19
|
+
const DEFAULT_HELPER_SOCKET_PATH = path.join(os.homedir(), "Library", "Caches", "tangu-computer-use", "bridge.sock");
|
|
20
|
+
export const HELPER_SOCKET_PATH = process.env.PI_CU_SOCKET_PATH ?? DEFAULT_HELPER_SOCKET_PATH;
|
|
21
|
+
const usingExternalHelperSocket = HELPER_SOCKET_PATH !== DEFAULT_HELPER_SOCKET_PATH;
|
|
22
|
+
|
|
23
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
24
|
+
const SETUP_HELPER_SCRIPT = path.join(PACKAGE_ROOT, "scripts", "setup-helper.mjs");
|
|
25
|
+
|
|
26
|
+
export class HelperTransportError extends Error {
|
|
27
|
+
constructor(message: string) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "HelperTransportError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class HelperCommandError extends Error {
|
|
34
|
+
readonly code?: string;
|
|
35
|
+
|
|
36
|
+
constructor(message: string, code?: string) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "HelperCommandError";
|
|
39
|
+
this.code = code;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
44
|
+
if (signal?.aborted) throw new Error("Operation aborted.");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
48
|
+
throwIfAborted(signal);
|
|
49
|
+
await new Promise<void>((resolve, reject) => {
|
|
50
|
+
const timer = setTimeout(resolve, ms);
|
|
51
|
+
const onAbort = () => {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
reject(new Error("Operation aborted."));
|
|
54
|
+
};
|
|
55
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
56
|
+
}).finally(() => signal?.throwIfAborted?.());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function isExecutable(filePath: string): Promise<boolean> {
|
|
60
|
+
try {
|
|
61
|
+
await access(filePath, fsConstants.X_OK);
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function isResolvedHelperExecutable(filePath?: string): Promise<boolean> {
|
|
69
|
+
if (!filePath) return true;
|
|
70
|
+
const [actualPath, expectedPath] = await Promise.all([
|
|
71
|
+
realpath(filePath).catch(() => path.resolve(filePath)),
|
|
72
|
+
realpath(HELPER_APP_EXECUTABLE_PATH).catch(() => path.resolve(HELPER_APP_EXECUTABLE_PATH)),
|
|
73
|
+
]);
|
|
74
|
+
return actualPath === expectedPath;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function runProcess(
|
|
78
|
+
command: string,
|
|
79
|
+
args: string[],
|
|
80
|
+
timeoutMs: number,
|
|
81
|
+
signal?: AbortSignal,
|
|
82
|
+
env?: NodeJS.ProcessEnv,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
throwIfAborted(signal);
|
|
85
|
+
|
|
86
|
+
await new Promise<void>((resolve, reject) => {
|
|
87
|
+
const child = spawn(command, args, {
|
|
88
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
89
|
+
env,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
let stderr = "";
|
|
93
|
+
let stdout = "";
|
|
94
|
+
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
child.kill("SIGTERM");
|
|
97
|
+
cleanup();
|
|
98
|
+
reject(new Error(`Command timed out after ${timeoutMs}ms: ${command} ${args.join(" ")}`));
|
|
99
|
+
}, timeoutMs);
|
|
100
|
+
|
|
101
|
+
const onAbort = () => {
|
|
102
|
+
child.kill("SIGTERM");
|
|
103
|
+
cleanup();
|
|
104
|
+
reject(new Error("Operation aborted."));
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const cleanup = () => {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
signal?.removeEventListener("abort", onAbort);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
child.stdout.on("data", (chunk) => {
|
|
113
|
+
stdout += String(chunk);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
child.stderr.on("data", (chunk) => {
|
|
117
|
+
stderr += String(chunk);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
child.on("error", (error) => {
|
|
121
|
+
cleanup();
|
|
122
|
+
reject(error);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
child.on("close", (code) => {
|
|
126
|
+
cleanup();
|
|
127
|
+
if (code === 0) {
|
|
128
|
+
resolve();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const output = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
|
|
132
|
+
reject(new Error(`Command failed (${code}): ${command} ${args.join(" ")}\n${output}`.trim()));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export class MacosHelperClient {
|
|
140
|
+
private daemonAvailable = false;
|
|
141
|
+
private requestSequence = 0;
|
|
142
|
+
private diagnosticsCache?: PlatformDiagnostics;
|
|
143
|
+
|
|
144
|
+
get diagnostics(): PlatformDiagnostics | undefined {
|
|
145
|
+
return this.diagnosticsCache;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async ensureInstalled(signal?: AbortSignal): Promise<void> {
|
|
149
|
+
if (usingExternalHelperSocket) return;
|
|
150
|
+
// Installation is a deployment/repair operation, not part of every new
|
|
151
|
+
// agent process's hot path. Protocol compatibility is checked against the
|
|
152
|
+
// live daemon immediately afterwards.
|
|
153
|
+
if (await isExecutable(HELPER_APP_EXECUTABLE_PATH)) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Re-enter Electron and Bun standalone hosts as their JavaScript runtimes.
|
|
158
|
+
await runProcess(process.execPath, [SETUP_HELPER_SCRIPT, "--runtime"], HELPER_SETUP_TIMEOUT_MS, signal, {
|
|
159
|
+
...process.env,
|
|
160
|
+
ELECTRON_RUN_AS_NODE: "1",
|
|
161
|
+
BUN_BE_BUN: "1",
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (!(await isExecutable(HELPER_APP_EXECUTABLE_PATH))) {
|
|
165
|
+
throw new Error(`Failed to install tangu-computer-use helper app at ${HELPER_APP_PATH}.`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async launchDaemon(signal?: AbortSignal): Promise<void> {
|
|
170
|
+
if (usingExternalHelperSocket) throw new HelperTransportError(`External helper socket is unavailable at ${HELPER_SOCKET_PATH}.`);
|
|
171
|
+
await mkdir(path.dirname(HELPER_SOCKET_PATH), { recursive: true });
|
|
172
|
+
// Open the resolved bundle directly so a legacy system-wide copy with the
|
|
173
|
+
// same bundle id cannot win LaunchServices resolution.
|
|
174
|
+
await runProcess("open", ["-n", "-g", HELPER_APP_PATH, "--args", "serve", "--socket", HELPER_SOCKET_PATH], COMMAND_TIMEOUT_MS, signal);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async daemonCommand<T>(cmd: string, args: Record<string, unknown>, timeoutMs: number, signal?: AbortSignal): Promise<T> {
|
|
178
|
+
return await new Promise<T>((resolve, reject) => {
|
|
179
|
+
const id = `req_${++this.requestSequence}`;
|
|
180
|
+
const socket = net.createConnection(HELPER_SOCKET_PATH);
|
|
181
|
+
let buffer = "";
|
|
182
|
+
const timer = setTimeout(() => { socket.destroy(); reject(new HelperTransportError(`Daemon command '${cmd}' timed out after ${timeoutMs}ms.`)); }, timeoutMs);
|
|
183
|
+
const cleanup = () => { clearTimeout(timer); signal?.removeEventListener("abort", onAbort); };
|
|
184
|
+
const onAbort = () => { socket.destroy(); cleanup(); reject(new Error("Operation aborted.")); };
|
|
185
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
186
|
+
socket.setEncoding("utf8");
|
|
187
|
+
socket.on("connect", () => socket.write(`${JSON.stringify({ id, cmd, ...args })}\n`));
|
|
188
|
+
socket.on("data", (chunk) => {
|
|
189
|
+
buffer += chunk;
|
|
190
|
+
const newline = buffer.indexOf("\n");
|
|
191
|
+
if (newline < 0) return;
|
|
192
|
+
cleanup();
|
|
193
|
+
socket.end();
|
|
194
|
+
try {
|
|
195
|
+
const parsed = JSON.parse(buffer.slice(0, newline));
|
|
196
|
+
if (parsed.ok === true) resolve(parsed.result as T);
|
|
197
|
+
else reject(new HelperCommandError(parsed?.error?.message ?? `Daemon command '${cmd}' failed.`, parsed?.error?.code));
|
|
198
|
+
} catch (error) {
|
|
199
|
+
reject(error);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
socket.on("error", (error) => { cleanup(); reject(new HelperTransportError(error.message)); });
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async ensureDaemon(signal?: AbortSignal): Promise<boolean> {
|
|
207
|
+
if (this.daemonAvailable) return true;
|
|
208
|
+
try {
|
|
209
|
+
await this.daemonCommand("diagnostics", {}, 1_000, signal);
|
|
210
|
+
this.daemonAvailable = true;
|
|
211
|
+
return true;
|
|
212
|
+
} catch {}
|
|
213
|
+
await this.launchDaemon(signal).catch(() => undefined);
|
|
214
|
+
for (let index = 0; index < 30; index += 1) {
|
|
215
|
+
try {
|
|
216
|
+
await this.daemonCommand("diagnostics", {}, 1_000, signal);
|
|
217
|
+
this.daemonAvailable = true;
|
|
218
|
+
return true;
|
|
219
|
+
} catch {
|
|
220
|
+
await sleep(100, signal);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async command<T>(cmd: string, args: Record<string, unknown> = {}, options?: { timeoutMs?: number; signal?: AbortSignal }): Promise<T> {
|
|
227
|
+
const timeoutMs = options?.timeoutMs ?? COMMAND_TIMEOUT_MS;
|
|
228
|
+
if (!(await this.ensureDaemon(options?.signal))) {
|
|
229
|
+
throw new HelperTransportError(`tangu-computer-use helper app daemon is unavailable at ${HELPER_APP_PATH}.`);
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
return await this.daemonCommand<T>(cmd, args, timeoutMs, options?.signal);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
this.daemonAvailable = false;
|
|
235
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async restart(signal?: AbortSignal): Promise<void> {
|
|
240
|
+
await this.command("shutdown", {}, { signal, timeoutMs: 2_000 }).catch(() => undefined);
|
|
241
|
+
this.daemonAvailable = false;
|
|
242
|
+
await sleep(400, signal);
|
|
243
|
+
if (!(await this.ensureDaemon(signal))) {
|
|
244
|
+
throw new Error(`tangu-computer-use helper did not come back after restart. Helper app: ${HELPER_APP_PATH}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async diagnosticsCommand(signal?: AbortSignal): Promise<PlatformDiagnostics> {
|
|
249
|
+
const result = await this.command<any>("diagnostics", {}, { signal });
|
|
250
|
+
const diagnostics = {
|
|
251
|
+
protocolVersion: Math.trunc(toFiniteNumber(result?.protocolVersion, 0)),
|
|
252
|
+
architectureVersion: Math.trunc(toFiniteNumber(result?.architectureVersion, 0)),
|
|
253
|
+
invariants: Array.isArray(result?.invariants) ? result.invariants.filter((value: unknown): value is string => typeof value === "string") : [],
|
|
254
|
+
pid: Math.trunc(toFiniteNumber(result?.pid, 0)),
|
|
255
|
+
parentPid: Math.trunc(toFiniteNumber(result?.parentPid, 0)) || undefined,
|
|
256
|
+
parentAppName: toOptionalString(result?.parentAppName),
|
|
257
|
+
parentBundleId: toOptionalString(result?.parentBundleId),
|
|
258
|
+
parentPath: toOptionalString(result?.parentPath),
|
|
259
|
+
executablePath: toOptionalString(result?.executablePath),
|
|
260
|
+
os: toOptionalString(result?.macOS),
|
|
261
|
+
arch: toOptionalString(result?.arch),
|
|
262
|
+
accessibility: toBoolean(result?.accessibility),
|
|
263
|
+
screenRecording: toBoolean(result?.screenRecording),
|
|
264
|
+
};
|
|
265
|
+
this.diagnosticsCache = diagnostics;
|
|
266
|
+
return diagnostics;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async ensureProtocol(signal?: AbortSignal): Promise<PlatformDiagnostics> {
|
|
270
|
+
let diagnostics = await this.diagnosticsCommand(signal);
|
|
271
|
+
const executableMatches = await isResolvedHelperExecutable(diagnostics.executablePath);
|
|
272
|
+
if (diagnostics.protocolVersion === HELPER_PROTOCOL_VERSION && executableMatches) return diagnostics;
|
|
273
|
+
|
|
274
|
+
// The helper daemon outlives Pi, so restarting/reloading Pi alone does not
|
|
275
|
+
// replace a stale daemon or one launched from the legacy system location.
|
|
276
|
+
// Stop it through the backwards-compatible command channel and relaunch
|
|
277
|
+
// the exact app bundle that ensureInstalled() resolved.
|
|
278
|
+
await this.restart(signal);
|
|
279
|
+
diagnostics = await this.diagnosticsCommand(signal);
|
|
280
|
+
const relaunchedExecutableMatches = await isResolvedHelperExecutable(diagnostics.executablePath);
|
|
281
|
+
if (diagnostics.protocolVersion !== HELPER_PROTOCOL_VERSION || !relaunchedExecutableMatches) {
|
|
282
|
+
this.daemonAvailable = false;
|
|
283
|
+
throw new Error(
|
|
284
|
+
`tangu-computer-use helper mismatch after relaunch: expected protocol ${HELPER_PROTOCOL_VERSION} and executable ${HELPER_APP_EXECUTABLE_PATH}; got protocol ${diagnostics.protocolVersion} and executable ${diagnostics.executablePath ?? "unknown"}. Reinstall or rebuild the helper app at ${HELPER_APP_PATH}.`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
return diagnostics;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export const macosHelper = new MacosHelperClient();
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { ensurePermissions, type PermissionKind, type PermissionStatus } from "../../permissions.ts";
|
|
3
|
+
import { toBoolean, toFiniteNumber, toOptionalString } from "../coerce.ts";
|
|
4
|
+
import type { PlatformReadyState } from "../types.ts";
|
|
5
|
+
import { HELPER_APP_PATH, macosHelper } from "./helper.ts";
|
|
6
|
+
import { assertPlatformArchitecture } from "../architecture.ts";
|
|
7
|
+
|
|
8
|
+
const GRANT_INSTRUCTIONS =
|
|
9
|
+
"Grant Accessibility and Screen Recording to tangu-computer-use.app in System Settings → Privacy & Security. " +
|
|
10
|
+
"Screen Recording lets the agent see the window; Accessibility lets it interact with the window.";
|
|
11
|
+
|
|
12
|
+
const SIGNING_MIGRATION_WARNING =
|
|
13
|
+
"If these permissions were enabled before this install/update, macOS invalidated the old grants because " +
|
|
14
|
+
"tangu-computer-use.app was re-signed. Re-enable both toggles for the newly signed helper. " +
|
|
15
|
+
"If a toggle is already on, switch it off and on again.";
|
|
16
|
+
|
|
17
|
+
const macosPermissionKinds = [
|
|
18
|
+
{ kind: "accessibility" as const, openOption: "Open Accessibility Settings (missing)" },
|
|
19
|
+
{ kind: "screenRecording" as const, openOption: "Open Screen Recording Settings (missing)" },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function permissionStatusSummary(status: PermissionStatus): string {
|
|
23
|
+
const lines = [
|
|
24
|
+
`Accessibility: ${status.accessibility ? "granted" : "missing"}`,
|
|
25
|
+
`Screen Recording: ${status.screenRecording ? "granted" : "missing"}`,
|
|
26
|
+
];
|
|
27
|
+
if (status.screenRecordingPreflight && !status.screenRecording) {
|
|
28
|
+
lines.push(
|
|
29
|
+
"(Screen Recording reads granted in the TCC database but a live capture probe failed — " +
|
|
30
|
+
"the grant likely belongs to a different app identity, or the helper needs a restart.)",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return lines.join("; ");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function permissionPrompt(status: PermissionStatus, helperPath: string, hint?: string): string {
|
|
37
|
+
return [
|
|
38
|
+
"tangu-computer-use needs macOS permissions for its helper app.",
|
|
39
|
+
permissionStatusSummary(status),
|
|
40
|
+
"",
|
|
41
|
+
`Helper: tangu-computer-use.app (${helperPath})`,
|
|
42
|
+
hint,
|
|
43
|
+
"",
|
|
44
|
+
`Important: ${SIGNING_MIGRATION_WARNING}`,
|
|
45
|
+
"",
|
|
46
|
+
"tangu-computer-use.app is already listed in the pane(s) — enable its toggle, then choose Recheck.",
|
|
47
|
+
].filter(Boolean).join("\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function missingPermissionMessage(kinds: PermissionKind[]): string {
|
|
51
|
+
return `Still missing after restart: ${kinds.join(" and ")}. ${SIGNING_MIGRATION_WARNING} Then choose Recheck again.`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function checkPermissions(signal?: AbortSignal): Promise<PermissionStatus> {
|
|
55
|
+
const result = await macosHelper.command<any>("checkPermissions", {}, { signal });
|
|
56
|
+
const rawSource = result?.source;
|
|
57
|
+
return {
|
|
58
|
+
accessibility: toBoolean(result?.accessibility),
|
|
59
|
+
// Authoritative: the helper's live ScreenCaptureKit probe.
|
|
60
|
+
screenRecording: toBoolean(result?.screenRecordingCapturable),
|
|
61
|
+
// Keep the preflight value separate: disagreement means stale per-process
|
|
62
|
+
// TCC cache or a grant row belonging to another app identity.
|
|
63
|
+
screenRecordingPreflight: toBoolean(result?.screenRecordingPreflight),
|
|
64
|
+
source: rawSource && typeof rawSource === "object"
|
|
65
|
+
? {
|
|
66
|
+
// macOS attributes Accessibility / Screen Recording grants to the
|
|
67
|
+
// responsible process at the top of the launch chain. "helper-app"
|
|
68
|
+
// is the canonical installed app via LaunchServices; "caller" means
|
|
69
|
+
// grants would attach to the launching app instead.
|
|
70
|
+
attribution: rawSource.attribution === "helper-app" ? "helper-app" : "caller",
|
|
71
|
+
pid: Math.trunc(toFiniteNumber(rawSource.pid, 0)) || undefined,
|
|
72
|
+
parentPid: Math.trunc(toFiniteNumber(rawSource.parentPid, 0)) || undefined,
|
|
73
|
+
executablePath: toOptionalString(rawSource.executablePath),
|
|
74
|
+
parentPath: toOptionalString(rawSource.parentPath),
|
|
75
|
+
parentBundleId: toOptionalString(rawSource.parentBundleId),
|
|
76
|
+
os: toOptionalString(rawSource.macOS),
|
|
77
|
+
}
|
|
78
|
+
: undefined,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function registerPermissions(signal?: AbortSignal): Promise<void> {
|
|
83
|
+
// Raises the Accessibility prompt and performs a real ScreenCaptureKit
|
|
84
|
+
// capture attempt so tangu-computer-use.app is pre-listed in both Settings
|
|
85
|
+
// panes; the user only flips toggles, no "+" path picking.
|
|
86
|
+
await macosHelper.command("registerPermissions", {}, { signal, timeoutMs: 15_000 });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function ensureMacosReady(
|
|
90
|
+
ctx: ExtensionContext,
|
|
91
|
+
state: PlatformReadyState,
|
|
92
|
+
signal?: AbortSignal,
|
|
93
|
+
): Promise<PlatformReadyState> {
|
|
94
|
+
await macosHelper.ensureInstalled(signal);
|
|
95
|
+
if (!(await macosHelper.ensureDaemon(signal))) {
|
|
96
|
+
throw new Error(`tangu-computer-use helper app daemon did not start. Helper app: ${HELPER_APP_PATH}`);
|
|
97
|
+
}
|
|
98
|
+
const helperDiagnostics = await macosHelper.ensureProtocol(signal);
|
|
99
|
+
assertPlatformArchitecture("macOS", helperDiagnostics);
|
|
100
|
+
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
const cachedStatus = state.permissionStatus;
|
|
103
|
+
const canUseCachedPermissions =
|
|
104
|
+
cachedStatus?.accessibility &&
|
|
105
|
+
cachedStatus.screenRecording &&
|
|
106
|
+
now - state.lastPermissionCheckAt < 2_000;
|
|
107
|
+
if (canUseCachedPermissions) {
|
|
108
|
+
return { ...state, helperDiagnostics };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let permissionStatus = await checkPermissions(signal);
|
|
112
|
+
let lastPermissionCheckAt = now;
|
|
113
|
+
|
|
114
|
+
if (!permissionStatus.accessibility || !permissionStatus.screenRecording) {
|
|
115
|
+
// Attribution "caller" means the helper is not running as the
|
|
116
|
+
// canonical installed app — grants would attach to the wrong identity.
|
|
117
|
+
const attributionHint = permissionStatus.source?.attribution === "caller"
|
|
118
|
+
? `Warning: the helper is not running as the installed tangu-computer-use.app (executable: ${permissionStatus.source?.executablePath ?? "unknown"}). Grants made now would attach to the launching app instead. Restart Tangu so the canonical helper is used.`
|
|
119
|
+
: undefined;
|
|
120
|
+
permissionStatus = await ensurePermissions(
|
|
121
|
+
ctx,
|
|
122
|
+
{
|
|
123
|
+
kinds: macosPermissionKinds,
|
|
124
|
+
copy: {
|
|
125
|
+
nonInteractiveError: (helperPath) => `tangu-computer-use setup requires an interactive session. Run 'tangu computer-use setup' in a terminal. ${GRANT_INSTRUCTIONS}\nHelper path: ${helperPath}`,
|
|
126
|
+
prompt: permissionPrompt,
|
|
127
|
+
incompleteError: (helperPath) => `tangu-computer-use setup is incomplete. ${GRANT_INSTRUCTIONS} Helper path: ${helperPath}`,
|
|
128
|
+
readyMessage: "tangu-computer-use is ready.",
|
|
129
|
+
stillMissing: missingPermissionMessage,
|
|
130
|
+
},
|
|
131
|
+
checkPermissions: (permissionSignal) => checkPermissions(permissionSignal ?? signal),
|
|
132
|
+
registerPermissions: (permissionSignal) => registerPermissions(permissionSignal ?? signal),
|
|
133
|
+
openPermissionPane: async (kind, permissionSignal) => {
|
|
134
|
+
await macosHelper.command("openPermissionPane", { kind }, { signal: permissionSignal ?? signal });
|
|
135
|
+
},
|
|
136
|
+
restartHelper: (permissionSignal) => macosHelper.restart(permissionSignal ?? signal),
|
|
137
|
+
permissionHint: attributionHint,
|
|
138
|
+
},
|
|
139
|
+
HELPER_APP_PATH,
|
|
140
|
+
signal,
|
|
141
|
+
);
|
|
142
|
+
lastPermissionCheckAt = Date.now();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { permissionStatus, lastPermissionCheckAt, helperDiagnostics };
|
|
146
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { LookResponse } from "../outline.ts";
|
|
3
|
+
import type { PermissionStatus } from "../permissions.ts";
|
|
4
|
+
|
|
5
|
+
export type PlatformName = "macos" | "windows" | "linux";
|
|
6
|
+
export type NativeInputDelivery = "hid" | "pid";
|
|
7
|
+
export type ActOutcome = "worked" | "didnt" | "unknown";
|
|
8
|
+
/**
|
|
9
|
+
* Best-effort presentation hint for a root. The seam guarantees only the
|
|
10
|
+
* `window` vs transient distinction; specific transient kinds are display hints
|
|
11
|
+
* and must never drive behavior in shared code. Platforms that need precise
|
|
12
|
+
* distinctions internally should use native signals.
|
|
13
|
+
*/
|
|
14
|
+
export type PlatformRootKind = "window" | "menu" | "sheet" | "popover" | "dialog";
|
|
15
|
+
|
|
16
|
+
export interface PlatformDiagnostics {
|
|
17
|
+
protocolVersion: number;
|
|
18
|
+
architectureVersion?: number;
|
|
19
|
+
invariants?: string[];
|
|
20
|
+
pid: number;
|
|
21
|
+
parentPid?: number;
|
|
22
|
+
parentAppName?: string;
|
|
23
|
+
parentBundleId?: string;
|
|
24
|
+
parentPath?: string;
|
|
25
|
+
executablePath?: string;
|
|
26
|
+
os?: string;
|
|
27
|
+
arch?: string;
|
|
28
|
+
accessibility?: boolean;
|
|
29
|
+
screenRecording?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PlatformReadyState {
|
|
33
|
+
permissionStatus?: PermissionStatus;
|
|
34
|
+
lastPermissionCheckAt: number;
|
|
35
|
+
helperDiagnostics?: PlatformDiagnostics;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PlatformRootQuery {
|
|
39
|
+
pid?: number;
|
|
40
|
+
title?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PlatformApp {
|
|
44
|
+
appName: string;
|
|
45
|
+
bundleId?: string;
|
|
46
|
+
pid: number;
|
|
47
|
+
isFrontmost?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface FramePoints {
|
|
51
|
+
x: number;
|
|
52
|
+
y: number;
|
|
53
|
+
w: number;
|
|
54
|
+
h: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PlatformRoot {
|
|
58
|
+
kind: PlatformRootKind;
|
|
59
|
+
rootRef?: string;
|
|
60
|
+
windowRef?: string;
|
|
61
|
+
windowId?: number;
|
|
62
|
+
pid?: number;
|
|
63
|
+
appName?: string;
|
|
64
|
+
bundleId?: string;
|
|
65
|
+
title: string;
|
|
66
|
+
role?: string;
|
|
67
|
+
subrole?: string;
|
|
68
|
+
zOrder: number;
|
|
69
|
+
framePoints: FramePoints;
|
|
70
|
+
scaleFactor: number;
|
|
71
|
+
isOnscreen: boolean;
|
|
72
|
+
isFocused: boolean;
|
|
73
|
+
isMinimized: boolean;
|
|
74
|
+
/** Best-effort; platforms without a main-window concept may mirror `isFocused`. */
|
|
75
|
+
isMain: boolean;
|
|
76
|
+
/** Platform-reported modality fact, including platform-specific modal/dialog/sheet signals. */
|
|
77
|
+
isModal: boolean;
|
|
78
|
+
metadata?: Record<string, unknown>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface PlatformFrontmostResult {
|
|
82
|
+
appName: string;
|
|
83
|
+
bundleId?: string;
|
|
84
|
+
pid: number;
|
|
85
|
+
windowTitle?: string;
|
|
86
|
+
windowId?: number;
|
|
87
|
+
rootRef?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface PlatformFocusWindowResult {
|
|
91
|
+
focused: boolean;
|
|
92
|
+
alreadyFocused?: boolean;
|
|
93
|
+
reason?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface HelperActPerformed {
|
|
97
|
+
grounding?: "description" | "coordinates" | "keyboard-events";
|
|
98
|
+
/** `ax` means the platform accessibility API (AX on macOS, UIA on Windows). */
|
|
99
|
+
delivery?: "ax" | NativeInputDelivery;
|
|
100
|
+
refound?: boolean;
|
|
101
|
+
/** Free-form diagnostic naming the platform's delta mechanism. */
|
|
102
|
+
deltaSource?: string;
|
|
103
|
+
selectionGrounding?: "ax" | "keyboard";
|
|
104
|
+
transaction?: boolean;
|
|
105
|
+
actionCount?: number;
|
|
106
|
+
activated?: boolean;
|
|
107
|
+
raised?: boolean;
|
|
108
|
+
focused?: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface PlatformRootDelta {
|
|
112
|
+
change: "appeared" | "closed" | "focused";
|
|
113
|
+
kind: string;
|
|
114
|
+
ref?: string;
|
|
115
|
+
title?: string;
|
|
116
|
+
pid: number;
|
|
117
|
+
isModal?: boolean;
|
|
118
|
+
metadata?: Record<string, unknown>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface HelperActResult {
|
|
122
|
+
outcome: ActOutcome;
|
|
123
|
+
performed?: HelperActPerformed;
|
|
124
|
+
evidence?: Record<string, unknown>;
|
|
125
|
+
error?: { code?: string; message?: string; whatIsThere?: unknown };
|
|
126
|
+
rootDelta?: PlatformRootDelta[];
|
|
127
|
+
steps?: HelperActResult[];
|
|
128
|
+
stoppedAt?: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface PlatformTarget {
|
|
132
|
+
pid?: number;
|
|
133
|
+
windowId?: number;
|
|
134
|
+
rootRef?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface PlatformObserveRequest {
|
|
138
|
+
target: PlatformTarget;
|
|
139
|
+
/** Existing immutable look whose untouched refs/coordinate geometry survive a scoped refresh. */
|
|
140
|
+
baseLookId?: string;
|
|
141
|
+
readText: "auto" | "always" | "never";
|
|
142
|
+
scopeRef?: string;
|
|
143
|
+
maxDimension?: number;
|
|
144
|
+
includeImage?: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type PlatformActTarget = { ref: string } | { x: number; y: number } | { focus: PlatformPoint };
|
|
148
|
+
type PlatformDeliveryPolicy = "ax_only" | "background" | "default" | "foreground";
|
|
149
|
+
type PlatformMouseButton = "left" | "right" | "middle";
|
|
150
|
+
type PlatformActDeliveryParam = { delivery?: NativeInputDelivery };
|
|
151
|
+
export type PlatformPoint = { x: number; y: number };
|
|
152
|
+
|
|
153
|
+
export interface PlatformActRequestBase {
|
|
154
|
+
lookId: string;
|
|
155
|
+
pid?: number;
|
|
156
|
+
target: PlatformActTarget;
|
|
157
|
+
policy: PlatformDeliveryPolicy;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type PlatformActRequest = PlatformActRequestBase & (
|
|
161
|
+
| { action: "press" | "click"; params: { button?: PlatformMouseButton; clickCount?: number } & PlatformActDeliveryParam }
|
|
162
|
+
| { action: "setText"; params: { text: string } & PlatformActDeliveryParam }
|
|
163
|
+
| { action: "typeText"; params: { text: string } & PlatformActDeliveryParam }
|
|
164
|
+
| { action: "keypress"; params: { keys: string[] } & PlatformActDeliveryParam }
|
|
165
|
+
| { action: "scroll"; params: { scrollX: number; scrollY: number } & PlatformActDeliveryParam }
|
|
166
|
+
| { action: "drag"; params: { path: PlatformPoint[] } & PlatformActDeliveryParam }
|
|
167
|
+
| { action: "moveMouse"; params: PlatformActDeliveryParam }
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
export interface PlatformReadTextRequest {
|
|
171
|
+
/** Observation that owns the element ref. Native backends must not resolve across observations. */
|
|
172
|
+
lookId: string;
|
|
173
|
+
elementRef: string;
|
|
174
|
+
offset: number;
|
|
175
|
+
limit: number;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface PlatformReadTextResponse {
|
|
179
|
+
text: string;
|
|
180
|
+
offset: number;
|
|
181
|
+
limit: number;
|
|
182
|
+
totalChars: number;
|
|
183
|
+
hasMore: boolean;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface PlatformWaitForRequest extends PlatformTarget {
|
|
187
|
+
lookId?: string;
|
|
188
|
+
text?: string;
|
|
189
|
+
role?: string;
|
|
190
|
+
value?: string;
|
|
191
|
+
scopeRef?: string;
|
|
192
|
+
scopeExact?: boolean;
|
|
193
|
+
gone: boolean;
|
|
194
|
+
timeoutMs: number;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface PlatformWaitForResponse {
|
|
198
|
+
found: boolean;
|
|
199
|
+
gone?: boolean;
|
|
200
|
+
timedOut?: boolean;
|
|
201
|
+
nodeCount?: number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface ComputerUsePlatformBackend {
|
|
205
|
+
name: PlatformName;
|
|
206
|
+
/** Release process-local resources when the Pi session is torn down. */
|
|
207
|
+
shutdown?(): void | Promise<void>;
|
|
208
|
+
ensureReady(ctx: ExtensionContext, state: PlatformReadyState, signal?: AbortSignal): Promise<PlatformReadyState>;
|
|
209
|
+
listApps(signal?: AbortSignal): Promise<PlatformApp[]>;
|
|
210
|
+
listRoots(query: PlatformRootQuery, signal?: AbortSignal): Promise<PlatformRoot[]>;
|
|
211
|
+
getFrontmost(signal?: AbortSignal): Promise<PlatformFrontmostResult>;
|
|
212
|
+
focusWindow(target: PlatformTarget, signal?: AbortSignal): Promise<PlatformFocusWindowResult>;
|
|
213
|
+
observe(request: PlatformObserveRequest, options?: { timeoutMs?: number; signal?: AbortSignal }): Promise<LookResponse>;
|
|
214
|
+
act(request: PlatformActRequest, options?: { timeoutMs?: number; signal?: AbortSignal }): Promise<HelperActResult>;
|
|
215
|
+
/** Execute one-resource actions with one root baseline and one final settle. */
|
|
216
|
+
actBatch?(requests: PlatformActRequest[], options?: { timeoutMs?: number; signal?: AbortSignal }): Promise<HelperActResult>;
|
|
217
|
+
readText(args: PlatformReadTextRequest, options?: { timeoutMs?: number; signal?: AbortSignal }): Promise<PlatformReadTextResponse>;
|
|
218
|
+
waitFor(args: PlatformWaitForRequest, options?: { timeoutMs?: number; signal?: AbortSignal }): Promise<PlatformWaitForResponse>;
|
|
219
|
+
isBrowserApp(appName: string, bundleId?: string): boolean;
|
|
220
|
+
isChromeFamilyApp(appName: string, bundleId?: string): boolean;
|
|
221
|
+
openBrowserLocation(target: { appName: string; bundleId?: string }, url: string, signal?: AbortSignal): Promise<boolean>;
|
|
222
|
+
}
|