Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## [2026-09-19 10:45] | Task: CLI 参数别名容错、大图解耦瘦身与语义化点击支持

### 🤖 Execution Context
* **Agent ID**: `Antigravity`
* **Base Model**: `Gemini 3.8 Flash (High)`
* **Runtime**: `macOS 27.0 arm64`

### 📥 User Query
> 针对调用 Apple Music 过程中的实际痛点进行优化:解决参数命名漂移报错、巨幅 Base64 截图污染终端输出与 Token 爆炸问题,并增强语义化点击能力。

### 🛠 Changes Overview
**Scope:** `scripts/npm/build-packages.mjs` (CLI launcher template), `skills/open-computer-use/SKILL.md`, `skills/open-computer-use/references/usage.md`, `docs/releases/feature-release-notes.md`

**Key Actions:**
- **参数别名标准化**: 在 launcher 中增加 `normalizeToolArgs`,自动将常见别名如 `index -> element_index`、`button -> mouse_button`、`amount -> pages`、`start/end -> from/to` 标准化,避免 Agent 因 API drift 报错。
- **截图瘦身解耦**: 默认将 `get_app_state`、`click` 及 `--calls` 输出中的多兆 Base64 图片保存至 `/tmp/ocu_last_screenshot.png`,在标准输出返回轻量引用;提供 `--raw-image` / `OCU_RAW_IMAGE=1` 显式保留原始格式。
- **语义化快速点击**: 支持在 `click` 参数中传入 `text` / `title` 及可选 `role`,通过启发式角色评分算法自动定位目标元素索引并执行点击。
- **文档与用例对齐**: 同步更新 `SKILL.md` 和 `usage.md`,补全常见 CLI 调用示例与参数规范说明。

### 🧠 Design Intent (Why)
*AI Agent 在进行 ReAct 循环交互时极易因文档与底层参数偏差(如 index vs element_index)导致单步中断,且数兆的 Base64 终端输出会造成管道阻塞与严重的 Token 浪费。通过在跨平台 CLI 启动包装层注入透明的中间件处理,既保障了 100% 向下兼容,又极大提升了所有 LLM Agent 在消费 Open Computer Use 时的易用性与稳定性。*

### 📁 Files Modified
- `scripts/npm/build-packages.mjs`
- `skills/open-computer-use/SKILL.md`
- `skills/open-computer-use/references/usage.md`
- `docs/releases/feature-release-notes.md`
1 change: 1 addition & 0 deletions docs/releases/feature-release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

| 日期 | 功能域 | 用户价值 | 变更摘要 |
| --- | --- | --- | --- |
| 2026-09-19 | CLI 参数容错与截图瘦身 | Agent 调用 CLI 时常见参数别名(如 `index`、`button`)自动映射,杜绝报错;Base64 截图解耦输出到本地文件,避免终端截断和 Token 爆炸。 | launcher 新增参数别名标准化中间件(`index -> element_index`、`button -> mouse_button`、`amount -> pages`、`start/end -> from/to`);默认将大图转存至 `/tmp/ocu_last_screenshot.png` 并在 stdout 返回轻量引用(支持 `--raw-image`);支持通过 `text` / `title` 触发带角色权重的语义化智能点击;更新 SKILL 及 usage 参考文档。 |
| 2026-09-17 | DeepSeek Harness 集成 | DSH 用户可以用一条命令安装 Open Computer Use,并在写入配置前发现错误的可执行文件或不兼容的 MCP server。 | 新增 DSH MCP 兼容安装器、turn-boundary 光标清理 hook 和非破坏性 skill 安装;安装前校验 server identity 与非空工具目录但不锁死工具数量,显式安装在 MCP 启动失败时 fail loud,并明确该路径不注册 DSH 的 first-class computer-use provider。 |
| 2026-09-10 | macOS 拖拽修复 | 显式开启全局指针路径后,窗口移动、文本拖选与 Finder 拖放可收到启动真实拖拽所需的事件;默认路径的限制也会明确呈现。 | 发布 `0.3.5`:补齐拖拽位移、手势事件编号与 HID 投递时序,增加投递路径说明;同步升级 Go MCP SDK 到 `v1.4.1`。 |
| 2026-09-08 | drag 投递路径可见性 | 在默认安全配置下调用 `drag` 做窗口移动、文本拖选或 Finder 拖放时,结果会明确说明事件只投递给目标进程、为何没有效果,以及需要设置哪个环境变量,不再表现为“成功但无效果”。 | `drag` 结果新增一条 `Drag delivered via app_post` / `Drag delivered via global pointer path` 文本项;tool description 补充默认路径限制与 `OPEN_COMPUTER_USE_ALLOW_GLOBAL_POINTER_FALLBACKS=1` 说明;skill 文档新增 Drag Delivery 章节;默认行为与安全门本身不变。 |
Expand Down
272 changes: 272 additions & 0 deletions scripts/npm/build-packages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,274 @@ Reinstall with:
return executablePath;
}

