@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
package/src/setup.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tangu computer-use <setup|doctor|stop>`。复用 vendor 的 ensureComputerUseSetup —— 给它一个 readline 版
|
|
3
|
+
* ExtensionContext(hasUI=true),vendor 的 ensurePermissions 交互式引导就跑在 CLI 上。doctor 用 hasUI=false
|
|
4
|
+
* 走非交互检查(权限缺 → vendor 抛指导文本)。
|
|
5
|
+
*/
|
|
6
|
+
import { createInterface } from 'node:readline/promises';
|
|
7
|
+
import { ensureComputerUseSetup, shutdownComputerUseSession } from './vendor/bridge.ts';
|
|
8
|
+
import { helperInstalled, helperExecutablePath } from './helperState.ts';
|
|
9
|
+
import type { ExtensionContext } from './pi-compat.ts';
|
|
10
|
+
|
|
11
|
+
function cliCtx(hasUI: boolean): ExtensionContext {
|
|
12
|
+
return {
|
|
13
|
+
cwd: process.cwd(),
|
|
14
|
+
hasUI,
|
|
15
|
+
ui: {
|
|
16
|
+
notify: (msg, level) => console.log(`[${level || 'info'}] ${msg}`),
|
|
17
|
+
select: async (prompt, options) => {
|
|
18
|
+
console.log(prompt);
|
|
19
|
+
options.forEach((o, i) => console.log(` ${i + 1}. ${o}`));
|
|
20
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
21
|
+
try {
|
|
22
|
+
const a = (await rl.question('> ')).trim();
|
|
23
|
+
const n = Number.parseInt(a, 10);
|
|
24
|
+
return Number.isFinite(n) && n >= 1 && n <= options.length ? options[n - 1] : a;
|
|
25
|
+
} finally {
|
|
26
|
+
rl.close();
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
sessionManager: { getBranch: () => [] },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function cliMain(argv: string[]): Promise<number> {
|
|
35
|
+
const sub = argv[0];
|
|
36
|
+
// setup / doctor 是一次性命令:返回前释放 helper 会话。Windows / Linux 的 helper 是 stdio 子进程,管道开着
|
|
37
|
+
// 事件循环就空不了,结果打印完进程也退不出(09-25 windows-2022 实测 doctor 挂满 90s)。宿主刻意不在插件命令后
|
|
38
|
+
// 强退(tangu worker 这类命令靠打开的句柄常驻),只能命令自己收。macOS 的常驻 daemon 不受影响(会话关闭本就不杀它)。
|
|
39
|
+
if (sub === 'setup' || sub === 'doctor') {
|
|
40
|
+
try {
|
|
41
|
+
return await (sub === 'setup' ? setup() : doctor());
|
|
42
|
+
} finally {
|
|
43
|
+
await shutdownComputerUseSession().catch(() => {}); // 收尾失败不能盖掉命令本身的结果
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (sub === 'stop') {
|
|
47
|
+
await shutdownComputerUseSession();
|
|
48
|
+
// mac 的 helper 是常驻 daemon,vendor 的 session shutdown 故意不杀它(TCC 身份稳定、下次秒连)。
|
|
49
|
+
// 但用户敲 stop 的预期就是「别再碰我屏幕」→ 真杀;杀进程不影响 TCC 授权,下次使用会自动重启。
|
|
50
|
+
if (process.platform === 'darwin') {
|
|
51
|
+
const { execFile } = await import('node:child_process');
|
|
52
|
+
await new Promise<void>((res) => execFile('pkill', ['-f', helperExecutablePath()], () => res()));
|
|
53
|
+
}
|
|
54
|
+
console.log('✓ helper stopped.');
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
console.log('usage: tangu computer-use <setup|doctor|stop>');
|
|
58
|
+
return sub ? 1 : 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function setup(): Promise<number> {
|
|
62
|
+
try {
|
|
63
|
+
await ensureComputerUseSetup(cliCtx(true));
|
|
64
|
+
console.log('✓ Computer Use is ready.');
|
|
65
|
+
return 0;
|
|
66
|
+
} catch (e: any) {
|
|
67
|
+
console.error(`✗ ${e?.message || e}`);
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function doctor(): Promise<number> {
|
|
73
|
+
if (!helperInstalled()) {
|
|
74
|
+
console.log(`✗ native helper not installed (expected at ${helperExecutablePath()}).`);
|
|
75
|
+
console.log(' Fix: run `tangu computer-use setup`.');
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
await ensureComputerUseSetup(cliCtx(false)); // helper 已装,这里只查权限;缺 → 抛指导文本
|
|
80
|
+
console.log('✓ helper installed, permissions granted, backend ready.');
|
|
81
|
+
return 0;
|
|
82
|
+
} catch (e: any) {
|
|
83
|
+
const msg = String(e?.message || e);
|
|
84
|
+
console.log(`✗ not ready: ${msg}`);
|
|
85
|
+
// 协议不匹配 = 装着的 helper 二进制比宿主老(新命令/新原生功能它都没有)。
|
|
86
|
+
// 重装会换二进制,macOS 因此可能重置辅助功能/录屏授权,setup 默认会拒绝 ad-hoc 覆盖 —— 明说怎么绕。
|
|
87
|
+
if (/protocol/i.test(msg)) {
|
|
88
|
+
console.log(' The installed helper binary is older than this plugin.');
|
|
89
|
+
console.log(' Fix: PI_COMPUTER_USE_ALLOW_ADHOC_UPDATE=1 tangu computer-use setup');
|
|
90
|
+
console.log(' then `tangu computer-use stop` so the new binary takes over.');
|
|
91
|
+
console.log(' macOS will ask you to re-grant Accessibility + Screen Recording.');
|
|
92
|
+
} else {
|
|
93
|
+
console.log(' Fix: run `tangu computer-use setup`.');
|
|
94
|
+
}
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 上游 pi 的 11 个 defineTool + 自研 ensure_app → Tangu 的 12 个 ToolDef。翻译要点:
|
|
3
|
+
* - parameters:上游 typebox schema 手翻成纯 JSON Schema 字面量(零依赖,不引 typebox)。
|
|
4
|
+
* - execute 签名:pi 的 (toolCallId, params, signal, onUpdate, ctx) → Tangu 的 (args, ctx)。
|
|
5
|
+
* - 结果:AgentToolResult.content 的 text block 拼成返回 string;image block(observe 截图)→ ctx.collectImage(回灌模型)。
|
|
6
|
+
* - 门禁:host + hostExec + 插件启用 + macOS(stickers 同款);浏览器三工具再叠 browser_use 设置。
|
|
7
|
+
* - 审批:动作类(act_ui/launch/navigate/evaluate_browser)声明 approval:'command'(与 run_bash 同档);观察类免审。
|
|
8
|
+
* - setup:每次工具执行前惰性 ensureComputerUseSetup(hasUI=false)→ 权限缺时 vendor 抛 nonInteractiveError,
|
|
9
|
+
* 被 catch 成指导文本返回给模型(提示跑 `tangu computer-use setup`)。
|
|
10
|
+
*/
|
|
11
|
+
import { execFile } from 'node:child_process';
|
|
12
|
+
import { promisify } from 'node:util';
|
|
13
|
+
import type { ToolDef, ToolProvider, ToolContext, AppProfile, PluginStore } from '@forsion/tangu-agent';
|
|
14
|
+
import type { ExtensionContext, AgentToolResult } from './pi-compat.ts';
|
|
15
|
+
import {
|
|
16
|
+
executeFind, executeObserve, executeSearchUi, executeExpandUi, executeInspectUi,
|
|
17
|
+
executeAct, executeReadText, executeWaitFor, executeLaunchBrowser, executeNavigateBrowser,
|
|
18
|
+
executeEvaluateBrowser, ensureComputerUseSetup,
|
|
19
|
+
} from './vendor/bridge.ts';
|
|
20
|
+
import { getComputerUseConfig } from './vendor/config.ts';
|
|
21
|
+
import { isSupportedPlatform } from './helperState.ts';
|
|
22
|
+
import { autoInstallFailed, ensureHelperCurrent, guideMissingPermissions } from './onboarding.ts';
|
|
23
|
+
import { foregroundNote } from './foregroundNote.ts';
|
|
24
|
+
|
|
25
|
+
const execFileP = promisify(execFile);
|
|
26
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
27
|
+
|
|
28
|
+
export const PLUGIN_ID = 'computer-use';
|
|
29
|
+
|
|
30
|
+
type PiExecute = (
|
|
31
|
+
toolCallId: string,
|
|
32
|
+
params: any,
|
|
33
|
+
signal: AbortSignal | undefined,
|
|
34
|
+
onUpdate: undefined,
|
|
35
|
+
ctx: ExtensionContext,
|
|
36
|
+
) => Promise<AgentToolResult<any>>;
|
|
37
|
+
|
|
38
|
+
// ── JSON Schema 片段(翻译自上游 extensions/computer-use.ts 的 typebox;v0.5.0 契约)──
|
|
39
|
+
// v0.5.0 收紧了整个工具面:动作里删掉 doubleClick(用 clickCount)与 wait(用 wait_for/expect);
|
|
40
|
+
// click 拆成「按 ref」与「按坐标」两个互斥变体(不再混着给);observe 只认 @r root(app/windowTitle
|
|
41
|
+
// 猜名字那套没了);search 用 capability 且不分页(refine 而非 paging);read_text 兼收 @e 与 @o
|
|
42
|
+
// (@o = 输出被截断时给的续读句柄,不需要 stateId);wait_for 与 act.expect 共用同一套条件字段。
|
|
43
|
+
const S = (description?: string, maxLength?: number) => ({ type: 'string', ...(description ? { description } : {}), ...(maxLength ? { maxLength } : {}) });
|
|
44
|
+
const N = (description?: string, extra: Record<string, number> = {}) => ({ type: 'number', ...(description ? { description } : {}), ...extra });
|
|
45
|
+
const stateId = { type: 'string', description: 'Required state id owning every @e ref used by this operation' };
|
|
46
|
+
const mouseButton = { type: 'string', enum: ['left', 'right', 'middle'] };
|
|
47
|
+
const obj = (properties: Record<string, unknown>, required: string[] = []) => ({ type: 'object', properties, required });
|
|
48
|
+
|
|
49
|
+
// wait_for 与 act_ui.expect 共用的条件字段(上游 conditionProperties)。
|
|
50
|
+
const conditionProps = {
|
|
51
|
+
ref: S('Specific @e ref to test', 128),
|
|
52
|
+
scopeRef: S('Restrict matching to this @e subtree', 128),
|
|
53
|
+
text: S('Text that must match', 512),
|
|
54
|
+
role: S('Exact normalized role', 128),
|
|
55
|
+
value: S('Exact normalized value; normally pair with ref'), // 上游此项刻意无 maxLength(要比对的可能是整段文本值)
|
|
56
|
+
until: { type: 'string', enum: ['present', 'absent'], description: 'Desired condition, default present' },
|
|
57
|
+
timeoutMs: N('Maximum wait, default 10000ms', { minimum: 100, maximum: 60_000 }),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const clickCount = N('1-3', { minimum: 1, maximum: 3 });
|
|
61
|
+
const point = { x: { type: 'number' }, y: { type: 'number' } };
|
|
62
|
+
|
|
63
|
+
// act_ui 的 actions:上游 uiAction union(8 变体)→ anyOf
|
|
64
|
+
const uiAction = {
|
|
65
|
+
anyOf: [
|
|
66
|
+
obj({ action: { const: 'press' }, ref: S('Actionable outline ref') }, ['action', 'ref']),
|
|
67
|
+
obj({ action: { const: 'click' }, ref: S('Actionable outline ref'), button: mouseButton, clickCount }, ['action', 'ref']),
|
|
68
|
+
obj({ action: { const: 'click' }, ...point, button: mouseButton, clickCount }, ['action', 'x', 'y']),
|
|
69
|
+
obj({ action: { const: 'setText' }, ref: S('Editable outline ref'), text: { type: 'string' } }, ['action', 'ref', 'text']),
|
|
70
|
+
obj({ action: { const: 'typeText' }, ref: S('Omit after a click to type into the focus established by that click'), text: { type: 'string' } }, ['action', 'text']),
|
|
71
|
+
obj({ action: { const: 'keypress' }, ref: S('Omit to send keys to the focused control'), keys: { type: 'array', items: { type: 'string' }, minItems: 1 } }, ['action', 'keys']),
|
|
72
|
+
obj({ action: { const: 'scroll' }, ref: S('Outline ref to scroll'), scrollX: { type: 'number' }, scrollY: { type: 'number' } }, ['action']),
|
|
73
|
+
obj({ action: { const: 'drag' }, path: { type: 'array', items: obj(point, ['x', 'y']), minItems: 2 } }, ['action', 'path']),
|
|
74
|
+
obj({ action: { const: 'moveMouse' }, ...point }, ['action', 'x', 'y']),
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
interface ToolSpec {
|
|
79
|
+
name: string;
|
|
80
|
+
description: string;
|
|
81
|
+
parameters: ReturnType<typeof obj>;
|
|
82
|
+
exec?: PiExecute;
|
|
83
|
+
// 适配层自有工具(非 vendor 委托),给定则走它,拿到 args/tcx/pctx 直接返回 string。
|
|
84
|
+
run?: (args: Record<string, any>, tcx: ToolContext, pctx: ExtensionContext) => Promise<string>;
|
|
85
|
+
sideEffect: 'read' | 'system' | 'browser';
|
|
86
|
+
approval?: 'command';
|
|
87
|
+
browserOnly?: boolean;
|
|
88
|
+
darwinOnly?: boolean;
|
|
89
|
+
timeoutMs: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const SPECS: ToolSpec[] = [
|
|
93
|
+
{
|
|
94
|
+
name: 'find_roots', exec: executeFind as PiExecute, sideEffect: 'read', timeoutMs: 30_000,
|
|
95
|
+
description: 'Find a bounded, ranked set of controllable UI roots with refs, geometry, and focus state. Find a target root before observe_ui when needed.',
|
|
96
|
+
parameters: obj({
|
|
97
|
+
text: S('Ranked app or title text', 256),
|
|
98
|
+
app: S('Exact normalized app name', 256),
|
|
99
|
+
bundleId: S('Exact bundle id'),
|
|
100
|
+
pid: N('Exact process id'),
|
|
101
|
+
kind: { type: 'string', enum: ['window', 'menu', 'sheet', 'popover', 'dialog', 'browser_page'], description: 'Exact root kind' },
|
|
102
|
+
}),
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: 'observe_ui', exec: executeObserve as PiExecute, sideEffect: 'read', timeoutMs: 60_000,
|
|
106
|
+
description: 'Capture the current/frontmost root or one exact @r root and return a bounded UI outline. Use mode=semantic to skip OCR and images, visual to force them, fused for automatic selection. Primary UI observation tool; follow with search_ui/expand_ui/inspect_ui/act_ui. Use @e outline refs from observe_ui/search_ui for act_ui; pictureOnly refs are coordinate-only.',
|
|
107
|
+
parameters: obj({
|
|
108
|
+
root: S('Exact @r ref issued by find_roots'),
|
|
109
|
+
mode: { type: 'string', enum: ['semantic', 'visual', 'fused'], description: 'Observation mode, default fused' },
|
|
110
|
+
}),
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'search_ui', exec: executeSearchUi as PiExecute, sideEffect: 'read', timeoutMs: 15_000,
|
|
114
|
+
description: 'Return a bounded, deterministically ranked search of the cached outline. At least one predicate is required. Find targets not shown in the compact observe_ui output; refine broad searches instead of paging matches.',
|
|
115
|
+
parameters: obj({ text: S('Human-readable text or label', 256), role: S('Exact normalized role, e.g. button', 128), capability: S('Exact capability, e.g. press', 128), stateId }, ['stateId']),
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'expand_ui', exec: executeExpandUi as PiExecute, sideEffect: 'read', timeoutMs: 15_000,
|
|
119
|
+
description: 'Unfold bounded local outline context for one @e ref. Expand a specific ref instead of dumping unrelated UI.',
|
|
120
|
+
parameters: obj({ ref: S('Outline ref from observe_ui/search_ui, e.g. @e12'), depth: N('Subtree depth, default 3', { minimum: 1, maximum: 8 }), stateId }, ['ref', 'stateId']),
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'inspect_ui', exec: executeInspectUi as PiExecute, sideEffect: 'read', timeoutMs: 15_000,
|
|
124
|
+
description: 'Inspect one exact outline ref with fields, geometry, capabilities, and annotations. Use when a target\'s evidence or provenance matters.',
|
|
125
|
+
parameters: obj({ ref: S('Outline ref from observe_ui/search_ui, e.g. @e12'), stateId }, ['ref', 'stateId']),
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'act_ui', exec: executeAct as PiExecute, sideEffect: 'system', approval: 'command', timeoutMs: 120_000,
|
|
129
|
+
description: 'Perform one or more precisely targeted checked actions and return the successor state. Pass dependent click/type steps together and use expect for observable completion instead of a separate observe_ui call. After clicking an editable region, omit ref from typeText/keypress so input follows the established focus. To fill a text field prefer a single setText action — it writes the value in the background without taking focus; only click the field first (which takes the foreground) if setText reports it did not take, as happens on some web/Electron inputs. When any action takes the foreground the result says so on a [foreground] line. For strict background-only runs, turn on the plugin\'s "strict background" setting.',
|
|
130
|
+
parameters: obj({
|
|
131
|
+
stateId,
|
|
132
|
+
expect: obj(conditionProps),
|
|
133
|
+
actions: { type: 'array', items: uiAction, minItems: 1, maxItems: 20 },
|
|
134
|
+
}, ['stateId', 'actions']),
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'ensure_app', run: runEnsureApp, sideEffect: 'system', darwinOnly: true, timeoutMs: 25_000,
|
|
138
|
+
description: 'Make sure a desktop app is running and observable WITHOUT bringing it to the foreground, then return its UI roots. Use this before observe_ui when the target app may not be running yet (observe_ui only sees already-running apps). Background-launches by bundleId (preferred) or app name and waits until a window is observable. macOS only.',
|
|
139
|
+
parameters: obj({
|
|
140
|
+
app: S('App name, e.g. "TextEdit"'),
|
|
141
|
+
bundleId: S('Bundle id, e.g. "com.apple.TextEdit" (preferred — unambiguous)'),
|
|
142
|
+
}),
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'read_text', exec: executeReadText as PiExecute, sideEffect: 'read', timeoutMs: 30_000,
|
|
146
|
+
description: 'Read a fixed-size page from an @e UI ref or immutable @o truncated-output ref. Use @e with its stateId; @o continuation refs (handed out when a tool result was truncated) do not need stateId.',
|
|
147
|
+
parameters: obj({ ref: S('@e UI ref or @o output ref'), offset: N('Byte offset, default 0', { minimum: 0 }), stateId }, ['ref']),
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: 'wait_for', exec: executeWaitFor as PiExecute, sideEffect: 'read', timeoutMs: 30_000,
|
|
151
|
+
description: 'Wait for one scoped UI condition and return the successor state. Use after asynchronous UI changes instead of polling observe_ui.',
|
|
152
|
+
parameters: obj({ ...conditionProps, stateId }, ['stateId']),
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: 'launch_browser', exec: executeLaunchBrowser as PiExecute, sideEffect: 'browser', approval: 'command', browserOnly: true, timeoutMs: 60_000,
|
|
156
|
+
description: 'Launch the configured Tangu-managed CDP browser and return an observed browser-page state. Use for browser work that needs a managed CDP context. Prefer curl through bash when the page is directly fetchable. Which browser starts is a plugin setting, not a parameter.',
|
|
157
|
+
parameters: obj({ url: S('Initial URL', 8192) }),
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'navigate_browser', exec: executeNavigateBrowser as PiExecute, sideEffect: 'browser', approval: 'command', browserOnly: true, timeoutMs: 60_000,
|
|
161
|
+
description: 'Navigate an observed CDP browser-page state to an HTTP(S) URL. Native browser windows use act_ui; this tool is CDP-only.',
|
|
162
|
+
parameters: obj({ url: S('HTTP(S) URL', 8192), stateId }, ['url', 'stateId']),
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: 'evaluate_browser', exec: executeEvaluateBrowser as PiExecute, sideEffect: 'browser', approval: 'command', browserOnly: true, timeoutMs: 30_000,
|
|
166
|
+
description: 'Evaluate targeted JavaScript in a CDP browser-page state; returned output is strictly bounded. Prefer observe/search/read; return selected fields, aggregates, or bounded slices.',
|
|
167
|
+
parameters: obj({ stateId, expression: S('JavaScript expression', 65_536) }, ['stateId', 'expression']),
|
|
168
|
+
},
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
/** 把 pluginStore 的设置就地注入 vendor 的 activeConfig(getComputerUseConfig 返回其引用)。 */
|
|
172
|
+
export function syncSettings(store: PluginStore): void {
|
|
173
|
+
const s = store.getScopeSettings(PLUGIN_ID, 'global');
|
|
174
|
+
const cfg = getComputerUseConfig();
|
|
175
|
+
if (typeof s.browser_use === 'boolean') cfg.browser_use = s.browser_use;
|
|
176
|
+
if (typeof s.headless === 'boolean') cfg.headless = s.headless;
|
|
177
|
+
if (typeof s.cursor_overlay === 'boolean') cfg.cursor_overlay = s.cursor_overlay;
|
|
178
|
+
if (s.managed_browser === 'chrome' || s.managed_browser === 'helium') cfg.managed_browser = s.managed_browser;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 构造 vendor 需要的 ExtensionContext。工具执行 hasUI=false(权限缺→vendor 抛指导文本);sessionManager 给空 stub。 */
|
|
182
|
+
export function makePiCtx(tcx: ToolContext, hasUI: boolean): ExtensionContext {
|
|
183
|
+
return {
|
|
184
|
+
cwd: tcx.cwd || process.cwd(),
|
|
185
|
+
hasUI,
|
|
186
|
+
ui: { notify: () => {}, select: async () => undefined },
|
|
187
|
+
sessionManager: { getBranch: () => [] },
|
|
188
|
+
signal: tcx.signal,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** AgentToolResult → 返回给模型的 string;image block(截图)经 collectImage 回灌;末尾附前台透明化提示。 */
|
|
193
|
+
function render(result: AgentToolResult, tcx: ToolContext): string {
|
|
194
|
+
const parts: string[] = [];
|
|
195
|
+
for (const block of result.content) {
|
|
196
|
+
if (block.type === 'text') parts.push(block.text);
|
|
197
|
+
else if (block.type === 'image' && tcx.collectImage) {
|
|
198
|
+
tcx.collectImage({ url: `data:${block.mimeType || 'image/jpeg'};base64,${block.data}`, name: 'screen.jpg' });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const body = parts.join('\n') || '(no output)';
|
|
202
|
+
return body + foregroundNote((result as { details?: { execution?: unknown } }).details?.execution);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** ensure_app:后台启动目标 app(open -g,不抢前台)+ 轮询 find_roots 直到有可观察窗口,回其 roots。 */
|
|
206
|
+
async function runEnsureApp(args: Record<string, any>, tcx: ToolContext, pctx: ExtensionContext): Promise<string> {
|
|
207
|
+
if (process.platform !== 'darwin') {
|
|
208
|
+
return 'ensure_app (background launch) is macOS-only. On other platforms, launch the app yourself, then use observe_ui.';
|
|
209
|
+
}
|
|
210
|
+
const bundleId = typeof args.bundleId === 'string' ? args.bundleId.trim() : '';
|
|
211
|
+
const app = typeof args.app === 'string' ? args.app.trim() : '';
|
|
212
|
+
if (!bundleId && !app) return 'ensure_app requires "app" or "bundleId".';
|
|
213
|
+
// -g:启动但不置前台。execFile 传 argv(非 shell),bundleId/app 作单参无注入。
|
|
214
|
+
const openArgs = ['-g', ...(bundleId ? ['-b', bundleId] : ['-a', app])];
|
|
215
|
+
try {
|
|
216
|
+
await execFileP('open', openArgs, { timeout: 8_000 });
|
|
217
|
+
} catch (e: any) {
|
|
218
|
+
return `Could not launch ${bundleId || app}: ${e?.stderr?.toString().trim() || e?.message || e}. Check the app/bundleId is installed.`;
|
|
219
|
+
}
|
|
220
|
+
const deadline = Date.now() + 12_000;
|
|
221
|
+
let last = '';
|
|
222
|
+
while (Date.now() < deadline) {
|
|
223
|
+
if (tcx.signal?.aborted) return 'ensure_app aborted.';
|
|
224
|
+
const found = await spec_executeFind(pctx, tcx, app, bundleId);
|
|
225
|
+
last = found;
|
|
226
|
+
if (/@r\d/.test(found)) return `Launched ${bundleId || app} in the background (no foreground focus).\n${found}`;
|
|
227
|
+
await sleep(400);
|
|
228
|
+
}
|
|
229
|
+
return `Launched ${bundleId || app} in the background, but no window is observable yet. Give it a moment and call observe_ui.\n${last}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 内部:调 vendor executeFind 并渲染(供 ensure_app 轮询)。
|
|
233
|
+
* bundleId 是精确匹配,有就用它;只有 app 名时走 **text**(排序匹配)而不是 `app` ——
|
|
234
|
+
* v0.5.0 起 `app` 是「精确的规范化 app 名」,用户随口给的 "TextEdit" 未必对得上,
|
|
235
|
+
* 精确匹配落空会让 ensure_app 白等满 12 秒再报「没有可观察窗口」。 */
|
|
236
|
+
async function spec_executeFind(pctx: ExtensionContext, tcx: ToolContext, app: string, bundleId: string): Promise<string> {
|
|
237
|
+
try {
|
|
238
|
+
const params = bundleId ? { bundleId } : { text: app };
|
|
239
|
+
const r = await executeFind('', params, tcx.signal, undefined, pctx);
|
|
240
|
+
return render(r, tcx);
|
|
241
|
+
} catch {
|
|
242
|
+
return '';
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function buildToolProvider(store: PluginStore): ToolProvider {
|
|
247
|
+
const gate = (spec: ToolSpec) => (profile: AppProfile): boolean =>
|
|
248
|
+
!!profile.capabilities.hostExec
|
|
249
|
+
&& store.isPluginEnabledSync(PLUGIN_ID)
|
|
250
|
+
&& isSupportedPlatform()
|
|
251
|
+
&& (!spec.darwinOnly || process.platform === 'darwin')
|
|
252
|
+
&& (!spec.browserOnly || getComputerUseConfig().browser_use !== false);
|
|
253
|
+
|
|
254
|
+
const toTool = (spec: ToolSpec): ToolDef => ({
|
|
255
|
+
name: spec.name,
|
|
256
|
+
mode: 'host',
|
|
257
|
+
isEnabledFor: gate(spec),
|
|
258
|
+
capabilities: { sideEffect: spec.sideEffect, parallel: false, concurrencyKey: 'computer-use', defaultTimeoutMs: spec.timeoutMs, approval: spec.approval },
|
|
259
|
+
definition: { type: 'function', function: { name: spec.name, description: spec.description, parameters: spec.parameters } },
|
|
260
|
+
execute: async (args: Record<string, any>, tcx: ToolContext): Promise<string> => {
|
|
261
|
+
syncSettings(store);
|
|
262
|
+
// ⚠️这里**绝不能**再加「没装就 return 指导文本」的早退:vendor 的 ensureInstalled() 本来就会
|
|
263
|
+
// 自动跑 setup-helper.mjs,早退等于把上游的自动安装堵死(用户报的「首次安装还要敲命令」就是它)。
|
|
264
|
+
// 我们只补上游缺的那半:装着的二进制比 bundle 老时也要重装。
|
|
265
|
+
const installNote = await ensureHelperCurrent(tcx.signal);
|
|
266
|
+
const pctx = makePiCtx(tcx, false);
|
|
267
|
+
try {
|
|
268
|
+
await ensureComputerUseSetup(pctx, tcx.signal);
|
|
269
|
+
} catch (e: any) {
|
|
270
|
+
// 权限是系统安全设置,只能用户自己拨 —— 但可以替他把 App 登记好、把面板打开。
|
|
271
|
+
const guidance = await guideMissingPermissions(tcx.signal);
|
|
272
|
+
// ⚠️只有「自动安装本身失败了」才该提终端命令。就绪失败的原因多得很(Linux 缺 AT-SPI、
|
|
273
|
+
// 架构不符、daemon 起不来……),对这些 case 说「去跑 setup」是把模型引到错的方向上,
|
|
274
|
+
// 而真正的原因反而被这句话盖住了。
|
|
275
|
+
const fallback = autoInstallFailed()
|
|
276
|
+
? `Computer Use is not ready: ${e?.message || e}\nAutomatic helper installation failed — run \`tangu computer-use setup\` in a terminal to see why.`
|
|
277
|
+
: `Computer Use is not ready: ${e?.message || e}`;
|
|
278
|
+
return [installNote, guidance ?? fallback].filter(Boolean).join('\n');
|
|
279
|
+
}
|
|
280
|
+
// 装/升级过就在结果里说一句 —— 用户装了什么、agent 替他做了什么,不该是暗箱。
|
|
281
|
+
const prefix = installNote ? `${installNote}\n` : '';
|
|
282
|
+
if (spec.run) return prefix + await spec.run(args, tcx, pctx);
|
|
283
|
+
const result = await spec.exec!('', args, tcx.signal, undefined, pctx);
|
|
284
|
+
return prefix + render(result, tcx);
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
return { id: 'plugin:computer-use', tools: () => SPECS.map(toTool) };
|
|
289
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { MouseButtonName, UiAction } from "./contract.ts";
|
|
2
|
+
import type { OutlineNode } from "./outline.ts";
|
|
3
|
+
import { toFiniteNumber } from "./platform/coerce.ts";
|
|
4
|
+
|
|
5
|
+
export type ActionTarget = { ref: string } | { x: number; y: number } | { focus: { x: number; y: number } };
|
|
6
|
+
|
|
7
|
+
export type PreparedAction =
|
|
8
|
+
| { action: "press" | "click"; target: ActionTarget; params: { button?: MouseButtonName; clickCount?: number }; establishesFocus: boolean; usesCurrentFocus: false; needsForeground: boolean }
|
|
9
|
+
| { action: "setText"; target: ActionTarget; params: { text: string }; establishesFocus: false; usesCurrentFocus: false; needsForeground: false }
|
|
10
|
+
| { action: "typeText"; target: ActionTarget; params: { text: string }; establishesFocus: false; usesCurrentFocus: boolean; needsForeground: false }
|
|
11
|
+
| { action: "keypress"; target: ActionTarget; params: { keys: string[] }; establishesFocus: false; usesCurrentFocus: boolean; needsForeground: false }
|
|
12
|
+
| { action: "scroll"; target: ActionTarget; params: { scrollX: number; scrollY: number }; establishesFocus: false; usesCurrentFocus: false; needsForeground: false }
|
|
13
|
+
| { action: "drag"; target: ActionTarget; params: { path: Array<{ x: number; y: number }> }; establishesFocus: false; usesCurrentFocus: false; needsForeground: false }
|
|
14
|
+
| { action: "moveMouse"; target: ActionTarget; params: Record<string, never>; establishesFocus: false; usesCurrentFocus: false; needsForeground: false }
|
|
15
|
+
| { action: "wait"; params: { ms: number }; establishesFocus: false; usesCurrentFocus: false; needsForeground: false };
|
|
16
|
+
|
|
17
|
+
export interface ActionState {
|
|
18
|
+
currentFocus: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ActionEnvironment {
|
|
22
|
+
headless: boolean;
|
|
23
|
+
image?: { width: number; height: number };
|
|
24
|
+
node(ref: string): OutlineNode;
|
|
25
|
+
center(node: OutlineNode): { x: number; y: number };
|
|
26
|
+
validatePoint(x: number, y: number, label?: string): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mouseButton(value: unknown): MouseButtonName {
|
|
30
|
+
return value === "right" || value === "middle" ? value : "left";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function clickCount(value: unknown, fallback = 1): number {
|
|
34
|
+
return Math.max(1, Math.min(3, Math.round(toFiniteNumber(value, fallback))));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function scrollDelta(value: unknown): number {
|
|
38
|
+
return Math.max(-10_000, Math.min(10_000, Math.round(toFiniteNumber(value, 0))));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function keys(value: unknown): string[] {
|
|
42
|
+
if (!Array.isArray(value) || value.length === 0) throw new Error("keypress.keys must contain at least one key.");
|
|
43
|
+
return value.map((key) => String(key));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function path(value: UiAction["path"], env: ActionEnvironment): Array<{ x: number; y: number }> {
|
|
47
|
+
if (!Array.isArray(value) || value.length < 2) throw new Error("drag.path must contain at least two points.");
|
|
48
|
+
return value.map((point, index) => {
|
|
49
|
+
const x = Array.isArray(point) ? toFiniteNumber(point[0], NaN) : toFiniteNumber(point?.x, NaN);
|
|
50
|
+
const y = Array.isArray(point) ? toFiniteNumber(point[1], NaN) : toFiniteNumber(point?.y, NaN);
|
|
51
|
+
env.validatePoint(x, y, `Drag point ${index + 1}`);
|
|
52
|
+
return { x, y };
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function nativeTarget(action: UiAction, operation: PreparedAction["action"], env: ActionEnvironment): ActionTarget {
|
|
57
|
+
if (action.ref?.trim()) {
|
|
58
|
+
const node = env.node(action.ref.trim());
|
|
59
|
+
const semanticClick = operation === "click" || operation === "press";
|
|
60
|
+
if (semanticClick && node.isTextInput) {
|
|
61
|
+
const point = env.center(node);
|
|
62
|
+
env.validatePoint(point.x, point.y);
|
|
63
|
+
return point;
|
|
64
|
+
}
|
|
65
|
+
const onlyIncidentalActions = node.actions.every((candidate) => candidate === "AXShowMenu" || candidate === "AXScrollToVisible");
|
|
66
|
+
if (node.wireRef && !node.pictureOnly && (!semanticClick || node.canPress || node.canFocus || node.canSetValue || !onlyIncidentalActions)) {
|
|
67
|
+
return { ref: node.wireRef };
|
|
68
|
+
}
|
|
69
|
+
const point = env.center(node);
|
|
70
|
+
env.validatePoint(point.x, point.y);
|
|
71
|
+
return point;
|
|
72
|
+
}
|
|
73
|
+
const x = toFiniteNumber(action.x, NaN);
|
|
74
|
+
const y = toFiniteNumber(action.y, NaN);
|
|
75
|
+
if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
76
|
+
env.validatePoint(x, y);
|
|
77
|
+
return { x, y };
|
|
78
|
+
}
|
|
79
|
+
if (operation === "drag" && action.path?.length) return path(action.path, env)[0];
|
|
80
|
+
throw new Error(`${operation} requires either ref or both x and y.`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function focusedTarget(env: ActionEnvironment): ActionTarget {
|
|
84
|
+
if (!env.image) throw new Error("Focused keyboard input requires an image-bearing state.");
|
|
85
|
+
return { focus: { x: Math.floor(env.image.width / 2), y: Math.floor(env.image.height / 2) } };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function containsEditable(node: OutlineNode): boolean {
|
|
89
|
+
if (node.canSetValue || node.role.toLowerCase().includes("text")) return true;
|
|
90
|
+
return node.children.some(containsEditable);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function prepareAction(action: UiAction, state: ActionState, env: ActionEnvironment): PreparedAction {
|
|
94
|
+
const operation = action.action;
|
|
95
|
+
const usesCurrentFocus = !env.headless && state.currentFocus && !action.ref && (operation === "typeText" || operation === "keypress");
|
|
96
|
+
const target = usesCurrentFocus ? focusedTarget(env) : nativeTarget(action, operation, env);
|
|
97
|
+
const establishesFocus = !env.headless && Boolean(action.ref) && (operation === "click" || operation === "press") && containsEditable(env.node(action.ref!));
|
|
98
|
+
const needsForeground = !env.headless && (operation === "click" || operation === "press") && "x" in target;
|
|
99
|
+
|
|
100
|
+
switch (operation) {
|
|
101
|
+
case "press":
|
|
102
|
+
case "click": return { action: operation, target, params: { button: mouseButton(action.button), clickCount: clickCount(action.clickCount) }, establishesFocus, usesCurrentFocus: false, needsForeground };
|
|
103
|
+
case "setText": return { action: operation, target, params: { text: action.text ?? "" }, establishesFocus: false, usesCurrentFocus: false, needsForeground: false };
|
|
104
|
+
case "typeText": return { action: operation, target, params: { text: action.text ?? "" }, establishesFocus: false, usesCurrentFocus, needsForeground: false };
|
|
105
|
+
case "keypress": return { action: operation, target, params: { keys: keys(action.keys) }, establishesFocus: false, usesCurrentFocus, needsForeground: false };
|
|
106
|
+
case "scroll": return { action: operation, target, params: { scrollX: scrollDelta(action.scrollX), scrollY: scrollDelta(action.scrollY) }, establishesFocus: false, usesCurrentFocus: false, needsForeground: false };
|
|
107
|
+
case "drag": return { action: operation, target, params: { path: path(action.path, env) }, establishesFocus: false, usesCurrentFocus: false, needsForeground: false };
|
|
108
|
+
case "moveMouse": return { action: operation, target, params: {}, establishesFocus: false, usesCurrentFocus: false, needsForeground: false };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function canRetryInForeground(action: PreparedAction, outcome: "worked" | "didnt" | "unknown", headless: boolean): boolean {
|
|
113
|
+
return !headless && outcome === "didnt" && (action.action === "typeText" || action.action === "keypress");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function outcomeAfterCheck(current: "worked" | "didnt" | "unknown", check: "verified" | "preexisting" | "failed"): "worked" | "didnt" | "unknown" {
|
|
117
|
+
if (check === "verified") return "worked";
|
|
118
|
+
if (check === "failed") return "didnt";
|
|
119
|
+
return current;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function outcomeAfterObservedValues(
|
|
123
|
+
current: "worked" | "didnt" | "unknown",
|
|
124
|
+
actions: UiAction[],
|
|
125
|
+
valueForRef: (ref: string) => string | undefined,
|
|
126
|
+
): "worked" | "didnt" | "unknown" {
|
|
127
|
+
if (actions.length === 0 || actions.some((action) => action.action !== "setText" || !action.ref)) return current;
|
|
128
|
+
const matches = actions.every((action) => valueForRef(action.ref!) === (action.text ?? ""));
|
|
129
|
+
return matches ? "worked" : current;
|
|
130
|
+
}
|