@forsion/tangu-computer-use 0.6.1 → 0.6.2
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 +27 -0
- package/UPSTREAM.md +3 -1
- package/icon.png +0 -0
- package/main.js +15 -0
- package/manifest.json +3 -3
- package/native/macos/bridge.swift +171 -7
- package/native/windows/bridge-rs/Cargo.lock +1 -0
- package/native/windows/bridge-rs/Cargo.toml +11 -0
- package/native/windows/bridge-rs/src/agent_marker.rs +97 -0
- package/native/windows/bridge-rs/src/lib.rs +4 -0
- package/native/windows/bridge-rs/src/main.rs +32 -0
- package/native/windows/bridge-rs/src/recorder.rs +1865 -0
- package/native/windows/bridge-rs/src/recorder_logic.rs +851 -0
- package/native/windows/bridge-rs/tests/recorder_logic_tests.rs +314 -0
- package/package.json +1 -1
- package/prebuilt/linux/x64/linux-bridge +0 -0
- package/prebuilt/macos/arm64/bridge +0 -0
- package/prebuilt/macos/arm64/tangu-computer-use.app.json +4 -4
- 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 +4 -4
- package/prebuilt/macos/x64/tangu-computer-use.app.zip +0 -0
- package/prebuilt/windows/windows-bridge.exe +0 -0
- package/scripts/no-foreground.check.mjs +14 -1
- package/scripts/probe-recorder-windows.mjs +356 -0
- package/skills/computer-use/SKILL.md +4 -0
- package/src/foregroundNote.ts +16 -0
- package/src/index.ts +2 -0
- package/src/tools.ts +4 -3
- package/src/vendor/platform/macos/helper.ts +1 -1
- package/tangu-plugins/computer-use/dist/foregroundNote.js +10 -1
- package/tangu-plugins/computer-use/dist/index.js +13 -2
- package/tangu-plugins/computer-use/tangu-plugin.json +1 -1
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 电脑历史 Windows 采集器(windows-bridge.exe serve)的真机实测台架 —— 只能在 Windows 上跑(本机是 mac 时走 CI:
|
|
3
|
+
// .github/workflows/windows-recorder-probe.yml,推 ci/windows-recorder* 分支触发)。
|
|
4
|
+
//
|
|
5
|
+
// 用真命名管道订阅 recordSubscribe,然后真的操作桌面:记事本打字 / 组合键 / 点菜单,stdio 助手 focusWindow(agent 标记),
|
|
6
|
+
// Chrome 普通窗口(标题 + 网址 + 排除站点)、Chrome 无痕 / Edge InPrivate(只能留不带标题的切换)、网页输入框与密码框。
|
|
7
|
+
// 输入走 SendKeys / mouse_event(注入的输入;采集器不按注入标记判 agent,所以照常记)。
|
|
8
|
+
//
|
|
9
|
+
// 用法:node scripts/probe-recorder-windows.mjs <windows-bridge.exe> [--out probe-results.json]
|
|
10
|
+
// 退出码:必选断言有任何一条失败 → 1。所有事件与断言写进 --out(CI 作为 artifact 上传)。
|
|
11
|
+
|
|
12
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
15
|
+
import net from "node:net";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
const exe = path.resolve(process.argv[2] ?? "native/windows/bridge-rs/target/release/windows-bridge.exe");
|
|
20
|
+
const outIndex = process.argv.indexOf("--out");
|
|
21
|
+
const outPath = outIndex > 0 ? process.argv[outIndex + 1] : "probe-results.json";
|
|
22
|
+
if (process.platform !== "win32") {
|
|
23
|
+
console.error("probe-recorder-windows only runs on Windows (use the CI workflow from a mac).");
|
|
24
|
+
process.exit(2);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
28
|
+
const results = [];
|
|
29
|
+
const findings = {};
|
|
30
|
+
function check(name, ok, detail = "", required = true) {
|
|
31
|
+
results.push({ name, ok: !!ok, required, detail });
|
|
32
|
+
console.log(`${ok ? "PASS" : required ? "FAIL" : "INFO"} ${name}${detail ? ` — ${detail}` : ""}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ps(script) {
|
|
36
|
+
try {
|
|
37
|
+
return execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", timeout: 30_000 }).trim();
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return `ERROR: ${String(e.stderr || e.message).trim()}`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const USER32 = `Add-Type -Name U -Namespace W -MemberDefinition '
|
|
44
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
|
45
|
+
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);
|
|
46
|
+
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
|
47
|
+
[DllImport("user32.dll")] public static extern void mouse_event(uint f, uint x, uint y, uint d, IntPtr e);
|
|
48
|
+
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
|
|
49
|
+
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
|
50
|
+
[DllImport("user32.dll")] public static extern void keybd_event(byte v, byte s, uint f, IntPtr e);
|
|
51
|
+
public struct RECT { public int L; public int T; public int R; public int B; }
|
|
52
|
+
';`;
|
|
53
|
+
|
|
54
|
+
function mainWindow(pid) {
|
|
55
|
+
return Number(ps(`(Get-Process -Id ${pid} -ErrorAction SilentlyContinue).MainWindowHandle`)) || 0;
|
|
56
|
+
}
|
|
57
|
+
function mainWindowOf(name) {
|
|
58
|
+
const out = ps(`Get-Process -Name ${name} -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | ForEach-Object { "$($_.Id)|$($_.MainWindowHandle)|$($_.MainWindowTitle)" }`);
|
|
59
|
+
return out.split(/\r?\n/).filter((l) => l.includes("|")).map((l) => {
|
|
60
|
+
const [pid, hwnd, ...title] = l.split("|");
|
|
61
|
+
return { pid: Number(pid), hwnd: Number(hwnd), title: title.join("|") };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function focus(hwnd) {
|
|
65
|
+
// 按住 Alt 解除前台锁(SetForegroundWindow 对后台进程的限制),再置前。Alt 按下抬起之间垫一个空键(F24):
|
|
66
|
+
// 单独轻敲 Alt 会让记事本进菜单模式、让 Chrome 聚焦到应用菜单按钮,焦点被抢,打的字进不了输入框。
|
|
67
|
+
ps(`${USER32} [W.U]::keybd_event(0x12,0,0,[IntPtr]::Zero); [W.U]::keybd_event(0x87,0,0,[IntPtr]::Zero); [W.U]::keybd_event(0x87,0,2,[IntPtr]::Zero); [W.U]::keybd_event(0x12,0,2,[IntPtr]::Zero); [W.U]::ShowWindow([IntPtr]${hwnd}, 9) | Out-Null; [W.U]::SetForegroundWindow([IntPtr]${hwnd}) | Out-Null`);
|
|
68
|
+
}
|
|
69
|
+
function sendKeys(keys) {
|
|
70
|
+
ps(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('${keys.replace(/'/g, "''")}')`);
|
|
71
|
+
}
|
|
72
|
+
function clickAt(x, y) {
|
|
73
|
+
ps(`${USER32} [W.U]::SetCursorPos(${x}, ${y}) | Out-Null; Start-Sleep -Milliseconds 100; [W.U]::mouse_event(2,0,0,0,[IntPtr]::Zero); [W.U]::mouse_event(4,0,0,0,[IntPtr]::Zero)`);
|
|
74
|
+
}
|
|
75
|
+
function windowRect(hwnd) {
|
|
76
|
+
const out = ps(`${USER32} $r = New-Object W.U+RECT; [W.U]::GetWindowRect([IntPtr]${hwnd}, [ref]$r) | Out-Null; "$($r.L),$($r.T),$($r.R),$($r.B)"`);
|
|
77
|
+
const [l, t, r, b] = out.split(",").map(Number);
|
|
78
|
+
return { l, t, r, b };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── 管道订阅 ──
|
|
82
|
+
|
|
83
|
+
const user = randomBytes(4).toString("hex");
|
|
84
|
+
const pipe = `\\\\.\\pipe\\tangu-computer-use-recorder-probe-${user}`;
|
|
85
|
+
const server = spawn(exe, ["serve", "--pipe", pipe], { stdio: ["ignore", "inherit", "inherit"], windowsHide: true, env: { ...process.env, TANGU_RECORDER_DEBUG: "1" } });
|
|
86
|
+
server.on("exit", (code) => console.log(`[probe] serve exited with ${code}`));
|
|
87
|
+
|
|
88
|
+
function subscribe(policy, label) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const events = [];
|
|
91
|
+
const socket = net.createConnection(pipe);
|
|
92
|
+
let buffer = "";
|
|
93
|
+
let reply = null;
|
|
94
|
+
socket.setEncoding("utf8");
|
|
95
|
+
socket.on("connect", () => socket.write(`${JSON.stringify({ id: `probe_${label}`, cmd: "recordSubscribe", policy })}\n`));
|
|
96
|
+
socket.on("data", (chunk) => {
|
|
97
|
+
buffer += chunk;
|
|
98
|
+
const lines = buffer.split("\n");
|
|
99
|
+
buffer = lines.pop() ?? "";
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
if (!line.trim()) continue;
|
|
102
|
+
const msg = JSON.parse(line);
|
|
103
|
+
if (!reply) {
|
|
104
|
+
reply = msg;
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
resolve({ reply, events, socket });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (msg.ev) {
|
|
110
|
+
events.push(msg.ev);
|
|
111
|
+
console.log(`[ev:${label}] ${JSON.stringify(msg.ev)}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
socket.on("error", reject);
|
|
116
|
+
// 只在回包之前生效(成功后清掉 —— 第四轮就是这个计时器到点把好好的订阅连接 destroy 了)。
|
|
117
|
+
const timer = setTimeout(() => { socket.destroy(); reject(new Error("subscribe timeout")); }, 5_000);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function connectWithRetry(policy, label) {
|
|
122
|
+
for (let i = 0; i < 40; i++) {
|
|
123
|
+
try {
|
|
124
|
+
return await subscribe(policy, label);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
if (i % 10 === 0) console.log(`[probe] connect attempt ${i} (${label}): ${e.code || e.message}`);
|
|
127
|
+
if (!String(e.code || e.message).match(/ENOENT|EBUSY|timeout/)) throw e;
|
|
128
|
+
await sleep(250);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
throw new Error("could not connect to the recorder pipe");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const since = (events, t0) => events.filter((e) => e.t >= t0 - 50);
|
|
135
|
+
/** recorder 线程此刻卡在哪一步(phase / phaseMs / backlog),每个场景后打一行,卡死时直接看出卡在哪个 UIA 调用。 */
|
|
136
|
+
async function logDiag(label) {
|
|
137
|
+
const d = await diagnostics();
|
|
138
|
+
console.log(`[probe] diag after ${label}: ${JSON.stringify(d?.result)}`);
|
|
139
|
+
(findings.diag ??= []).push({ label, ...d?.result });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function main() {
|
|
143
|
+
const main = await connectWithRetry({}, "open");
|
|
144
|
+
check("recordSubscribe reply", main.reply?.ok === true && main.reply?.result?.protocolVersion === 13 && main.reply?.result?.axTrusted === true, JSON.stringify(main.reply));
|
|
145
|
+
await sleep(1500);
|
|
146
|
+
findings.initialEvents = main.events.slice();
|
|
147
|
+
check("initial snapshot or system state", main.events.length > 0, JSON.stringify(main.events.slice(0, 3)));
|
|
148
|
+
if (main.events.some((e) => e.kind === "system" && e.state === "locked")) {
|
|
149
|
+
check("session is not locked on the runner", false, "the recorder thinks the input desktop is locked; nothing else can be observed");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── 记事本:切换、打字、组合键、点菜单 ──
|
|
153
|
+
let t0 = Date.now();
|
|
154
|
+
const notepad = spawn("notepad.exe", [], { detached: true, stdio: "ignore" });
|
|
155
|
+
await sleep(2500);
|
|
156
|
+
const npWindows = mainWindowOf("notepad");
|
|
157
|
+
const np = npWindows.find((w) => w.hwnd) ?? { hwnd: mainWindow(notepad.pid), pid: notepad.pid };
|
|
158
|
+
findings.notepad = np;
|
|
159
|
+
focus(np.hwnd);
|
|
160
|
+
await sleep(1500);
|
|
161
|
+
await logDiag("notepad-switch");
|
|
162
|
+
const npEvents = () => since(main.events, t0).filter((e) => e.app?.bundleId === "notepad.exe");
|
|
163
|
+
check("switching to Notepad emits an app event with the exe name", npEvents().some((e) => e.kind === "app"), JSON.stringify(npEvents().slice(0, 2)));
|
|
164
|
+
check("Notepad app event has a display name", npEvents().some((e) => e.kind === "app" && e.app?.name && e.app.name !== "notepad.exe"), JSON.stringify(npEvents().find((e) => e.kind === "app")?.app));
|
|
165
|
+
|
|
166
|
+
t0 = Date.now();
|
|
167
|
+
sendKeys("hello world");
|
|
168
|
+
await sleep(3500);
|
|
169
|
+
const typed = since(main.events, t0).filter((e) => e.kind === "text");
|
|
170
|
+
check("typing in Notepad produces a text event", typed.some((e) => e.text?.includes("hello world")), JSON.stringify(typed));
|
|
171
|
+
await logDiag("notepad-typing");
|
|
172
|
+
check("text event carries the field role", typed.some((e) => typeof e.el?.role === "string"), JSON.stringify(typed[0]?.el));
|
|
173
|
+
|
|
174
|
+
t0 = Date.now();
|
|
175
|
+
sendKeys("^a");
|
|
176
|
+
await sleep(800);
|
|
177
|
+
const keys = since(main.events, t0).filter((e) => e.kind === "key");
|
|
178
|
+
check("Ctrl+A is recorded as a shortcut", keys.some((e) => e.keys === "Ctrl+A"), JSON.stringify(keys));
|
|
179
|
+
sendKeys("{DEL}");
|
|
180
|
+
await sleep(2500);
|
|
181
|
+
|
|
182
|
+
t0 = Date.now();
|
|
183
|
+
const rect = windowRect(np.hwnd);
|
|
184
|
+
findings.notepadRect = rect;
|
|
185
|
+
// 经典记事本:标题栏下方第一项菜单「File / 文件」
|
|
186
|
+
clickAt(rect.l + 30, rect.t + 45);
|
|
187
|
+
await sleep(1200);
|
|
188
|
+
sendKeys("{ESC}");
|
|
189
|
+
await sleep(500);
|
|
190
|
+
await logDiag("notepad-click");
|
|
191
|
+
const clicks = since(main.events, t0).filter((e) => e.kind === "click");
|
|
192
|
+
check("clicking a menu item records role and label", clicks.some((e) => e.el?.role === "MenuItem" && e.el?.label), JSON.stringify(clicks), false);
|
|
193
|
+
check("a click in Notepad is recorded", clicks.length > 0, JSON.stringify(clicks));
|
|
194
|
+
|
|
195
|
+
// ── agent 标记:stdio 助手 focusWindow 期间切过去的前台 → origin:"agent" ──
|
|
196
|
+
// 另开一个记事本进程当「别的窗口」(Explorer 的主窗口句柄可能是桌面 Progman,置前后前台会落回记事本,前提不成立)。
|
|
197
|
+
const other = spawn("notepad.exe", [], { detached: true, stdio: "ignore" });
|
|
198
|
+
await sleep(2500);
|
|
199
|
+
const otherWin = mainWindowOf("notepad").find((w) => w.pid !== np.pid);
|
|
200
|
+
const fg = () => Number(ps(`${USER32} [W.U]::GetForegroundWindow().ToInt64()`)) || 0;
|
|
201
|
+
for (let i = 0; i < 3 && otherWin && fg() !== otherWin.hwnd; i++) {
|
|
202
|
+
focus(otherWin.hwnd);
|
|
203
|
+
await sleep(800);
|
|
204
|
+
}
|
|
205
|
+
findings.agentPrecondition = { foreground: fg(), notepad: np.hwnd, other: otherWin };
|
|
206
|
+
check("precondition: another window is in front before the agent acts", otherWin && fg() === otherWin.hwnd, JSON.stringify(findings.agentPrecondition));
|
|
207
|
+
await sleep(800);
|
|
208
|
+
t0 = Date.now();
|
|
209
|
+
const agentResult = await stdioFocus(np.pid);
|
|
210
|
+
findings.agentFocus = agentResult;
|
|
211
|
+
await sleep(1500);
|
|
212
|
+
await logDiag("agent-focus");
|
|
213
|
+
const agentEvents = since(main.events, t0).filter((e) => e.app?.bundleId === "notepad.exe");
|
|
214
|
+
check("foreground change by the agent carries origin=agent", agentEvents.some((e) => e.origin === "agent"), JSON.stringify(agentEvents));
|
|
215
|
+
other.unref();
|
|
216
|
+
|
|
217
|
+
// ── 浏览器 ──
|
|
218
|
+
const chrome = ["C:/Program Files/Google/Chrome/Application/chrome.exe", "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe"].find(existsSync);
|
|
219
|
+
const edge = ["C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe", "C:/Program Files/Microsoft/Edge/Application/msedge.exe"].find(existsSync);
|
|
220
|
+
const flags = (dir) => [`--user-data-dir=${dir}`, "--no-first-run", "--no-default-browser-check", "--disable-search-engine-choice-screen", "--disable-features=PrivacySandboxSettings4"];
|
|
221
|
+
if (chrome) {
|
|
222
|
+
const siteOff = await connectWithRetry({ excludeDomains: ["example.com"] }, "siteOff");
|
|
223
|
+
t0 = Date.now();
|
|
224
|
+
const profile = mkdtempSync(path.join(os.tmpdir(), "probe-chrome-"));
|
|
225
|
+
spawn(chrome, [...flags(profile), "https://example.com/?q=secret#frag"], { detached: true, stdio: "ignore" }).unref();
|
|
226
|
+
await sleep(7000);
|
|
227
|
+
const win = mainWindowOf("chrome").find((w) => w.title);
|
|
228
|
+
findings.chromeNormal = win;
|
|
229
|
+
if (win) focus(win.hwnd);
|
|
230
|
+
// 第一次遍历可能走不完(Chrome 此刻才打开无障碍):采集器按无痕先不记,约 1 秒后自己重走,这里多等几轮。
|
|
231
|
+
await sleep(6000);
|
|
232
|
+
const ev = since(main.events, t0).filter((e) => e.app?.bundleId === "chrome.exe");
|
|
233
|
+
check("Chrome normal window has a title", ev.some((e) => e.title?.includes("Example")), JSON.stringify(ev.slice(-3)));
|
|
234
|
+
check("Chrome URL is read and sanitized", ev.some((e) => /^https:\/\/example\.com\/?$/.test(e.url ?? "")), JSON.stringify(ev.map((e) => e.url)));
|
|
235
|
+
const off = since(siteOff.events, t0).filter((e) => e.app?.bundleId === "chrome.exe");
|
|
236
|
+
check("an excluded site leaves only a content-free marker", off.length > 0 && off.every((e) => !e.title && !e.url && (e.app?.excluded || e.kind === "app")), JSON.stringify(off.slice(-3)));
|
|
237
|
+
siteOff.socket.destroy();
|
|
238
|
+
|
|
239
|
+
// 网页输入框与密码框
|
|
240
|
+
t0 = Date.now();
|
|
241
|
+
spawn(chrome, [...flags(profile), "data:text/html,<title>Form</title><input id=a autofocus placeholder=Name>"], { detached: true, stdio: "ignore" }).unref();
|
|
242
|
+
await sleep(4000);
|
|
243
|
+
const formWin = mainWindowOf("chrome").find((w) => w.title.startsWith("Form"));
|
|
244
|
+
if (formWin) focus(formWin.hwnd);
|
|
245
|
+
await sleep(1500);
|
|
246
|
+
sendKeys("plain words");
|
|
247
|
+
await sleep(3500);
|
|
248
|
+
const webText = since(main.events, t0).filter((e) => e.kind === "text");
|
|
249
|
+
check("typing in a web input is recorded", webText.some((e) => e.text?.includes("plain words")), JSON.stringify(webText), false);
|
|
250
|
+
t0 = Date.now();
|
|
251
|
+
spawn(chrome, [...flags(profile), "data:text/html,<title>Login</title><input type=password autofocus>"], { detached: true, stdio: "ignore" }).unref();
|
|
252
|
+
await sleep(4000);
|
|
253
|
+
const loginWin = mainWindowOf("chrome").find((w) => w.title.startsWith("Login"));
|
|
254
|
+
if (loginWin) focus(loginWin.hwnd);
|
|
255
|
+
await sleep(1500);
|
|
256
|
+
sendKeys("hunter2secret");
|
|
257
|
+
await sleep(3500);
|
|
258
|
+
const leaked = since(main.events, t0).filter((e) => JSON.stringify(e).includes("hunter2"));
|
|
259
|
+
check("password fields are never recorded", leaked.length === 0, JSON.stringify(leaked));
|
|
260
|
+
|
|
261
|
+
// 无痕:先关掉前面的普通窗口,免得前台落回普通窗口时误判
|
|
262
|
+
ps("Get-Process chrome -ErrorAction SilentlyContinue | Stop-Process -Force");
|
|
263
|
+
await sleep(1500);
|
|
264
|
+
t0 = Date.now();
|
|
265
|
+
spawn(chrome, [...flags(profile), "--incognito", "https://example.com/incognito-page"], { detached: true, stdio: "ignore" }).unref();
|
|
266
|
+
await sleep(6000);
|
|
267
|
+
const incognito = mainWindowOf("chrome");
|
|
268
|
+
findings.chromeIncognitoTitles = incognito.map((w) => w.title);
|
|
269
|
+
const incWin = incognito[0];
|
|
270
|
+
if (incWin) focus(incWin.hwnd);
|
|
271
|
+
await sleep(2500);
|
|
272
|
+
const incEvents = since(main.events, t0).filter((e) => e.app?.bundleId === "chrome.exe");
|
|
273
|
+
findings.chromeIncognitoEvents = incEvents;
|
|
274
|
+
check("the incognito window was observed at all", incEvents.length > 0, JSON.stringify(incEvents));
|
|
275
|
+
check("Chrome incognito never leaks title or URL", incEvents.every((e) => !String(e.title ?? "").length || !/incognito-page|Example/.test(e.title)) && incEvents.every((e) => !String(e.url ?? "").includes("incognito-page")), JSON.stringify(incEvents));
|
|
276
|
+
} else {
|
|
277
|
+
check("Chrome is installed on the runner", false, "skipped Chrome scenarios", false);
|
|
278
|
+
}
|
|
279
|
+
if (edge) {
|
|
280
|
+
t0 = Date.now();
|
|
281
|
+
const profile = mkdtempSync(path.join(os.tmpdir(), "probe-edge-"));
|
|
282
|
+
spawn(edge, [...flags(profile), "--inprivate", "https://example.com/inprivate-page"], { detached: true, stdio: "ignore" }).unref();
|
|
283
|
+
await sleep(8000);
|
|
284
|
+
const wins = mainWindowOf("msedge");
|
|
285
|
+
findings.edgeInPrivateTitles = wins.map((w) => w.title);
|
|
286
|
+
const w = wins.find((x) => /inprivate/i.test(x.title)) ?? wins.at(-1);
|
|
287
|
+
if (w) focus(w.hwnd);
|
|
288
|
+
await sleep(2500);
|
|
289
|
+
const edgeEvents = since(main.events, t0).filter((e) => e.app?.bundleId === "msedge.exe");
|
|
290
|
+
findings.edgeInPrivateEvents = edgeEvents;
|
|
291
|
+
check("the InPrivate window was observed at all", edgeEvents.length > 0, JSON.stringify(edgeEvents));
|
|
292
|
+
check("Edge InPrivate never leaks title or URL", edgeEvents.every((e) => !/inprivate-page|Example/.test(e.title ?? "") && !String(e.url ?? "").includes("inprivate-page")), JSON.stringify(edgeEvents));
|
|
293
|
+
} else {
|
|
294
|
+
check("Edge is installed on the runner", false, "skipped Edge scenarios", false);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── 退订后采集器拆干净、空闲后退出 ──
|
|
298
|
+
main.socket.destroy();
|
|
299
|
+
findings.allEvents = main.events;
|
|
300
|
+
await sleep(1000);
|
|
301
|
+
const diag = await diagnostics();
|
|
302
|
+
check("diagnostics after unsubscribe reports no subscribers", diag?.result?.subscribers === 0, JSON.stringify(diag));
|
|
303
|
+
notepad.unref();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function diagnostics() {
|
|
307
|
+
return new Promise((resolve) => {
|
|
308
|
+
const s = net.createConnection(pipe);
|
|
309
|
+
let buf = "";
|
|
310
|
+
s.setEncoding("utf8");
|
|
311
|
+
s.on("connect", () => s.write(`${JSON.stringify({ id: "diag", cmd: "diagnostics" })}\n`));
|
|
312
|
+
s.on("data", (c) => { buf += c; if (buf.includes("\n")) { s.destroy(); resolve(JSON.parse(buf.split("\n")[0])); } });
|
|
313
|
+
s.on("error", () => resolve(null));
|
|
314
|
+
setTimeout(() => resolve(null), 5000);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** stdio 助手:listRoots(pid) → focusWindow(rootRef)。 */
|
|
319
|
+
function stdioFocus(pid) {
|
|
320
|
+
return new Promise((resolve) => {
|
|
321
|
+
const child = spawn(exe, [], { stdio: ["pipe", "pipe", "inherit"] });
|
|
322
|
+
let buf = "";
|
|
323
|
+
const send = (id, cmd, args) => child.stdin.write(`${JSON.stringify({ protocolVersion: 4, id, cmd, args })}\n`);
|
|
324
|
+
child.stdout.setEncoding("utf8");
|
|
325
|
+
child.stdout.on("data", (chunk) => {
|
|
326
|
+
buf += chunk;
|
|
327
|
+
const lines = buf.split("\n");
|
|
328
|
+
buf = lines.pop() ?? "";
|
|
329
|
+
for (const line of lines) {
|
|
330
|
+
if (!line.trim()) continue;
|
|
331
|
+
const msg = JSON.parse(line);
|
|
332
|
+
if (msg.id === "roots") {
|
|
333
|
+
const root = (msg.result?.roots ?? msg.result?.windows ?? []).find((r) => r.rootRef);
|
|
334
|
+
if (!root) { child.kill(); return resolve({ error: "no root", msg }); }
|
|
335
|
+
send("focus", "focusWindow", { rootRef: root.rootRef });
|
|
336
|
+
} else if (msg.id === "focus") {
|
|
337
|
+
setTimeout(() => child.kill(), 1500);
|
|
338
|
+
resolve(msg);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
send("roots", "listRoots", { pid });
|
|
343
|
+
setTimeout(() => { child.kill(); resolve({ error: "timeout" }); }, 15_000);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
await main();
|
|
349
|
+
} catch (e) {
|
|
350
|
+
check("probe ran to completion", false, String(e?.stack || e));
|
|
351
|
+
}
|
|
352
|
+
writeFileSync(outPath, JSON.stringify({ results, findings }, null, 2));
|
|
353
|
+
server.kill();
|
|
354
|
+
const failed = results.filter((r) => r.required && !r.ok);
|
|
355
|
+
console.log(`\n${results.length - failed.length}/${results.length} checks passed${failed.length ? `; FAILED: ${failed.map((f) => f.name).join(", ")}` : ""}`);
|
|
356
|
+
process.exit(failed.length ? 1 : 0);
|
|
@@ -11,6 +11,10 @@ The Computer Use tools let you observe and control any on-screen application thr
|
|
|
11
11
|
tree, OCR, and screenshots. Reach for them only when the job genuinely needs a GUI: an API, a CLI, or
|
|
12
12
|
reading a file directly is always cheaper and more reliable.
|
|
13
13
|
|
|
14
|
+
Forsion's own windows are not a Computer Use target. To change a Forsion setting or open one of its
|
|
15
|
+
pages, use `list_ui_commands` and then `run_ui_command` / `set_ui_setting`; a tool result marked
|
|
16
|
+
`[forsion]` means you are looking at Forsion itself.
|
|
17
|
+
|
|
14
18
|
For a page you can simply fetch, use `curl` through bash. For pure web work prefer `browser_task` /
|
|
15
19
|
`browser_*` — they are lighter. Use Computer Use when the workflow lives in a native desktop app, or
|
|
16
20
|
when a web page must be handled inside the same window forest as a desktop app.
|
package/src/foregroundNote.ts
CHANGED
|
@@ -41,3 +41,19 @@ export function foregroundNote(execution: unknown): string {
|
|
|
41
41
|
const why = steps.map((s) => s?.escalationReason).find(Boolean);
|
|
42
42
|
return `\n[foreground] This action took the foreground (focus/pointer was moved to the target)${why ? ` — ${why}` : ''}. To keep actions in the background, prefer setText to fill fields; strict background-only is the plugin's "strict background" setting (act_ui no longer takes a headless parameter).`;
|
|
43
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 自家窗口提示:目标窗口属于拉起本引擎的那个桌面应用(= Forsion 自己;托管引擎是它的直接子进程)时,
|
|
47
|
+
* 明说一句「改用界面命令」。Forsion 反馈 6a239e58:agent 用 Computer Use 去点 Forsion 自己的设置窗口,
|
|
48
|
+
* 界面一重渲染元素引用就失效(一轮里 3 次 stale ref + 1 次 stale state),而同一件事 run_ui_command 一次就办完。
|
|
49
|
+
* 只提示不拦:没有对应界面命令的地方(插件自绘页)仍只能这样操作。
|
|
50
|
+
* TUI / 独立运行时父进程是 shell(没有窗口)、父进程已死时 ppid=1,都不会命中。
|
|
51
|
+
* 读的是 details.target.pid(observe / act / wait)与 details.windows[].pid(find_roots);形状变了就静默返回空串。
|
|
52
|
+
*/
|
|
53
|
+
export function selfWindowNote(details: unknown, hostPid: number = process.ppid): string {
|
|
54
|
+
const d = details as { target?: { pid?: unknown }; windows?: Array<{ pid?: unknown }> } | null | undefined;
|
|
55
|
+
if (!d || typeof d !== 'object' || !(hostPid > 1)) return '';
|
|
56
|
+
const own = d.target?.pid === hostPid || (Array.isArray(d.windows) && d.windows.some((w) => w?.pid === hostPid));
|
|
57
|
+
if (!own) return '';
|
|
58
|
+
return `\n[forsion] pid ${hostPid} is Forsion itself, the app you are running in. Operate Forsion through its own UI tools (list_ui_commands, then run_ui_command / set_ui_setting): they act directly and report the outcome. Computer Use on Forsion's own windows is unreliable because the UI re-renders under you and element refs go stale; keep it for controls that have no UI command.`;
|
|
59
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -41,6 +41,8 @@ const plugin: TanguPlugin = {
|
|
|
41
41
|
? 'On macOS, if the target app may not be running yet, call ensure_app first — it starts the app in the background (no focus steal).'
|
|
42
42
|
: 'On Windows and Linux, ensure_app is unavailable. Start with find_roots to discover running apps. If necessary, launch an existing app using a verified executable or platform app launcher, then observe again. Do not guess app paths or treat ensure_app absence as failure of the observation tools.',
|
|
43
43
|
'Prefer the background path: to fill a field use a single setText action (writes the value without taking focus) rather than click-then-type; the foreground is taken only when an action genuinely needs it, and the result says so on a [foreground] line — when it does, tell the user. Strict background-only is a plugin setting, not a tool parameter.',
|
|
44
|
+
// The desktop side panel (Forsion chat docked beside an app window) prefixes every message with this token.
|
|
45
|
+
'A user message that starts with <forsion-app app="…" bundleId="…" pid="…" windowId="…" title="…" /> was sent from a chat panel docked beside that app window: the user is working in it, so it is the default target. Questions about "this", "here" or "the selection" refer to it, and a quoted block right after the token is what they had selected there. Locate it with find_roots({pid}) (fall back to bundleId or app if the pid is gone) and continue with observe_ui. The token only names the window — you still need the tools to see its contents.',
|
|
44
46
|
'For pure web tasks prefer browser_task / browser_* (lighter). Use the Computer Use browser tools (launch/navigate/evaluate_browser) only when a desktop workflow must touch a web page within the same @r root forest.',
|
|
45
47
|
// Working discipline: re-observe before acting, derive element refs from the latest observation (they go stale), prefer the AX text and only screenshot when it is incomplete.
|
|
46
48
|
'Re-observe (observe_ui) right before you act; take element references from the latest observation only — never reuse a reference from an earlier state. Prefer the accessibility text; fall back to a screenshot when the tree is incomplete.',
|
package/src/tools.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
import { getComputerUseConfig } from './vendor/config.ts';
|
|
21
21
|
import { isSupportedPlatform } from './helperState.ts';
|
|
22
22
|
import { autoInstallFailed, ensureHelperCurrent, guideMissingPermissions } from './onboarding.ts';
|
|
23
|
-
import { foregroundNote } from './foregroundNote.ts';
|
|
23
|
+
import { foregroundNote, selfWindowNote } from './foregroundNote.ts';
|
|
24
24
|
|
|
25
25
|
const execFileP = promisify(execFile);
|
|
26
26
|
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
@@ -189,7 +189,7 @@ export function makePiCtx(tcx: ToolContext, hasUI: boolean): ExtensionContext {
|
|
|
189
189
|
};
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
/** AgentToolResult → 返回给模型的 string;image block(截图)经 collectImage
|
|
192
|
+
/** AgentToolResult → 返回给模型的 string;image block(截图)经 collectImage 回灌;末尾附前台透明化提示与「这是 Forsion 自己的窗口」提示。 */
|
|
193
193
|
function render(result: AgentToolResult, tcx: ToolContext): string {
|
|
194
194
|
const parts: string[] = [];
|
|
195
195
|
for (const block of result.content) {
|
|
@@ -199,7 +199,8 @@ function render(result: AgentToolResult, tcx: ToolContext): string {
|
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
201
|
const body = parts.join('\n') || '(no output)';
|
|
202
|
-
|
|
202
|
+
const details = (result as { details?: { execution?: unknown } }).details;
|
|
203
|
+
return body + foregroundNote(details?.execution) + selfWindowNote(details);
|
|
203
204
|
}
|
|
204
205
|
|
|
205
206
|
/** ensure_app:后台启动目标 app(open -g,不抢前台)+ 轮询 find_roots 直到有可观察窗口,回其 roots。 */
|
|
@@ -10,7 +10,7 @@ import type { PlatformDiagnostics } from "../types.ts";
|
|
|
10
10
|
import { resolveMacosHelperAppPath } from "./helper-path.mjs";
|
|
11
11
|
|
|
12
12
|
const COMMAND_TIMEOUT_MS = 15_000;
|
|
13
|
-
const HELPER_PROTOCOL_VERSION =
|
|
13
|
+
const HELPER_PROTOCOL_VERSION = 14;
|
|
14
14
|
const HELPER_SETUP_TIMEOUT_MS = 60_000;
|
|
15
15
|
|
|
16
16
|
export const HELPER_BUNDLE_ID = "com.forsion.tangu-computer-use";
|
|
@@ -14,6 +14,15 @@ function foregroundNote(execution) {
|
|
|
14
14
|
return `
|
|
15
15
|
[foreground] This action took the foreground (focus/pointer was moved to the target)${why ? ` \u2014 ${why}` : ""}. To keep actions in the background, prefer setText to fill fields; strict background-only is the plugin's "strict background" setting (act_ui no longer takes a headless parameter).`;
|
|
16
16
|
}
|
|
17
|
+
function selfWindowNote(details, hostPid = process.ppid) {
|
|
18
|
+
const d = details;
|
|
19
|
+
if (!d || typeof d !== "object" || !(hostPid > 1)) return "";
|
|
20
|
+
const own = d.target?.pid === hostPid || Array.isArray(d.windows) && d.windows.some((w) => w?.pid === hostPid);
|
|
21
|
+
if (!own) return "";
|
|
22
|
+
return `
|
|
23
|
+
[forsion] pid ${hostPid} is Forsion itself, the app you are running in. Operate Forsion through its own UI tools (list_ui_commands, then run_ui_command / set_ui_setting): they act directly and report the outcome. Computer Use on Forsion's own windows is unreliable because the UI re-renders under you and element refs go stale; keep it for controls that have no UI command.`;
|
|
24
|
+
}
|
|
17
25
|
export {
|
|
18
|
-
foregroundNote
|
|
26
|
+
foregroundNote,
|
|
27
|
+
selfWindowNote
|
|
19
28
|
};
|
|
@@ -171,7 +171,7 @@ var init_helper = __esm({
|
|
|
171
171
|
init_coerce();
|
|
172
172
|
init_helper_path();
|
|
173
173
|
COMMAND_TIMEOUT_MS2 = 15e3;
|
|
174
|
-
HELPER_PROTOCOL_VERSION =
|
|
174
|
+
HELPER_PROTOCOL_VERSION = 14;
|
|
175
175
|
HELPER_SETUP_TIMEOUT_MS = 6e4;
|
|
176
176
|
HELPER_BUNDLE_ID = "com.forsion.tangu-computer-use";
|
|
177
177
|
HELPER_APP_PATH = resolveMacosHelperAppPath();
|
|
@@ -5283,6 +5283,14 @@ function foregroundNote(execution) {
|
|
|
5283
5283
|
return `
|
|
5284
5284
|
[foreground] This action took the foreground (focus/pointer was moved to the target)${why ? ` \u2014 ${why}` : ""}. To keep actions in the background, prefer setText to fill fields; strict background-only is the plugin's "strict background" setting (act_ui no longer takes a headless parameter).`;
|
|
5285
5285
|
}
|
|
5286
|
+
function selfWindowNote(details, hostPid = process.ppid) {
|
|
5287
|
+
const d = details;
|
|
5288
|
+
if (!d || typeof d !== "object" || !(hostPid > 1)) return "";
|
|
5289
|
+
const own = d.target?.pid === hostPid || Array.isArray(d.windows) && d.windows.some((w) => w?.pid === hostPid);
|
|
5290
|
+
if (!own) return "";
|
|
5291
|
+
return `
|
|
5292
|
+
[forsion] pid ${hostPid} is Forsion itself, the app you are running in. Operate Forsion through its own UI tools (list_ui_commands, then run_ui_command / set_ui_setting): they act directly and report the outcome. Computer Use on Forsion's own windows is unreliable because the UI re-renders under you and element refs go stale; keep it for controls that have no UI command.`;
|
|
5293
|
+
}
|
|
5286
5294
|
|
|
5287
5295
|
// src/tools.ts
|
|
5288
5296
|
var execFileP = promisify(execFile);
|
|
@@ -5467,7 +5475,8 @@ function render(result, tcx) {
|
|
|
5467
5475
|
}
|
|
5468
5476
|
}
|
|
5469
5477
|
const body = parts.join("\n") || "(no output)";
|
|
5470
|
-
|
|
5478
|
+
const details = result.details;
|
|
5479
|
+
return body + foregroundNote(details?.execution) + selfWindowNote(details);
|
|
5471
5480
|
}
|
|
5472
5481
|
async function runEnsureApp(args, tcx, pctx) {
|
|
5473
5482
|
if (process.platform !== "darwin") {
|
|
@@ -5648,6 +5657,8 @@ var plugin = {
|
|
|
5648
5657
|
"These control ANY on-screen application through its accessibility tree + OCR + screenshots \u2014 use them when the task needs a desktop app rather than an API/CLI/file.",
|
|
5649
5658
|
process.platform === "darwin" ? "On macOS, if the target app may not be running yet, call ensure_app first \u2014 it starts the app in the background (no focus steal)." : "On Windows and Linux, ensure_app is unavailable. Start with find_roots to discover running apps. If necessary, launch an existing app using a verified executable or platform app launcher, then observe again. Do not guess app paths or treat ensure_app absence as failure of the observation tools.",
|
|
5650
5659
|
"Prefer the background path: to fill a field use a single setText action (writes the value without taking focus) rather than click-then-type; the foreground is taken only when an action genuinely needs it, and the result says so on a [foreground] line \u2014 when it does, tell the user. Strict background-only is a plugin setting, not a tool parameter.",
|
|
5660
|
+
// The desktop side panel (Forsion chat docked beside an app window) prefixes every message with this token.
|
|
5661
|
+
'A user message that starts with <forsion-app app="\u2026" bundleId="\u2026" pid="\u2026" windowId="\u2026" title="\u2026" /> was sent from a chat panel docked beside that app window: the user is working in it, so it is the default target. Questions about "this", "here" or "the selection" refer to it, and a quoted block right after the token is what they had selected there. Locate it with find_roots({pid}) (fall back to bundleId or app if the pid is gone) and continue with observe_ui. The token only names the window \u2014 you still need the tools to see its contents.',
|
|
5651
5662
|
"For pure web tasks prefer browser_task / browser_* (lighter). Use the Computer Use browser tools (launch/navigate/evaluate_browser) only when a desktop workflow must touch a web page within the same @r root forest.",
|
|
5652
5663
|
// Working discipline: re-observe before acting, derive element refs from the latest observation (they go stale), prefer the AX text and only screenshot when it is incomplete.
|
|
5653
5664
|
"Re-observe (observe_ui) right before you act; take element references from the latest observation only \u2014 never reuse a reference from an earlier state. Prefer the accessibility text; fall back to a screenshot when the tree is incomplete.",
|