function normalizeToolArgs(toolName, argsObj, resolveNativeExecutable) {
if (!argsObj || typeof argsObj !== "object") return argsObj;

if (toolName === "click" || toolName === "scroll" || toolName === "set_value" || toolName === "perform_secondary_action") {
if (argsObj.index !== undefined && argsObj.element_index === undefined) {
argsObj.element_index = argsObj.index;
delete argsObj.index;
}
}

if (toolName === "click") {
if (argsObj.button !== undefined && argsObj.mouse_button === undefined) {
argsObj.mouse_button = argsObj.button;
delete argsObj.button;
}
}

if (toolName === "scroll") {
if (argsObj.amount !== undefined && argsObj.pages === undefined) {
argsObj.pages = argsObj.amount;
delete argsObj.amount;
}
}

if (toolName === "drag") {
if (argsObj.start_x !== undefined && argsObj.from_x === undefined) {
argsObj.from_x = argsObj.start_x;
delete argsObj.start_x;
}
if (argsObj.start_y !== undefined && argsObj.from_y === undefined) {
argsObj.from_y = argsObj.start_y;
delete argsObj.start_y;
}
if (argsObj.end_x !== undefined && argsObj.to_x === undefined) {
argsObj.to_x = argsObj.end_x;
delete argsObj.end_x;
}
if (argsObj.end_y !== undefined && argsObj.to_y === undefined) {
argsObj.to_y = argsObj.end_y;
delete argsObj.end_y;
}
}

if (
toolName === "click" &&
argsObj.element_index === undefined &&
argsObj.x === undefined &&
argsObj.y === undefined &&
argsObj.app &&
(argsObj.title || argsObj.text || argsObj.selector)
) {
const targetText = String(argsObj.title || argsObj.text || argsObj.selector).trim().toLowerCase();
const targetRole = argsObj.role ? String(argsObj.role).trim().toLowerCase() : null;
try {
const { execFileSync } = require("node:child_process");
const nativeBin = resolveNativeExecutable();
const snapshotOut = execFileSync(nativeBin, ["snapshot", argsObj.app], {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The index comes from a separate snapshot process and is then used in another process. In MCP mode, the synchronous execFileSync also blocks the stdin handler for up to 5 s per click.

encoding: "utf8",
timeout: 5000,
});

const lines = snapshotOut.split("\\n");
const candidates = [];
for (const line of lines) {
const trimmed = line.trim();
const m = trimmed.match(/^(\\d+)\\s+([^\\s]+)\\s*(.*)$/);
if (m) {
const idx = parseInt(m[1], 10);
const role = m[2];
const rest = m[3] || "";
const fullLine = role + " " + rest;
if (fullLine.toLowerCase().includes(targetText)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The match is a substring test on the whole line, and candidates are ranked only by role, so ties go to the first line. With 1 button Save As… and 3 button Save in the snapshot, {"text":"Save"} was sent as element_index: 1, which clicks Save As…. With labels like "Delete", a wrong pick is a real risk. query already handles this with exact-then-contains matching.

let score = 10;
const roleLower = role.toLowerCase();
if (roleLower.includes("按钮") || roleLower.includes("button")) score += 30;
if (roleLower.includes("单元格") || roleLower.includes("cell")) score += 20;
if (roleLower.includes("文本") || roleLower.includes("text")) score += 15;
if (roleLower.includes("row") || roleLower.includes("outline")) score -= 10;
if (targetRole && roleLower.includes(targetRole)) score += 50;

candidates.push({ idx, score });
}
}
}

if (candidates.length > 0) {
candidates.sort((a, b) => b.score - a.score);
argsObj.element_index = candidates[0].idx;
}
} catch (err) {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When nothing matches, or the snapshot throws, the catch is empty and lines 430–433 then delete text/title/role. The native side receives {"app":"X"} and reports "click requires either element_index or x/y", which hides the actual reason (no element matched).


delete argsObj.title;
delete argsObj.text;
delete argsObj.selector;
delete argsObj.role;
}

return argsObj;
}

function processOutputJson(data, rawImage) {
if (rawImage || !data) return data;

function handleContentItem(item) {
if (item && item.type === "image" && typeof item.data === "string") {
try {
const savePath = "/tmp/ocu_last_screenshot.png";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every image is written to the same fixed path:

  • In a --calls batch with two images, both results pointed at one file, which held only the second image (BBBB).
  • Because it is a fixed name in shared /tmp, fs.writeFileSync follows a symlink planted there. I checked this: the write went through the symlink and overwrote the target file.
  • On main the format can be jpg or webp (2cf3eae), but the file is always named .png.
  • It also changes the default output of call for every existing consumer (image becomes image_saved), so the "100% backward-compatible" claim doesn't hold.

const buf = Buffer.from(item.data, "base64");
fs.writeFileSync(savePath, buf);
return {
type: "image_saved",
path: savePath,
mimeType: item.mimeType || "image/png",
size_bytes: buf.length,
note: "Base64 image decoupled to prevent token explosion. Image saved to /tmp/ocu_last_screenshot.png"
};
} catch (e) {
return item;
}
}
return item;
}

if (Array.isArray(data)) {
return data.map((d) => processOutputJson(d, rawImage));
}

if (data && typeof data === "object") {
if (Array.isArray(data.content)) {
data.content = data.content.map(handleContentItem);
}
if (data.result && Array.isArray(data.result.content)) {
data.result.content = data.result.content.map(handleContentItem);
}
}

return data;
}

function runEnhancedCall(resolveNativeExecutable, rawArgs) {
const args = [...rawArgs];
let rawImage = process.env.OCU_RAW_IMAGE === "1";

const rawImageIdx = args.indexOf("--raw-image");
if (rawImageIdx !== -1) {
rawImage = true;
args.splice(rawImageIdx, 1);
}

const toolName = args[1];

const argsIdx = args.indexOf("--args");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only --args and --calls are normalized. --args-file and --calls-file skip the aliases.

if (argsIdx !== -1 && args[argsIdx + 1]) {
try {
const parsed = JSON.parse(args[argsIdx + 1]);
const normalized = normalizeToolArgs(toolName, parsed, resolveNativeExecutable);
args[argsIdx + 1] = JSON.stringify(normalized);
} catch (e) {}
}

const callsIdx = args.indexOf("--calls");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In --calls, every semantic click is resolved before the batch starts. The fake binary logged snapshot X and only then call --calls …. So a click that follows a navigation step in the same batch is resolved against the UI as it was before the batch.

if (callsIdx !== -1 && args[callsIdx + 1]) {
try {
const parsedCalls = JSON.parse(args[callsIdx + 1]);
if (Array.isArray(parsedCalls)) {
for (const callItem of parsedCalls) {
if (callItem && callItem.tool && callItem.args) {
callItem.args = normalizeToolArgs(callItem.tool, callItem.args, resolveNativeExecutable);
}
}
args[callsIdx + 1] = JSON.stringify(parsedCalls);
}
} catch (e) {}
}

const executable = resolveNativeExecutable();
const needsImageProcessing = !rawImage;

if (needsImageProcessing) {
const child = spawn(executable, args, {
stdio: ["inherit", "pipe", "inherit"],
windowsHide: false,
});

let stdoutData = "";
child.stdout.on("data", (chunk) => {
stdoutData += chunk.toString("utf8");
});

child.on("error", (error) => {
console.error(\`Failed to start \${executable}: \${error.message}\`);
process.exit(1);
});

for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
child.kill(signal);
});
}

child.on("exit", (code, signal) => {
if (stdoutData) {
try {
const parsed = JSON.parse(stdoutData.trim());
const processed = processOutputJson(parsed, rawImage);
console.log(JSON.stringify(processed, null, 2));
} catch (e) {
process.stdout.write(stdoutData);
}
}
if (signal) process.exit(1);
process.exit(code ?? 0);
});
} else {
spawnAndExit(executable, args);
}
}

function runEnhancedMcp(resolveNativeExecutable, rawArgs) {
const executable = resolveNativeExecutable();
const child = spawn(executable, rawArgs, {
stdio: ["pipe", "pipe", "inherit"],
windowsHide: false,
});

child.on("error", (error) => {
console.error(\`Failed to start \${executable}: \${error.message}\`);
process.exit(1);
});

for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
child.kill(signal);
});
}

child.on("exit", (code, signal) => {
if (signal) process.exit(1);
process.exit(code ?? 0);
});

let stdinBuffer = "";
process.stdin.on("data", (chunk) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the client closes stdin, the launcher never passes that on, so the native MCP server never sees EOF and the launcher never exits. With one request piped in, the launcher was still running after 5 s. The fake native server logged mcp: stdin EOF, exiting only when the launcher was killed. On main the child inherited stdio, so EOF went straight through. This also means anything the runtime does when the connection closes, such as restoring parked windows, waits until the client kills the process. At minimum this needs process.stdin.on("end", () => child.stdin.end()).

stdinBuffer += chunk.toString("utf8");
let newlineIdx;
while ((newlineIdx = stdinBuffer.indexOf("\\n")) !== -1) {
const line = stdinBuffer.slice(0, newlineIdx).trim();
stdinBuffer = stdinBuffer.slice(newlineIdx + 1);
if (line) {
try {
const req = JSON.parse(line);
if (req && req.method === "tools/call" && req.params) {
const toolName = req.params.name;
if (req.params.arguments) {
req.params.arguments = normalizeToolArgs(toolName, req.params.arguments, resolveNativeExecutable);
}
}
child.stdin.write(JSON.stringify(req) + "\\n");
} catch (e) {
child.stdin.write(line + "\\n");
}
}
}
});

child.stdout.pipe(process.stdout);
}

if (command === "-h" || command === "--help" || (command === "help" && args.length <= 1)) {
printLauncherHelp();
process.exit(0);
Expand Down Expand Up @@ -374,6 +642,10 @@ if (command === "help" && (args[1] === "install-claude-mcp" || args[1] === "inst
if (installCommands.has(command)) {
const scriptName = installCommands.get(command);
runInstallCommand(scriptName, args.slice(1));
} else if (command === "call") {
runEnhancedCall(resolveNativeExecutable, args);
} else if (command === "mcp") {
runEnhancedMcp(resolveNativeExecutable, args);
} else {
spawnAndExit(resolveNativeExecutable(), args);
}
Expand Down
7 changes: 6 additions & 1 deletion skills/open-computer-use/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ It supports the same core tool surface across macOS, Linux, and Windows:
5. Capture current UI state with `open-computer-use call get_app_state --args '{"app":"TextEdit"}'`. The default state is usually enough for UI operation.
6. When the task needs longer semantic text, such as chat history, email bodies, document text, or long form content, call `get_app_state` with `text_limit: 1000` or `text_limit: "max"`.
7. When visible long pages or lists appear incomplete even after scrolling, call `get_app_state` with a larger `max_tree_nodes` or `max_tree_depth`.
8. Prefer element-targeted actions using `element_index` from the latest `get_app_state` result.
8. Prefer element-targeted actions using `element_index` (or `index` alias) from the latest `get_app_state` result, or use semantic click with `text` / `title`.
9. For multi-step CLI work, use `open-computer-use call --calls '<json-array>'` so one process can reuse the latest element index mapping.
10. For agent runtimes that support local MCP servers, configure `open-computer-use mcp` or `ocu mcp` and call the exposed Computer Use tools directly. Read [references/usage.md](references/usage.md).
11. If communication, permission, or desktop-session access fails, read [references/troubleshooting.md](references/troubleshooting.md).
Expand All @@ -35,6 +35,7 @@ It supports the same core tool surface across macOS, Linux, and Windows:
- Ask before sending, deleting, purchasing, approving, uploading, or making other externally visible changes.
- Do not assume Codex.app plugin helpers are available. Use the installed `open-computer-use` / `ocu` CLI or an explicit MCP config.
- Always run `get_app_state` before using `element_index`; do not guess indexes across sessions or after large UI changes.
- In CLI mode, base64 screenshots are decoupled by default and saved to `/tmp/ocu_last_screenshot.png` to prevent terminal truncation and token exhaustion (use `--raw-image` to retain raw inline data).
- Prefer semantic actions and `set_value` for editable controls. Use coordinate `click`, `scroll`, and `drag` only when the element tree does not expose a safer target.
- On macOS, do not enable `OPEN_COMPUTER_USE_ALLOW_GLOBAL_POINTER_FALLBACKS=1` unless the user explicitly requested `click_method: "global"`, a `drag` that must drive a window-server drag session (window move, drag-select text, Finder drag-and-drop), or other diagnostic behavior that may move the real pointer. Without it `drag` reports `Drag delivered via app_post` and those operations have no effect; see `references/usage.md` for alternatives.
- On Windows and Linux, confirm the command is running inside the logged-in desktop session before assuming GUI automation is available.
Expand All @@ -52,6 +53,10 @@ open-computer-use call get_app_state --args '{"app":"TextEdit","text_limit":1000
open-computer-use call get_app_state --args '{"app":"TextEdit","text_limit":"max"}'
open-computer-use call get_app_state --args '{"app":"Google Chrome","max_tree_nodes":3000,"max_tree_depth":96}'
open-computer-use call click --args '{"app":"TextEdit","element_index":"0"}'
# Common parameter aliases are supported (index -> element_index, button -> mouse_button, amount -> pages)
open-computer-use call click --args '{"app":"TextEdit","index":0}'
# Semantic click by text/title and optional role
open-computer-use call click --args '{"app":"TextEdit","text":"Close","role":"button"}'
open-computer-use call type_text --args '{"app":"TextEdit","text":"Hello from Open Computer Use"}'
```

Expand Down
27 changes: 27 additions & 0 deletions skills/open-computer-use/references/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,33 @@ Use `--calls-file` when the sequence is too large for a readable shell command:
open-computer-use call --calls-file examples/textedit-overlay-seq.json --sleep 0.5
```

### Parameter Aliases & Semantic Click

The CLI launcher provides automatic normalization for common parameter aliases:
- `index` $\rightarrow$ `element_index` (supported across `click`, `scroll`, `set_value`, and `perform_secondary_action`)
- `button` $\rightarrow$ `mouse_button` (in `click`)
- `amount` $\rightarrow$ `pages` (in `scroll`)
- `start_x, start_y, end_x, end_y` $\rightarrow$ `from_x, from_y, to_x, to_y` (in `drag`)

For `click`, semantic matching is also supported by specifying `text` or `title` and an optional `role`:
```sh
open-computer-use call click --args '{"app":"TextEdit","text":"Save","role":"button"}'
```
This inspects the current window snapshot, scores candidate elements (prioritizing interactive buttons and cells over generic containers), and resolves the element index automatically.

### Screenshot Decoupling in CLI

By default, CLI tool calls decouple multi-megabyte base64 screenshot data from stdout and write the image to `/tmp/ocu_last_screenshot.png`. This prevents terminal buffer overflow and large LLM context overhead. The stdout JSON response summarizes the image reference:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This documents the fixed /tmp path. It would go with the decoupling.

```json
{
"type": "image_saved",
"path": "/tmp/ocu_last_screenshot.png",
"mimeType": "image/png",
"size_bytes": 142191
}
```
To retain inline base64 image data, supply `--raw-image` or set `OCU_RAW_IMAGE=1`.

## Text Limits

Snapshot text is truncated to 500 characters by default and ends with `...` when truncation happens. This keeps normal UI state compact for agent planning and element-targeted actions.
Expand Down