From c7805814362e30b2349936ce86b90fa97e6e570f Mon Sep 17 00:00:00 2001 From: AI Bot Date: Mon, 25 May 2026 16:31:13 +0800 Subject: [PATCH 1/3] feat(trae-cn): add desktop adapter --- README.md | 4 +- README.zh-CN.md | 4 +- cli-manifest.json | 573 +++++++++++++++++++++++++ clis/trae-cn/activity.js | 22 + clis/trae-cn/approve.js | 47 +++ clis/trae-cn/ask.js | 99 +++++ clis/trae-cn/dump.js | 5 + clis/trae-cn/export.js | 40 ++ clis/trae-cn/model.js | 22 + clis/trae-cn/new.js | 71 ++++ clis/trae-cn/read.js | 23 + clis/trae-cn/screenshot.js | 5 + clis/trae-cn/select-model.js | 25 ++ clis/trae-cn/send.js | 23 + clis/trae-cn/setup.js | 75 ++++ clis/trae-cn/status.js | 27 ++ clis/trae-cn/targets.js | 96 +++++ clis/trae-cn/trae-cn.test.js | 327 ++++++++++++++ clis/trae-cn/utils.js | 704 +++++++++++++++++++++++++++++++ clis/trae-cn/watch.js | 119 ++++++ docs/.vitepress/config.mts | 1 + docs/adapters/desktop/trae-cn.md | 127 ++++++ docs/adapters/index.md | 1 + src/electron-apps.test.ts | 10 + src/electron-apps.ts | 7 + 25 files changed, 2453 insertions(+), 4 deletions(-) create mode 100644 clis/trae-cn/activity.js create mode 100644 clis/trae-cn/approve.js create mode 100644 clis/trae-cn/ask.js create mode 100644 clis/trae-cn/dump.js create mode 100644 clis/trae-cn/export.js create mode 100644 clis/trae-cn/model.js create mode 100644 clis/trae-cn/new.js create mode 100644 clis/trae-cn/read.js create mode 100644 clis/trae-cn/screenshot.js create mode 100644 clis/trae-cn/select-model.js create mode 100644 clis/trae-cn/send.js create mode 100644 clis/trae-cn/setup.js create mode 100644 clis/trae-cn/status.js create mode 100644 clis/trae-cn/targets.js create mode 100644 clis/trae-cn/trae-cn.test.js create mode 100644 clis/trae-cn/utils.js create mode 100644 clis/trae-cn/watch.js create mode 100644 docs/adapters/desktop/trae-cn.md diff --git a/README.md b/README.md index 78b74339d..0c72100e7 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ OpenCLI gives you one surface for three different kinds of automation: - **Let AI Agents operate any website** — install the `opencli-browser` skill in your AI agent (Claude Code, Cursor, etc.), and it can navigate, click, type/fill, extract, and inspect any page through your logged-in browser via `opencli browser` primitives. - **Write new adapters** end-to-end with `opencli browser` + the `opencli-adapter-author` skill, which guides from first recon through field decoding, code, and `opencli browser verify`. -It also works as a **CLI hub** for local tools such as `gh`, `docker`, `longbridge`, `tg`, `discord`, `wx`, `ntn` (Notion), and other binaries you register yourself, plus **desktop app adapters** for Electron apps like Cursor, Codex, Antigravity, ChatGPT, and Trae SOLO. +It also works as a **CLI hub** for local tools such as `gh`, `docker`, `longbridge`, `tg`, `discord`, `wx`, `ntn` (Notion), and other binaries you register yourself, plus **desktop app adapters** for Electron apps like Cursor, Trae CN, Codex, Antigravity, ChatGPT, and Trae SOLO. ## Quick Start @@ -194,7 +194,7 @@ Unified passthrough for your existing command-line tools. Run `opencli .. Register your own with `opencli external register `; list everything with `opencli external list`. -**Desktop app adapters** (Electron, via CDP): Cursor / Codex / Antigravity / ChatGPT App / ChatWise / Qoder / Discord / Doubao / Trae SOLO — see [`docs/adapters/desktop/`](./docs/adapters/desktop/). +**Desktop app adapters** (Electron, via CDP): Cursor / Trae CN / Codex / Antigravity / ChatGPT App / ChatWise / Qoder / Discord / Doubao / Trae SOLO — see [`docs/adapters/desktop/`](./docs/adapters/desktop/). ## Download Support diff --git a/README.zh-CN.md b/README.zh-CN.md index dae734446..0a761ac63 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -15,7 +15,7 @@ OpenCLI 可以用同一套 CLI 做三类事情: - **让 AI Agent 操作任意网站**:在你的 AI Agent(Claude Code、Cursor 等)中安装 `opencli-browser` skill,Agent 就能用你的已登录浏览器导航、点击、输入/填充、提取任意网页内容。 - **把新网站写成 CLI**:用 `opencli browser` 原语 + `opencli-adapter-author` skill,从站点侦察、API 发现、字段解码到 `opencli browser verify` 一条龙。 -除了网站能力,OpenCLI 还是一个 **CLI 枢纽**:你可以把 `gh`、`docker`、`longbridge`、`tg`、`discord`、`wx`、`ntn`(Notion)等本地工具统一注册到 `opencli` 下,也可以通过桌面端适配器控制 Cursor、Codex、Antigravity、ChatGPT、Trae SOLO 等 Electron 应用。 +除了网站能力,OpenCLI 还是一个 **CLI 枢纽**:你可以把 `gh`、`docker`、`longbridge`、`tg`、`discord`、`wx`、`ntn`(Notion)等本地工具统一注册到 `opencli` 下,也可以通过桌面端适配器控制 Cursor、Trae CN、Codex、Antigravity、ChatGPT、Trae SOLO 等 Electron 应用。 ## 快速开始 @@ -182,7 +182,7 @@ Agent 在内部自动处理所有 `opencli browser` 命令——你只需用自 注册自定义本地 CLI:`opencli external register `;查看所有:`opencli external list`。 -**桌面应用适配器**(Electron,通过 CDP):Cursor / Codex / Antigravity / ChatGPT App / ChatWise / Qoder / Discord / Doubao / Trae SOLO — 详见 [`docs/adapters/desktop/`](./docs/adapters/desktop/)。 +**桌面应用适配器**(Electron,通过 CDP):Cursor / Trae CN / Codex / Antigravity / ChatGPT App / ChatWise / Qoder / Discord / Doubao / Trae SOLO — 详见 [`docs/adapters/desktop/`](./docs/adapters/desktop/)。 ## 下载支持 diff --git a/cli-manifest.json b/cli-manifest.json index c89d0755d..99accdb3d 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -27592,6 +27592,579 @@ "modulePath": "toutiao/hot.js", "sourceFile": "toutiao/hot.js" }, + { + "site": "trae-cn", + "name": "activity", + "description": "Read the current Trae CN task/activity state, including in-progress steps when visible", + "access": "read", + "example": "OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn activity --max-chars 1200 -f json", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "max-chars", + "type": "int", + "default": 1200, + "required": false, + "help": "Max chars per text field; 0 returns full text (default: 1200)" + } + ], + "columns": [ + "Status", + "Workspace", + "Model", + "Agent", + "LatestRole", + "TurnIndex", + "MessageId", + "Progress", + "ActiveStep", + "CompletedSteps", + "PendingSteps", + "TotalSteps", + "ApprovalPending", + "ApprovalKind", + "ApprovalButton", + "Thinking", + "LatestText", + "TextChars", + "UpdatedAt" + ], + "type": "js", + "modulePath": "trae-cn/activity.js", + "sourceFile": "trae-cn/activity.js", + "navigateBefore": true + }, + { + "site": "trae-cn", + "name": "approve", + "description": "Approve visible Trae CN permission prompts such as terminal-run, high-risk command, or delete confirmations", + "access": "write", + "example": "OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn approve --approve-kinds terminal,delete -f json", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "approve-kinds", + "type": "string", + "default": "terminal,delete", + "required": false, + "help": "Comma-separated approval categories: terminal,delete,keep,all (default: terminal,delete; keep is not default)" + }, + { + "name": "limit", + "type": "int", + "default": 1, + "required": false, + "help": "Max visible prompts to approve in this pass (default: 1)" + }, + { + "name": "max-chars", + "type": "int", + "default": 600, + "required": false, + "help": "Max chars to return from prompt text; 0 returns full text (default: 600)" + }, + { + "name": "dry-run", + "type": "boolean", + "default": false, + "required": false, + "help": "Detect matching prompts without clicking them (default: false)" + } + ], + "columns": [ + "Status", + "Kind", + "Button", + "Prompt", + "Selector", + "Action" + ], + "type": "js", + "modulePath": "trae-cn/approve.js", + "sourceFile": "trae-cn/approve.js", + "navigateBefore": true + }, + { + "site": "trae-cn", + "name": "ask", + "description": "Send a prompt to Trae CN, wait for the assistant result without treating approval cards as final, and return it", + "access": "write", + "example": "OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn ask \"请只回复 OK\" --timeout 120 -f json", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "text", + "type": "str", + "required": true, + "positional": true, + "help": "Prompt to send into Trae CN" + }, + { + "name": "timeout", + "type": "int", + "default": 60, + "required": false, + "help": "Max seconds to wait for response (default: 60)" + }, + { + "name": "max-chars", + "type": "int", + "default": 12000, + "required": false, + "help": "Max chars to return from the assistant response; 0 returns full text (default: 12000)" + }, + { + "name": "auto-approve", + "type": "boolean", + "default": true, + "required": false, + "help": "While waiting, approve visible terminal/delete prompts, including high-risk terminal confirmation layers (default: true; pass false to disable)" + }, + { + "name": "approve-kinds", + "type": "string", + "default": "terminal,delete", + "required": false, + "help": "Comma-separated approval categories for --auto-approve: terminal,delete,keep,all (default: terminal,delete; keep is not default)" + } + ], + "columns": [ + "Role", + "Text", + "TextChars", + "Truncated", + "TurnIndex", + "MessageId" + ], + "type": "js", + "modulePath": "trae-cn/ask.js", + "sourceFile": "trae-cn/ask.js", + "navigateBefore": true + }, + { + "site": "trae-cn", + "name": "dump", + "description": "Dump the DOM and Accessibility tree of trae-cn for reverse-engineering", + "access": "read", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "action", + "files" + ], + "type": "js", + "modulePath": "trae-cn/dump.js", + "sourceFile": "trae-cn/dump.js", + "navigateBefore": true + }, + { + "site": "trae-cn", + "name": "export", + "description": "Export the current Trae CN conversation to Markdown", + "access": "read", + "example": "OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn export --limit 20 --output /tmp/trae-cn-export.md", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "output", + "type": "str", + "required": false, + "help": "Output file (default: /tmp/trae-cn-export.md)" + }, + { + "name": "limit", + "type": "int", + "default": 50, + "required": false, + "help": "Max recent turns to export (default: 50)" + } + ], + "columns": [ + "Status", + "File", + "Messages" + ], + "type": "js", + "modulePath": "trae-cn/export.js", + "sourceFile": "trae-cn/export.js", + "navigateBefore": true + }, + { + "site": "trae-cn", + "name": "model", + "description": "Read the model label currently shown in the Trae CN chat input", + "access": "read", + "example": "OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn model -f json", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Model", + "Agent", + "Workspace" + ], + "type": "js", + "modulePath": "trae-cn/model.js", + "sourceFile": "trae-cn/model.js", + "navigateBefore": true + }, + { + "site": "trae-cn", + "name": "new", + "description": "Start a new Trae CN task in the current workspace, optionally sending the first prompt", + "access": "write", + "example": "OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn new \"请执行你的任务\" -f json", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "prompt", + "type": "str", + "required": false, + "positional": true, + "help": "Optional first prompt to send after creating the task" + }, + { + "name": "timeout", + "type": "int", + "default": 10, + "required": false, + "help": "Max seconds to wait for a fresh task composer (default: 10)" + } + ], + "columns": [ + "Status", + "Action", + "Workspace", + "Model", + "Agent", + "FreshTaskConfirmed", + "TurnsBeforeSubmit", + "Turns", + "ComposerReady", + "SubmitMode" + ], + "type": "js", + "modulePath": "trae-cn/new.js", + "sourceFile": "trae-cn/new.js", + "navigateBefore": true + }, + { + "site": "trae-cn", + "name": "read", + "description": "Read the current Trae CN chat conversation", + "access": "read", + "example": "OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn read --limit 5 --max-chars 12000 -f json", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "limit", + "type": "int", + "default": 20, + "required": false, + "help": "Max recent turns to return (default: 20)" + }, + { + "name": "max-chars", + "type": "int", + "default": 6000, + "required": false, + "help": "Max chars per turn; 0 returns full text (default: 6000)" + } + ], + "columns": [ + "Role", + "Text", + "TextChars", + "Truncated", + "TurnIndex", + "MessageId" + ], + "type": "js", + "modulePath": "trae-cn/read.js", + "sourceFile": "trae-cn/read.js", + "navigateBefore": true + }, + { + "site": "trae-cn", + "name": "screenshot", + "description": "Capture a snapshot of the current Trae CN window (DOM + Accessibility tree)", + "access": "read", + "example": "OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn screenshot --output /tmp/trae-cn-snapshot.txt", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "output", + "type": "str", + "required": false, + "help": "Output file path (default: /tmp/trae-cn-snapshot.txt)" + } + ], + "columns": [ + "Status", + "File" + ], + "type": "js", + "modulePath": "trae-cn/screenshot.js", + "sourceFile": "trae-cn/screenshot.js", + "navigateBefore": true + }, + { + "site": "trae-cn", + "name": "select-model", + "description": "Select a model in the current Trae CN chat input", + "access": "write", + "example": "OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn select-model \"GPT 5.4\" -f json", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "name", + "type": "str", + "required": true, + "positional": true, + "help": "Model label, for example GPT 5.4 or GPT-5.4" + } + ], + "columns": [ + "Status", + "Requested", + "Selected", + "Workspace", + "Agent" + ], + "type": "js", + "modulePath": "trae-cn/select-model.js", + "sourceFile": "trae-cn/select-model.js", + "navigateBefore": true + }, + { + "site": "trae-cn", + "name": "send", + "description": "Send a prompt into the current Trae CN chat input", + "access": "write", + "example": "OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn send \"请执行你的任务\" -f json", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "text", + "type": "str", + "required": true, + "positional": true, + "help": "Text to send into Trae CN" + } + ], + "columns": [ + "Status", + "InjectedText", + "SubmitMode" + ], + "type": "js", + "modulePath": "trae-cn/send.js", + "sourceFile": "trae-cn/send.js", + "navigateBefore": true + }, + { + "site": "trae-cn", + "name": "setup", + "description": "Show local setup commands for controlling Trae CN with OpenCLI", + "access": "read", + "example": "opencli trae-cn setup -f table", + "strategy": "local", + "browser": false, + "args": [], + "columns": [ + "Step", + "Command", + "Purpose" + ], + "type": "js", + "modulePath": "trae-cn/setup.js", + "sourceFile": "trae-cn/setup.js" + }, + { + "site": "trae-cn", + "name": "status", + "description": "Check active CDP connection to Trae CN and summarize the current workspace/model", + "access": "read", + "example": "OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn status -f json", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [], + "columns": [ + "Status", + "Title", + "Workspace", + "Model", + "Agent", + "Turns", + "ComposerReady", + "Url" + ], + "type": "js", + "modulePath": "trae-cn/status.js", + "sourceFile": "trae-cn/status.js", + "navigateBefore": true + }, + { + "site": "trae-cn", + "name": "targets", + "description": "List Trae CN CDP targets and show which workspace/window is waiting", + "access": "read", + "example": "OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 opencli trae-cn targets -f table", + "strategy": "local", + "browser": false, + "args": [ + { + "name": "endpoint", + "type": "string", + "required": false, + "help": "CDP endpoint (default: OPENCLI_CDP_ENDPOINT)" + }, + { + "name": "probe-activity", + "type": "boolean", + "default": true, + "required": false, + "help": "Probe each target for Trae activity state (default: true)" + }, + { + "name": "max-chars", + "type": "int", + "default": 240, + "required": false, + "help": "Max chars for approval prompt/activity snippets (default: 240)" + } + ], + "columns": [ + "Index", + "SelectedHint", + "RecommendedTarget", + "Title", + "Workspace", + "Status", + "ApprovalPending", + "ApprovalKind", + "ApprovalButton", + "Model", + "Agent", + "Type", + "Url" + ], + "type": "js", + "modulePath": "trae-cn/targets.js", + "sourceFile": "trae-cn/targets.js" + }, + { + "site": "trae-cn", + "name": "watch", + "description": "Sample Trae CN activity over time to monitor long-running tasks", + "access": "read", + "example": "OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn watch --stream true --duration 120", + "domain": "localhost", + "strategy": "ui", + "browser": true, + "args": [ + { + "name": "duration", + "type": "int", + "default": 30, + "required": false, + "help": "Seconds to observe (default: 30)" + }, + { + "name": "interval", + "type": "int", + "default": 2, + "required": false, + "help": "Seconds between samples (default: 2)" + }, + { + "name": "max-chars", + "type": "int", + "default": 600, + "required": false, + "help": "Max chars per text field; 0 returns full text (default: 600)" + }, + { + "name": "timeout", + "type": "int", + "default": 86400, + "required": false, + "help": "Max seconds for the overall watch command (default: 86400)" + }, + { + "name": "stream", + "type": "boolean", + "default": false, + "required": false, + "help": "Write each sample immediately as JSONL and skip result rendering (default: false)" + }, + { + "name": "stop-on-complete", + "type": "boolean", + "default": true, + "required": false, + "help": "Stop watching after the first completed sample (default: true)" + }, + { + "name": "auto-approve", + "type": "boolean", + "default": true, + "required": false, + "help": "Approve visible terminal/delete prompts before each sample, including high-risk terminal confirmation layers (default: true; pass false to disable)" + }, + { + "name": "approve-kinds", + "type": "string", + "default": "terminal,delete", + "required": false, + "help": "Comma-separated approval categories for --auto-approve: terminal,delete,keep,all (default: terminal,delete; keep is not default)" + } + ], + "columns": [ + "Sample", + "ElapsedSec", + "Changed", + "Status", + "Progress", + "ActiveStep", + "ApprovalPending", + "ApprovalKind", + "ApprovalButton", + "AutoApproved", + "LatestRole", + "TurnIndex", + "MessageId", + "TextChars", + "LatestText", + "UpdatedAt" + ], + "type": "js", + "modulePath": "trae-cn/watch.js", + "sourceFile": "trae-cn/watch.js", + "navigateBefore": true + }, { "site": "trae-solo", "name": "automation-list", diff --git a/clis/trae-cn/activity.js b/clis/trae-cn/activity.js new file mode 100644 index 000000000..965b166ee --- /dev/null +++ b/clis/trae-cn/activity.js @@ -0,0 +1,22 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { activityScript, normalizeMaxChars } from './utils.js'; + +export const activityCommand = cli({ + site: 'trae-cn', + name: 'activity', + access: 'read', + description: 'Read the current Trae CN task/activity state, including in-progress steps when visible', + example: 'OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn activity --max-chars 1200 -f json', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'max-chars', type: 'int', required: false, help: 'Max chars per text field; 0 returns full text (default: 1200)', default: 1200 }, + ], + columns: ['Status', 'Workspace', 'Model', 'Agent', 'LatestRole', 'TurnIndex', 'MessageId', 'Progress', 'ActiveStep', 'CompletedSteps', 'PendingSteps', 'TotalSteps', 'ApprovalPending', 'ApprovalKind', 'ApprovalButton', 'Thinking', 'LatestText', 'TextChars', 'UpdatedAt'], + func: async (page, kwargs) => { + const maxChars = normalizeMaxChars(kwargs['max-chars'], 1200); + const activity = await page.evaluate(activityScript(maxChars)); + return [activity]; + }, +}); diff --git a/clis/trae-cn/approve.js b/clis/trae-cn/approve.js new file mode 100644 index 000000000..455a04b96 --- /dev/null +++ b/clis/trae-cn/approve.js @@ -0,0 +1,47 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + approveTraePrompts, + normalizeApprovalKinds, + normalizeApprovalLimit, + normalizeMaxChars, +} from './utils.js'; + +function normalizeDryRun(value) { + return value === true || String(value).toLowerCase() === 'true'; +} + +export const approveCommand = cli({ + site: 'trae-cn', + name: 'approve', + access: 'write', + description: 'Approve visible Trae CN permission prompts such as terminal-run, high-risk command, or delete confirmations', + example: 'OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn approve --approve-kinds terminal,delete -f json', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'approve-kinds', type: 'string', required: false, help: 'Comma-separated approval categories: terminal,delete,keep,all (default: terminal,delete; keep is not default)', default: 'terminal,delete' }, + { name: 'limit', type: 'int', required: false, help: 'Max visible prompts to approve in this pass (default: 1)', default: 1 }, + { name: 'max-chars', type: 'int', required: false, help: 'Max chars to return from prompt text; 0 returns full text (default: 600)', default: 600 }, + { name: 'dry-run', type: 'boolean', required: false, help: 'Detect matching prompts without clicking them (default: false)', default: false }, + ], + columns: ['Status', 'Kind', 'Button', 'Prompt', 'Selector', 'Action'], + func: async (page, kwargs) => { + const kinds = normalizeApprovalKinds(kwargs['approve-kinds']); + const limit = normalizeApprovalLimit(kwargs.limit, 1); + const maxChars = normalizeMaxChars(kwargs['max-chars'], 600); + const dryRun = normalizeDryRun(kwargs['dry-run']); + const rows = await approveTraePrompts(page, kinds, { click: !dryRun, limit, maxChars }); + if (rows.length === 0) { + return [{ + Status: 'NoPrompt', + Kind: kinds.join(','), + Button: '', + Prompt: 'No matching visible Trae CN approval prompt found', + Selector: '', + Action: dryRun ? 'dry-run' : 'none', + }]; + } + return rows; + }, +}); diff --git a/clis/trae-cn/ask.js b/clis/trae-cn/ask.js new file mode 100644 index 000000000..f27c40c9d --- /dev/null +++ b/clis/trae-cn/ask.js @@ -0,0 +1,99 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { TimeoutError } from '@jackwener/opencli/errors'; +import { + activityScript, + approveTraePrompts, + isActivityComplete, + isLikelyBlockingActivity, + latestAssistantScript, + normalizeApprovalKinds, + normalizeMaxChars, + normalizeTimeout, + sendTraePrompt, +} from './utils.js'; + +function normalizeAutoApprove(value) { + return value === undefined || value === true || String(value).toLowerCase() === 'true'; +} + +export const askCommand = cli({ + site: 'trae-cn', + name: 'ask', + access: 'write', + description: 'Send a prompt to Trae CN, wait for the assistant result without treating approval cards as final, and return it', + example: 'OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn ask "请只回复 OK" --timeout 120 -f json', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'text', required: true, positional: true, help: 'Prompt to send into Trae CN' }, + { name: 'timeout', type: 'int', required: false, help: 'Max seconds to wait for response (default: 60)', default: 60 }, + { name: 'max-chars', type: 'int', required: false, help: 'Max chars to return from the assistant response; 0 returns full text (default: 12000)', default: 12000 }, + { name: 'auto-approve', type: 'boolean', required: false, help: 'While waiting, approve visible terminal/delete prompts, including high-risk terminal confirmation layers (default: true; pass false to disable)', default: true }, + { name: 'approve-kinds', type: 'string', required: false, help: 'Comma-separated approval categories for --auto-approve: terminal,delete,keep,all (default: terminal,delete; keep is not default)', default: 'terminal,delete' }, + ], + columns: ['Role', 'Text', 'TextChars', 'Truncated', 'TurnIndex', 'MessageId'], + func: async (page, kwargs) => { + const timeout = normalizeTimeout(kwargs.timeout, 60); + const maxChars = normalizeMaxChars(kwargs['max-chars'], 12000); + const autoApprove = normalizeAutoApprove(kwargs['auto-approve']); + const approvalKinds = normalizeApprovalKinds(kwargs['approve-kinds']); + const beforeAssistant = await page.evaluate(latestAssistantScript(0)); + const beforeKey = [ + beforeAssistant?.MessageId || '', + beforeAssistant?.TurnIndex || '', + beforeAssistant?.Text || '', + ].join('|'); + await sendTraePrompt(page, kwargs.text); + + const deadline = Date.now() + timeout * 1000; + let lastText = ''; + let stableSince = 0; + let latest = null; + let latestActivity = null; + + while (Date.now() < deadline) { + await page.wait(2); + if (autoApprove) { + await approveTraePrompts(page, approvalKinds, { click: true, limit: 1, maxChars: 600 }); + } + latest = await page.evaluate(latestAssistantScript(maxChars)); + latestActivity = await page.evaluate(activityScript(600)); + const text = latest?.Text?.trim() || ''; + if (!text) continue; + const latestKey = [ + latest?.MessageId || '', + latest?.TurnIndex || '', + latest?.Text || '', + ].join('|'); + if (latestKey === beforeKey) continue; + if (text !== lastText) { + lastText = text; + stableSince = Date.now(); + continue; + } + const stableFor = Date.now() - stableSince; + const blocked = isLikelyBlockingActivity(latestActivity); + const completed = isActivityComplete(latestActivity); + if (completed && !blocked && stableFor >= 1000) { + return [latest]; + } + if (!blocked && stableFor >= 3000) { + return [latest]; + } + } + + if (latest?.Text) { + const blocked = isLikelyBlockingActivity(latestActivity); + if (blocked) { + throw new TimeoutError( + 'trae-cn ask', + timeout, + `Current status=${latestActivity?.Status ?? ''}, approvalPending=${latestActivity?.ApprovalPending ?? ''}, approvalKind=${latestActivity?.ApprovalKind ?? ''}, approvalButton=${latestActivity?.ApprovalButton ?? ''}. Use "opencli trae-cn targets -f json" to find the waiting target, then run "OPENCLI_CDP_TARGET= opencli trae-cn watch --stream true --duration 300" or "opencli trae-cn approve".`, + ); + } + return [latest]; + } + throw new TimeoutError('trae-cn ask', timeout, 'No Trae CN response was visible before the timeout. The agent may still be generating.'); + }, +}); diff --git a/clis/trae-cn/dump.js b/clis/trae-cn/dump.js new file mode 100644 index 000000000..cd2181262 --- /dev/null +++ b/clis/trae-cn/dump.js @@ -0,0 +1,5 @@ +import { makeDumpCommand } from '../_shared/desktop-commands.js'; + +export const dumpCommand = makeDumpCommand('trae-cn', { + example: 'OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn dump -f json', +}); diff --git a/clis/trae-cn/export.js b/clis/trae-cn/export.js new file mode 100644 index 000000000..ccb397488 --- /dev/null +++ b/clis/trae-cn/export.js @@ -0,0 +1,40 @@ +import * as fs from 'node:fs'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { normalizeLimit, readTraeMessages } from './utils.js'; + +export const exportCommand = cli({ + site: 'trae-cn', + name: 'export', + access: 'read', + description: 'Export the current Trae CN conversation to Markdown', + example: 'OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn export --limit 20 --output /tmp/trae-cn-export.md', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'output', required: false, help: 'Output file (default: /tmp/trae-cn-export.md)' }, + { name: 'limit', type: 'int', required: false, help: 'Max recent turns to export (default: 50)', default: 50 }, + ], + columns: ['Status', 'File', 'Messages'], + func: async (page, kwargs) => { + const outputPath = kwargs.output || '/tmp/trae-cn-export.md'; + const limit = normalizeLimit(kwargs.limit, 50); + const messages = await readTraeMessages(page, limit); + const markdown = [ + '# Trae CN Conversation Export', + '', + ...messages.map((message, index) => [ + `## ${index + 1}. ${message.Role}`, + '', + message.Text, + '', + ].join('\n')), + ].join('\n'); + fs.writeFileSync(outputPath, markdown); + return [{ + Status: 'Success', + File: outputPath, + Messages: messages.length, + }]; + }, +}); diff --git a/clis/trae-cn/model.js b/clis/trae-cn/model.js new file mode 100644 index 000000000..defcc4284 --- /dev/null +++ b/clis/trae-cn/model.js @@ -0,0 +1,22 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { inspectTraeShellScript } from './utils.js'; + +export const modelCommand = cli({ + site: 'trae-cn', + name: 'model', + access: 'read', + description: 'Read the model label currently shown in the Trae CN chat input', + example: 'OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn model -f json', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + columns: ['Model', 'Agent', 'Workspace'], + func: async (page) => { + const info = await page.evaluate(inspectTraeShellScript()); + return [{ + Model: info.model || '', + Agent: info.agent || '', + Workspace: info.workspace || '', + }]; + }, +}); diff --git a/clis/trae-cn/new.js b/clis/trae-cn/new.js new file mode 100644 index 000000000..70ae160f8 --- /dev/null +++ b/clis/trae-cn/new.js @@ -0,0 +1,71 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { selectorError } from '@jackwener/opencli/errors'; +import { clickNewTaskScript, currentTaskStateScript, ensurePrompt, normalizeTimeout, sendTraePrompt } from './utils.js'; + +export const newCommand = cli({ + site: 'trae-cn', + name: 'new', + access: 'write', + description: 'Start a new Trae CN task in the current workspace, optionally sending the first prompt', + example: 'OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn new "请执行你的任务" -f json', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'prompt', required: false, positional: true, help: 'Optional first prompt to send after creating the task' }, + { name: 'timeout', type: 'int', required: false, help: 'Max seconds to wait for a fresh task composer (default: 10)', default: 10 }, + ], + columns: ['Status', 'Action', 'Workspace', 'Model', 'Agent', 'FreshTaskConfirmed', 'TurnsBeforeSubmit', 'Turns', 'ComposerReady', 'SubmitMode'], + func: async (page, kwargs) => { + const timeout = normalizeTimeout(kwargs.timeout, 10); + const clicked = await page.evaluate(clickNewTaskScript()); + if (!clicked?.ok) { + throw selectorError('Trae CN new task button'); + } + + const started = Date.now(); + let state = null; + while (Date.now() - started < timeout * 1000) { + await page.wait(0.5); + state = await page.evaluate(currentTaskStateScript()); + if (state?.composerReady && state.turns === 0) break; + } + + if (!state?.composerReady || state.turns !== 0) { + return [{ + Status: 'Unclear', + Action: `Clicked new task via ${clicked.method}; fresh empty composer was not confirmed`, + Workspace: state?.workspace || '', + Model: state?.model || '', + Agent: state?.agent || '', + FreshTaskConfirmed: 'no', + TurnsBeforeSubmit: state?.turns ?? '', + Turns: state?.turns ?? '', + ComposerReady: state?.composerReady ? 'yes' : 'no', + SubmitMode: '', + }]; + } + + const turnsBeforeSubmit = state.turns; + let submitMode = ''; + if (kwargs.prompt !== undefined && kwargs.prompt !== null && String(kwargs.prompt).trim()) { + const result = await sendTraePrompt(page, ensurePrompt(kwargs.prompt)); + submitMode = result.mode; + await page.wait(0.5); + state = await page.evaluate(currentTaskStateScript()); + } + + return [{ + Status: 'Success', + Action: kwargs.prompt ? `New task created via ${clicked.method}; prompt submitted` : `New task created via ${clicked.method}`, + Workspace: state?.workspace || '', + Model: state?.model || '', + Agent: state?.agent || '', + FreshTaskConfirmed: 'yes', + TurnsBeforeSubmit: turnsBeforeSubmit, + Turns: state?.turns ?? 0, + ComposerReady: state?.composerReady ? 'yes' : 'no', + SubmitMode: submitMode, + }]; + }, +}); diff --git a/clis/trae-cn/read.js b/clis/trae-cn/read.js new file mode 100644 index 000000000..92cb6a58f --- /dev/null +++ b/clis/trae-cn/read.js @@ -0,0 +1,23 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { normalizeLimit, normalizeMaxChars, readTraeMessages } from './utils.js'; + +export const readCommand = cli({ + site: 'trae-cn', + name: 'read', + access: 'read', + description: 'Read the current Trae CN chat conversation', + example: 'OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn read --limit 5 --max-chars 12000 -f json', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'limit', type: 'int', required: false, help: 'Max recent turns to return (default: 20)', default: 20 }, + { name: 'max-chars', type: 'int', required: false, help: 'Max chars per turn; 0 returns full text (default: 6000)', default: 6000 }, + ], + columns: ['Role', 'Text', 'TextChars', 'Truncated', 'TurnIndex', 'MessageId'], + func: async (page, kwargs) => { + const limit = normalizeLimit(kwargs.limit, 20); + const maxChars = normalizeMaxChars(kwargs['max-chars'], 6000); + return readTraeMessages(page, limit, maxChars); + }, +}); diff --git a/clis/trae-cn/screenshot.js b/clis/trae-cn/screenshot.js new file mode 100644 index 000000000..7ef6c1392 --- /dev/null +++ b/clis/trae-cn/screenshot.js @@ -0,0 +1,5 @@ +import { makeScreenshotCommand } from '../_shared/desktop-commands.js'; + +export const screenshotTraeCn = makeScreenshotCommand('trae-cn', 'Trae CN', { + example: 'OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn screenshot --output /tmp/trae-cn-snapshot.txt', +}); diff --git a/clis/trae-cn/select-model.js b/clis/trae-cn/select-model.js new file mode 100644 index 000000000..090889c9b --- /dev/null +++ b/clis/trae-cn/select-model.js @@ -0,0 +1,25 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { selectTraeModel } from './utils.js'; + +export const selectModelCommand = cli({ + site: 'trae-cn', + name: 'select-model', + access: 'write', + description: 'Select a model in the current Trae CN chat input', + example: 'OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn select-model "GPT 5.4" -f json', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [{ name: 'name', required: true, positional: true, help: 'Model label, for example GPT 5.4 or GPT-5.4' }], + columns: ['Status', 'Requested', 'Selected', 'Workspace', 'Agent'], + func: async (page, kwargs) => { + const result = await selectTraeModel(page, kwargs.name); + return [{ + Status: 'Success', + Requested: result.requested, + Selected: result.selected, + Workspace: result.workspace, + Agent: result.agent, + }]; + }, +}); diff --git a/clis/trae-cn/send.js b/clis/trae-cn/send.js new file mode 100644 index 000000000..d2bd23290 --- /dev/null +++ b/clis/trae-cn/send.js @@ -0,0 +1,23 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { sendTraePrompt } from './utils.js'; + +export const sendCommand = cli({ + site: 'trae-cn', + name: 'send', + access: 'write', + description: 'Send a prompt into the current Trae CN chat input', + example: 'OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn send "请执行你的任务" -f json', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [{ name: 'text', required: true, positional: true, help: 'Text to send into Trae CN' }], + columns: ['Status', 'InjectedText', 'SubmitMode'], + func: async (page, kwargs) => { + const result = await sendTraePrompt(page, kwargs.text); + return [{ + Status: 'Success', + InjectedText: result.prompt, + SubmitMode: result.mode, + }]; + }, +}); diff --git a/clis/trae-cn/setup.js b/clis/trae-cn/setup.js new file mode 100644 index 000000000..f1ca0bfb1 --- /dev/null +++ b/clis/trae-cn/setup.js @@ -0,0 +1,75 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; + +export const setupCommand = cli({ + site: 'trae-cn', + name: 'setup', + access: 'read', + description: 'Show local setup commands for controlling Trae CN with OpenCLI', + example: 'opencli trae-cn setup -f table', + strategy: Strategy.LOCAL, + browser: false, + args: [], + columns: ['Step', 'Command', 'Purpose'], + func: async () => [ + { + Step: '1. Launch Trae CN with CDP', + Command: 'open -a "Trae CN" --args --remote-debugging-port=39240', + Purpose: 'Start Trae CN with a Chrome DevTools Protocol endpoint', + }, + { + Step: '2. Point OpenCLI at Trae', + Command: 'export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:39240"', + Purpose: 'Tell OpenCLI which local Trae CDP endpoint to use', + }, + { + Step: '3. Select a workspace target', + Command: 'export OPENCLI_CDP_TARGET="talk"', + Purpose: 'Choose the Trae workspace/window title when multiple targets are open', + }, + { + Step: '4. List Trae targets', + Command: 'opencli trae-cn targets -f table', + Purpose: 'Find the right workspace target and spot windows with ApprovalPending=yes', + }, + { + Step: '5. Verify connection', + Command: 'opencli trae-cn status -f json', + Purpose: 'Confirm workspace, model, agent, turn count, and composer readiness', + }, + { + Step: '6. Start a fresh task', + Command: 'opencli trae-cn new "请执行你的任务" -f json', + Purpose: 'Create a new task and send the first prompt', + }, + { + Step: '7. Monitor progress', + Command: 'opencli trae-cn watch --stream true --duration 120', + Purpose: 'Read in-app running/completed state as JSONL; terminal/delete confirmations are auto-approved by default', + }, + { + Step: '8. Approve blockers when needed', + Command: 'opencli trae-cn approve --approve-kinds terminal,delete -f json', + Purpose: 'Click visible Trae prompts for terminal command or delete confirmations', + }, + { + Step: '9. Disable auto-approve when needed', + Command: 'opencli trae-cn watch --stream true --duration 120 --auto-approve false', + Purpose: 'Monitor without clicking terminal/delete prompts', + }, + { + Step: '10. Read result', + Command: 'opencli trae-cn read --limit 5 --max-chars 12000 -f json', + Purpose: 'Fetch recent user/assistant turns from the current task', + }, + { + Step: 'Auto-run boundary', + Command: 'rm, mv, chmod, dd, truncate, kill, destructive git/database commands', + Purpose: 'Trae CN may still stop these as high-risk even when command mode is 自动运行; OpenCLI auto-approves them if they appear as terminal confirmation UI', + }, + { + Step: 'Help', + Command: 'opencli trae-cn --help -f yaml', + Purpose: 'Get all Trae CN commands, args, examples, and output columns in structured form', + }, + ], +}); diff --git a/clis/trae-cn/status.js b/clis/trae-cn/status.js new file mode 100644 index 000000000..1363680ee --- /dev/null +++ b/clis/trae-cn/status.js @@ -0,0 +1,27 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { inspectTraeShellScript } from './utils.js'; + +export const statusCommand = cli({ + site: 'trae-cn', + name: 'status', + access: 'read', + description: 'Check active CDP connection to Trae CN and summarize the current workspace/model', + example: 'OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn status -f json', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + columns: ['Status', 'Title', 'Workspace', 'Model', 'Agent', 'Turns', 'ComposerReady', 'Url'], + func: async (page) => { + const info = await page.evaluate(inspectTraeShellScript()); + return [{ + Status: 'Connected', + Title: info.title || '', + Workspace: info.workspace || '', + Model: info.model || '', + Agent: info.agent || '', + Turns: info.turns ?? 0, + ComposerReady: info.composerReady ? 'yes' : 'no', + Url: info.url || '', + }]; + }, +}); diff --git a/clis/trae-cn/targets.js b/clis/trae-cn/targets.js new file mode 100644 index 000000000..edfa8f1aa --- /dev/null +++ b/clis/trae-cn/targets.js @@ -0,0 +1,96 @@ +import { CDPBridge } from '@jackwener/opencli/browser/cdp'; +import { ArgumentError } from '@jackwener/opencli/errors'; +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { activityScript } from './utils.js'; + +function normalizeEndpoint(value) { + const endpoint = String(value || process.env.OPENCLI_CDP_ENDPOINT || 'http://127.0.0.1:39240').trim(); + if (!endpoint) { + throw new ArgumentError('Set OPENCLI_CDP_ENDPOINT, for example http://127.0.0.1:39240'); + } + return endpoint.replace(/\/$/, ''); +} + +function normalizeBoolean(value, fallback = true) { + if (value === undefined || value === null) return fallback; + return value === true || String(value).toLowerCase() === 'true'; +} + +function targetValue(target) { + const title = String(target?.title || '').trim(); + const workspace = title.match(/—\s*(.+)$/)?.[1]?.trim(); + return workspace || title || String(target?.url || '').trim(); +} + +function matchesTarget(target, wanted) { + if (!wanted) return false; + const needle = String(wanted).toLowerCase(); + const haystack = `${target.title || ''} ${target.url || ''}`.toLowerCase(); + return haystack.includes(needle); +} + +async function fetchTargets(endpoint) { + const response = await fetch(`${endpoint}/json`, { signal: AbortSignal.timeout(5000) }); + if (!response.ok) { + throw new Error(`Failed to fetch CDP targets: HTTP ${response.status}`); + } + const targets = await response.json(); + return Array.isArray(targets) ? targets : []; +} + +async function probeTargetActivity(target, maxChars) { + if (!target.webSocketDebuggerUrl) return null; + const bridge = new CDPBridge(); + try { + const page = await bridge.connect({ cdpEndpoint: target.webSocketDebuggerUrl, timeout: 3, surface: 'adapter' }); + return await page.evaluate(activityScript(maxChars)); + } catch { + return null; + } finally { + await bridge.close().catch(() => {}); + } +} + +export const targetsCommand = cli({ + site: 'trae-cn', + name: 'targets', + access: 'read', + description: 'List Trae CN CDP targets and show which workspace/window is waiting', + example: 'OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 opencli trae-cn targets -f table', + strategy: Strategy.LOCAL, + browser: false, + args: [ + { name: 'endpoint', type: 'string', required: false, help: 'CDP endpoint (default: OPENCLI_CDP_ENDPOINT)' }, + { name: 'probe-activity', type: 'boolean', required: false, help: 'Probe each target for Trae activity state (default: true)', default: true }, + { name: 'max-chars', type: 'int', required: false, help: 'Max chars for approval prompt/activity snippets (default: 240)', default: 240 }, + ], + columns: ['Index', 'SelectedHint', 'RecommendedTarget', 'Title', 'Workspace', 'Status', 'ApprovalPending', 'ApprovalKind', 'ApprovalButton', 'Model', 'Agent', 'Type', 'Url'], + func: async (kwargs) => { + const endpoint = normalizeEndpoint(kwargs.endpoint); + const probe = normalizeBoolean(kwargs['probe-activity'], true); + const maxChars = Number.isInteger(Number(kwargs['max-chars'])) ? Number(kwargs['max-chars']) : 240; + const wanted = process.env.OPENCLI_CDP_TARGET || ''; + const targets = (await fetchTargets(endpoint)).filter(target => target.webSocketDebuggerUrl && target.type !== 'worker'); + const rows = []; + + for (const [index, target] of targets.entries()) { + const activity = probe ? await probeTargetActivity(target, maxChars) : null; + rows.push({ + Index: index, + SelectedHint: wanted ? (matchesTarget(target, wanted) ? 'yes' : 'no') : (index === 0 ? 'default' : ''), + RecommendedTarget: targetValue(target), + Title: target.title || '', + Workspace: activity?.Workspace || target.title?.match(/—\s*(.+)$/)?.[1]?.trim() || '', + Status: activity?.Status || '', + ApprovalPending: activity?.ApprovalPending || '', + ApprovalKind: activity?.ApprovalKind || '', + ApprovalButton: activity?.ApprovalButton || '', + Model: activity?.Model || '', + Agent: activity?.Agent || '', + Type: target.type || '', + Url: target.url || '', + }); + } + return rows; + }, +}); diff --git a/clis/trae-cn/trae-cn.test.js b/clis/trae-cn/trae-cn.test.js new file mode 100644 index 000000000..7c48d0536 --- /dev/null +++ b/clis/trae-cn/trae-cn.test.js @@ -0,0 +1,327 @@ +import { describe, expect, it, vi } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { TimeoutError } from '@jackwener/opencli/errors'; +import { activityCommand } from './activity.js'; +import { approveCommand } from './approve.js'; +import { askCommand } from './ask.js'; +import { modelCommand } from './model.js'; +import { newCommand } from './new.js'; +import { readCommand } from './read.js'; +import { selectModelCommand } from './select-model.js'; +import { sendCommand } from './send.js'; +import { setupCommand } from './setup.js'; +import { statusCommand } from './status.js'; +import { targetsCommand } from './targets.js'; +import { watchCommand } from './watch.js'; +import { + activityScript, + approvalPromptsScript, + clickNewTaskScript, + countTurnsScript, + currentTaskStateScript, + injectPromptScript, + isLikelyBlockingActivity, + listOpenModelItemsScript, + normalizeApprovalKinds, + normalizeApprovalLimit, + normalizeLimit, + normalizeMaxChars, + normalizeModelLabel, + normalizeTimeout, + readMessagesScript, + submitPromptScript, +} from './utils.js'; + +function evaluateInDom(html, script) { + const dom = new JSDOM(html, { runScripts: 'dangerously' }); + return dom.window.eval(script); +} + +describe('trae-cn utils', () => { + it('extracts user and assistant turns from Trae CN DOM', () => { + const rows = evaluateInDom(` +
+
User
+
hello
+
world
+
+
+
Trae Agent
+

TRAE_OPENCLI_SMOKE_OK

+
copy
+
+ `, readMessagesScript(10)); + + expect(rows).toEqual([ + { Role: 'User', Text: 'hello\nworld', TextChars: 11, Truncated: 'no', TurnIndex: '1', MessageId: 'u1' }, + { Role: 'Assistant', Text: 'TRAE_OPENCLI_SMOKE_OK', TextChars: 21, Truncated: 'no', TurnIndex: '2', MessageId: 'a1' }, + ]); + }); + + it('limits and truncates returned turns', () => { + const rows = evaluateInDom(` +
one
+
abcdef
+ `, readMessagesScript(1, 3)); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ Text: 'abc\n...[truncated, 3 chars omitted]', TextChars: 6, Truncated: 'yes' }); + }); + + it('validates timeout, limit, and approval arguments', () => { + expect(normalizeTimeout(undefined, 60)).toBe(60); + expect(normalizeLimit(undefined, 20)).toBe(20); + expect(normalizeMaxChars(undefined, 6000)).toBe(6000); + expect(normalizeApprovalKinds(undefined)).toEqual(['terminal', 'delete']); + expect(normalizeApprovalKinds('terminal,delete')).toEqual(['terminal', 'delete']); + expect(normalizeApprovalKinds('all')).toEqual(['terminal', 'delete', 'keep']); + expect(normalizeApprovalLimit(undefined, 1)).toBe(1); + expect(() => normalizeTimeout(0)).toThrow('--timeout'); + expect(() => normalizeLimit(201)).toThrow('--limit'); + expect(() => normalizeMaxChars(-1)).toThrow('--max-chars'); + expect(() => normalizeApprovalKinds('terminal,unknown')).toThrow('--approve-kinds'); + expect(() => normalizeApprovalLimit(21)).toThrow('--limit'); + }); + + it('builds injectable scripts for composer, turn count, and model selection', () => { + expect(injectPromptScript('hello')).toContain('chat-input-v2-input-box-editable'); + expect(submitPromptScript()).toContain('chat-input-v2-send-button'); + expect(countTurnsScript()).toContain('section.chat-turn'); + expect(normalizeModelLabel('GPT 5.4')).toBe('gpt5.4'); + expect(normalizeModelLabel('GPT-5.4')).toBe('gpt5.4'); + expect(listOpenModelItemsScript()).toContain('icube-model-select-portal-model-item'); + }); + + it('clicks the Trae new task control by stable data-testid', () => { + const dom = new JSDOM(` + 新建 + `, { runScripts: 'dangerously' }); + const button = dom.window.document.querySelector('[data-testid="ai-chat-create-new-session"]'); + button.click = () => button.setAttribute('data-clicked', 'yes'); + button.getBoundingClientRect = () => ({ width: 120, height: 32 }); + + const result = dom.window.eval(clickNewTaskScript()); + expect(result).toMatchObject({ ok: true, method: 'data-testid-or-class' }); + expect(button.getAttribute('data-clicked')).toBe('yes'); + }); + + it('reads the current task state', () => { + const state = evaluateInDom(` + AGENTS.md (Preview) — talk +
@Trae Agent
+
GPT-5.4
+
+ `, currentTaskStateScript()); + + expect(state).toMatchObject({ workspace: 'talk', model: 'GPT-5.4', agent: '@Trae Agent', turns: 0, composerReady: true }); + }); + + it('detects and clicks terminal approval prompts', () => { + const dom = new JSDOM(` +
+

Trae 请求运行终端命令:printf OPENCLI_APPROVE

+ + +
+ `, { runScripts: 'dangerously' }); + const approve = dom.window.document.querySelector('#approve'); + const cancel = dom.window.document.querySelector('#cancel'); + approve.click = () => approve.setAttribute('data-clicked', 'yes'); + cancel.click = () => cancel.setAttribute('data-clicked', 'yes'); + + const rows = dom.window.eval(approvalPromptsScript(['terminal'], { click: true, limit: 1, maxChars: 200 })); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ Status: 'Approved', Kind: 'terminal', Button: '同意运行', Action: 'clicked' }); + expect(rows[0].Prompt).toContain('运行终端命令'); + expect(approve.getAttribute('data-clicked')).toBe('yes'); + expect(cancel.getAttribute('data-clicked')).toBeNull(); + }); + + it('does not click ordinary terminal result-card controls without a confirmation prompt', () => { + const dom = new JSDOM(` +
+
+

workspace 白名单运行 在终端查看

+ + +
+
+ `, { runScripts: 'dangerously' }); + for (const button of dom.window.document.querySelectorAll('button')) { + button.click = () => button.setAttribute('data-clicked', 'yes'); + } + + const rows = dom.window.eval(approvalPromptsScript(['terminal'], { click: true, limit: 1, maxChars: 200 })); + expect(rows).toEqual([]); + expect(dom.window.document.querySelector('#sandbox').getAttribute('data-clicked')).toBeNull(); + expect(dom.window.document.querySelector('#run').getAttribute('data-clicked')).toBeNull(); + }); + + it('does not click terminal run-mode dropdowns inside a real confirmation card', () => { + const dom = new JSDOM(` +
+

检测到高风险命令 echo ok,运行命令可能会带来严重后果,是否仍要在沙箱中运行?

+ + + + + +
+ `, { runScripts: 'dangerously' }); + for (const button of dom.window.document.querySelectorAll('button')) { + button.click = () => button.setAttribute('data-clicked', 'yes'); + } + + const rows = dom.window.eval(approvalPromptsScript(['terminal'], { click: true, limit: 1, maxChars: 200 })); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ Kind: 'terminal', Button: '运行' }); + expect(dom.window.document.querySelector('#run').getAttribute('data-clicked')).toBe('yes'); + expect(dom.window.document.querySelector('#mode').getAttribute('data-clicked')).toBeNull(); + expect(dom.window.document.querySelector('#auto').getAttribute('data-clicked')).toBeNull(); + expect(dom.window.document.querySelector('#allowlist').getAttribute('data-clicked')).toBeNull(); + expect(dom.window.document.querySelector('#skip').getAttribute('data-clicked')).toBeNull(); + }); + + it('prioritizes the second high-risk terminal confirmation modal', () => { + const dom = new JSDOM(` +
+

运行终端命令 rm -rf tmp-file

+ +
+
+

运行风险命令可能导致不可挽回的后果,确认是否仍要执行?

+ + +
+ `, { runScripts: 'dangerously' }); + for (const button of dom.window.document.querySelectorAll('button')) { + button.click = () => button.setAttribute('data-clicked', 'yes'); + } + + const rows = dom.window.eval(approvalPromptsScript(['terminal'], { click: true, limit: 1, maxChars: 300 })); + expect(rows[0]).toMatchObject({ Kind: 'terminal', Button: '仍要运行', Action: 'clicked' }); + expect(dom.window.document.querySelector('#run-modal').getAttribute('data-clicked')).toBe('yes'); + expect(dom.window.document.querySelector('#run-card').getAttribute('data-clicked')).toBeNull(); + }); + + it('distinguishes delete approval from keep approval', () => { + const dom = new JSDOM(` +
+

是否删除 opencli.tmp?删除后文件无法恢复。

+ + +
+ `, { runScripts: 'dangerously' }); + const keep = dom.window.document.querySelector('#keep'); + const remove = dom.window.document.querySelector('#delete'); + keep.click = () => keep.setAttribute('data-clicked', 'yes'); + remove.click = () => remove.setAttribute('data-clicked', 'yes'); + + const deleteRows = dom.window.eval(approvalPromptsScript(['delete'], { click: true, limit: 1, maxChars: 200 })); + expect(deleteRows[0]).toMatchObject({ Kind: 'delete', Button: '删除' }); + expect(remove.getAttribute('data-clicked')).toBe('yes'); + expect(keep.getAttribute('data-clicked')).toBeNull(); + + const keepRows = dom.window.eval(approvalPromptsScript(['keep'], { click: true, limit: 1, maxChars: 200 })); + expect(keepRows[0]).toMatchObject({ Kind: 'keep', Button: '保留文档' }); + expect(keep.getAttribute('data-clicked')).toBe('yes'); + }); + + it('summarizes activity and blocking approval state', () => { + const row = evaluateInDom(` + Task — talk +
@Trae Agent
+
GPT-5.4
+
+
+
+
Run command
+
+
+
+

Trae 请求运行终端命令:npm test

+ +
+
+ `, activityScript(600)); + + expect(row).toMatchObject({ + Status: 'running', + Workspace: 'talk', + Model: 'GPT-5.4', + Agent: '@Trae Agent', + ApprovalPending: 'yes', + ApprovalKind: 'terminal', + ApprovalButton: '同意运行', + }); + expect(isLikelyBlockingActivity(row)).toBe(true); + }); +}); + +describe('trae-cn commands', () => { + it('keeps the first PR command surface focused on the desktop adapter loop', async () => { + const setupRows = await setupCommand.func(); + const setupText = setupRows.map(row => `${row.Command}\n${row.Purpose}`).join('\n'); + expect(setupText).toContain('open -a "Trae CN" --args --remote-debugging-port=39240'); + expect(setupText).toContain('export OPENCLI_CDP_TARGET="talk"'); + expect(setupText).toContain('opencli trae-cn approve --approve-kinds terminal,delete -f json'); + expect(setupText).toContain('opencli trae-cn watch --stream true --duration 120 --auto-approve false'); + }); + + it('documents endpoint/target examples for browser commands', () => { + for (const command of [activityCommand, approveCommand, askCommand, modelCommand, newCommand, readCommand, selectModelCommand, sendCommand, statusCommand, watchCommand]) { + expect(command.example).toContain('OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240'); + expect(command.example).toContain('OPENCLI_CDP_TARGET='); + } + expect(targetsCommand.example).toContain('OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240'); + }); + + it('keeps ask/watch auto-approval on by default for terminal/delete and keeps keep opt-in', () => { + expect(askCommand.args.find(arg => arg.name === 'auto-approve')).toMatchObject({ type: 'boolean', default: true }); + expect(watchCommand.args.find(arg => arg.name === 'auto-approve')).toMatchObject({ type: 'boolean', default: true }); + expect(askCommand.args.find(arg => arg.name === 'approve-kinds')).toMatchObject({ default: 'terminal,delete' }); + expect(watchCommand.args.find(arg => arg.name === 'approve-kinds')).toMatchObject({ default: 'terminal,delete' }); + }); + + it('approve supports dry-run without clicking', async () => { + const page = { + evaluate: vi.fn().mockResolvedValueOnce([ + { Status: 'Detected', Kind: 'terminal', Button: '运行', Prompt: '运行终端命令', Selector: 'button', Action: 'detected' }, + ]), + }; + + const rows = await approveCommand.func(page, { 'approve-kinds': 'terminal,delete', limit: 1, 'max-chars': 300, 'dry-run': true }); + expect(rows).toEqual([ + { Status: 'Detected', Kind: 'terminal', Button: '运行', Prompt: '运行终端命令', Selector: 'button', Action: 'detected' }, + ]); + }); + + it('approve returns an explicit no-prompt row on dry-run when nothing matches', async () => { + const page = { + evaluate: vi.fn().mockResolvedValueOnce([]), + }; + + const rows = await approveCommand.func(page, { 'approve-kinds': 'terminal', limit: 1, 'dry-run': true }); + expect(rows[0]).toMatchObject({ Status: 'NoPrompt', Kind: 'terminal', Action: 'dry-run' }); + }); + + it('ask throws a typed timeout instead of returning a system sentinel row', async () => { + const page = { + evaluate: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: true, mode: 'button' }), + wait: vi.fn().mockResolvedValue(undefined), + }; + let now = 1_000; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => { + now += 2_000; + return now; + }); + try { + await expect(askCommand.func(page, { text: 'ping', timeout: 1, 'auto-approve': false })) + .rejects.toBeInstanceOf(TimeoutError); + } finally { + nowSpy.mockRestore(); + } + }); +}); diff --git a/clis/trae-cn/utils.js b/clis/trae-cn/utils.js new file mode 100644 index 000000000..b4733ac32 --- /dev/null +++ b/clis/trae-cn/utils.js @@ -0,0 +1,704 @@ +import { ArgumentError, EmptyResultError, selectorError } from '@jackwener/opencli/errors'; + +export const TRAE_CN_COMPOSER_SELECTOR = '.chat-input-v2-input-box-editable[data-lexical-editor="true"], .chat-input-v2-input-box-editable[contenteditable="true"]'; +export const TRAE_CN_SEND_BUTTON_SELECTOR = '.chat-input-v2-send-button'; +export const TRAE_CN_TURN_SELECTOR = 'section.chat-turn'; +export const TRAE_CN_MODEL_TRIGGER_SELECTOR = 'button.icd-model-select-trigger'; +export const TRAE_CN_MODEL_ITEM_SELECTOR = '.icube-model-select-portal-model-item'; +export const TRAE_CN_NEW_TASK_SELECTOR = '[data-testid="ai-chat-create-new-session"], [class*="new-task-button"]'; +export const TRAE_CN_APPROVAL_DEFAULT_KINDS = ['terminal', 'delete']; +export const TRAE_CN_HIGH_RISK_COMMAND_PATTERNS = [ + 'rm', + 'delete', + 'unlink', + 'shred', + 'dd', + 'truncate', + 'kill', + 'chmod', + 'mv', + 'copy', + 'move', + 'Set-Content', + 'Out-File', + 'mkfs', + 'git force/delete/hard/filter/rebase operations', + 'destructive database commands', +]; + +export function normalizeTimeout(value, fallback = 60) { + const timeout = value === undefined || value === null ? fallback : Number(value); + if (!Number.isInteger(timeout) || timeout < 1) { + throw new ArgumentError('--timeout must be a positive integer (seconds)'); + } + return timeout; +} + +export function normalizeLimit(value, fallback = 20) { + const limit = value === undefined || value === null ? fallback : Number(value); + if (!Number.isInteger(limit) || limit < 1 || limit > 200) { + throw new ArgumentError('--limit must be an integer between 1 and 200'); + } + return limit; +} + +export function normalizeMaxChars(value, fallback = 6000) { + const maxChars = value === undefined || value === null ? fallback : Number(value); + if (!Number.isInteger(maxChars) || maxChars < 0 || maxChars > 1_000_000) { + throw new ArgumentError('--max-chars must be an integer between 0 and 1000000'); + } + return maxChars; +} + +export function normalizeDuration(value, fallback = 30) { + const duration = value === undefined || value === null ? fallback : Number(value); + if (!Number.isInteger(duration) || duration < 1 || duration > 3600) { + throw new ArgumentError('--duration must be an integer between 1 and 3600 seconds'); + } + return duration; +} + +export function normalizeInterval(value, fallback = 2) { + const interval = value === undefined || value === null ? fallback : Number(value); + if (!Number.isInteger(interval) || interval < 1 || interval > 300) { + throw new ArgumentError('--interval must be an integer between 1 and 300 seconds'); + } + return interval; +} + +export function normalizeApprovalKinds(value, fallback = TRAE_CN_APPROVAL_DEFAULT_KINDS.join(',')) { + const raw = value === undefined || value === null || value === '' ? fallback : value; + const parts = Array.isArray(raw) ? raw : String(raw).split(','); + const expanded = []; + for (const part of parts) { + const item = String(part || '').trim().toLowerCase(); + if (!item) continue; + if (item === 'all') { + expanded.push('terminal', 'delete', 'keep'); + continue; + } + if (!['terminal', 'delete', 'keep'].includes(item)) { + throw new ArgumentError('--approve-kinds must contain only terminal, delete, keep, or all'); + } + expanded.push(item); + } + const unique = Array.from(new Set(expanded)); + if (unique.length === 0) { + throw new ArgumentError('--approve-kinds must contain at least one approval kind'); + } + return unique; +} + +export function normalizeApprovalLimit(value, fallback = 1) { + const limit = value === undefined || value === null ? fallback : Number(value); + if (!Number.isInteger(limit) || limit < 1 || limit > 20) { + throw new ArgumentError('--limit must be an integer between 1 and 20'); + } + return limit; +} + +export function ensurePrompt(text) { + const prompt = typeof text === 'string' ? text : ''; + if (!prompt.trim()) { + throw new ArgumentError('text must not be empty'); + } + return prompt; +} + +export function normalizeModelLabel(text) { + return String(text || '').toLowerCase().replace(/[^a-z0-9.]+/g, ''); +} + +export function listOpenModelItemsScript() { + return ` + (function() { + return Array.from(document.querySelectorAll('${TRAE_CN_MODEL_ITEM_SELECTOR}')) + .map((el, index) => ({ + Index: index, + Model: String(el.innerText || el.textContent || '').replace(/\\s+/g, ' ').trim() + })) + .filter(item => item.Model); + })() + `; +} + +export function inspectTraeShellScript() { + return ` + (function() { + const textOf = (el) => String(el?.innerText || el?.textContent || '').trim(); + const model = textOf(document.querySelector('.chat-input-v2-editor-part-lower__left')) + || textOf(document.querySelector('.chat-input-v2-editor-part-lower-content')) + || ''; + const agent = textOf(document.querySelector('.chat-input-selected-agent-name')) + || textOf(document.querySelector('.chat-input-selected-agent-title')) + || ''; + const title = document.title || ''; + const workspaceMatch = title.match(/—\\s*(.+)$/); + return { + title, + url: location.href, + workspace: workspaceMatch ? workspaceMatch[1].trim() : '', + model, + agent, + turns: document.querySelectorAll('${TRAE_CN_TURN_SELECTOR}').length, + composerReady: !!document.querySelector('${TRAE_CN_COMPOSER_SELECTOR}'), + }; + })() + `; +} + +export function clickNewTaskScript() { + return ` + (function() { + const isVisible = (el) => { + if (!el) return false; + const rect = el.getBoundingClientRect(); + const style = window.getComputedStyle(el); + return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; + }; + const isDisabled = (el) => el.getAttribute('aria-disabled') === 'true' || el.disabled === true; + const candidates = Array.from(document.querySelectorAll('${TRAE_CN_NEW_TASK_SELECTOR}')) + .filter(el => isVisible(el) && !isDisabled(el)); + const textCandidates = Array.from(document.querySelectorAll('button, a, [role="button"], div')) + .filter(el => isVisible(el) && !isDisabled(el)) + .filter(el => /(^|\\s)新任务(\\s|$)/.test(String(el.innerText || el.textContent || '').replace(/\\s+/g, ' ').trim())); + const target = candidates[0] || textCandidates[0] || null; + if (!target) { + return { ok: false, reason: 'new_task_button_not_found' }; + } + target.click(); + return { + ok: true, + method: candidates[0] ? 'data-testid-or-class' : 'text', + label: String(target.getAttribute('aria-label') || target.innerText || target.textContent || '').replace(/\\s+/g, ' ').trim() + }; + })() + `; +} + +export function currentTaskStateScript() { + return ` + (function() { + const textOf = (el) => String(el?.innerText || el?.textContent || '').trim(); + const model = textOf(document.querySelector('.chat-input-v2-editor-part-lower__left')) + || textOf(document.querySelector('.chat-input-v2-editor-part-lower-content')) + || ''; + const agent = textOf(document.querySelector('.chat-input-selected-agent-name')) + || textOf(document.querySelector('.chat-input-selected-agent-title')) + || ''; + const title = document.title || ''; + const workspaceMatch = title.match(/—\\s*(.+)$/); + return { + workspace: workspaceMatch ? workspaceMatch[1].trim() : '', + model, + agent, + turns: document.querySelectorAll('${TRAE_CN_TURN_SELECTOR}').length, + composerReady: !!document.querySelector('${TRAE_CN_COMPOSER_SELECTOR}'), + }; + })() + `; +} + +export function isLikelyBlockingActivity(activity) { + if (!activity || typeof activity !== 'object') return false; + if (activity.ApprovalPending === 'yes') return true; + const text = `${activity.LatestText || ''}\n${activity.Thinking || ''}`; + return activity.Status === 'running' && /(等待操作|正在等待|命令运行中|检测到高风险|高风险命令|运行风险命令|请在运行前检查|仍要运行|自动运行\s+跳过\s+运行|手动运行\s+取消|\$\s*(rm|mv|chmod|dd|truncate|kill)\b)/i.test(text); +} + +export function isActivityComplete(activity) { + return activity && activity.Status === 'completed'; +} + +function approvalPromptRowsExpression(kinds = TRAE_CN_APPROVAL_DEFAULT_KINDS, options = {}) { + const click = Boolean(options.click); + const limit = options.limit === undefined ? 1 : Number(options.limit); + const maxChars = options.maxChars === undefined ? 600 : Number(options.maxChars); + const markCandidates = Boolean(options.markCandidates); + return ` + (function(kinds, click, limit, maxChars, markCandidates) { + const normalize = (text) => String(text || '') + .replace(/\\u00a0/g, ' ') + .replace(/\\s+/g, ' ') + .trim(); + const trim = (text) => { + const full = normalize(text); + if (maxChars > 0 && full.length > maxChars) { + return full.slice(0, maxChars) + '...[truncated, ' + (full.length - maxChars) + ' chars omitted]'; + } + return full; + }; + const wanted = new Set(Array.isArray(kinds) ? kinds : ['terminal', 'delete']); + if (wanted.has('all')) { + wanted.add('terminal'); + wanted.add('delete'); + wanted.add('keep'); + wanted.delete('all'); + } + const isJsdom = /jsdom/i.test(String(navigator.userAgent || '')); + const isVisible = (el) => { + if (!el) return false; + const style = window.getComputedStyle(el); + if (style.visibility === 'hidden' || style.display === 'none') return false; + const rect = el.getBoundingClientRect(); + return isJsdom || rect.width > 0 || rect.height > 0 || el.getClientRects().length > 0; + }; + const isDisabled = (el) => el.disabled === true + || el.getAttribute('aria-disabled') === 'true' + || el.classList.contains('disabled') + || /disabled/i.test(String(el.getAttribute('class') || '')); + const labelOf = (el) => normalize( + el.getAttribute('aria-label') + || el.getAttribute('title') + || el.innerText + || el.textContent + || '' + ); + const rootSelectors = [ + '[role="dialog"]', + 'dialog', + '[aria-modal="true"]', + '[class*="modal"]', + '[class*="Modal"]', + '[class*="popover"]', + '[class*="Popover"]', + '[class*="popup"]', + '[class*="Popup"]', + '[class*="permission"]', + '[class*="Permission"]', + '[class*="confirm"]', + '[class*="Confirm"]', + '[class*="terminal"]', + '[class*="Terminal"]', + '[class*="tool"]', + '[class*="Tool"]', + '.ai-agent-task', + '.task-artifact-container', + 'section.chat-turn' + ].join(','); + const rootOf = (el) => { + const ancestors = []; + let node = el.parentElement; + for (let depth = 0; node && depth < 8; depth += 1) { + ancestors.push(node); + const text = normalize(node.innerText || node.textContent); + if (!/^(BODY|HTML)$/i.test(node.tagName || '') && /(确认删除|删除后文件无法恢复|是否仍要删除|检测到高风险|是否.*运行|删除\\s*:)/i.test(text)) { + return node; + } + node = node.parentElement; + } + const direct = el.closest(rootSelectors); + if (direct) return direct; + for (const ancestor of ancestors) { + if (normalize(ancestor.innerText || ancestor.textContent).length > 0) return ancestor; + } + return el; + }; + const priorityOf = (kind, label, context, button) => { + const cls = String(button.className || ''); + if (kind === 'delete' && /(确认删除|删除后文件无法恢复|是否仍要删除)/i.test(context)) return 100; + if (kind === 'terminal' && /(运行风险命令|不可挽回|仍要执行|确认是否仍要执行)/i.test(context) && /(仍要运行|运行|确认|同意|允许)/i.test(label)) return 110; + if (kind === 'terminal' && /(确认|检测到高风险|是否.*运行)/i.test(context) && /运行|确认|同意|允许/i.test(label)) return 80; + if (kind === 'delete' && /icd-delete-files-command-card/i.test(cls + ' ' + context)) return 60; + if (kind === 'keep') return 10; + return 20; + }; + const selectorOf = (el) => { + const testid = el.getAttribute('data-testid'); + if (testid) return '[data-testid="' + testid.replace(/"/g, '\\\\"') + '"]'; + if (el.id) return '#' + el.id; + const cls = Array.from(el.classList || []).slice(0, 2).join('.'); + return el.tagName.toLowerCase() + (cls ? '.' + cls : ''); + }; + const classify = (label, context) => { + const negativeLabel = /(取消|拒绝|不同意|不允许|不运行|不删除|跳过|稍后|否|cancel|deny|reject|no|never|skip)/i.test(label); + const runModeLabel = /(自动运行|白名单|沙箱|sandbox|allowlist|模式|mode)/i.test(label); + const keepLabel = /(保留|保留文档|保留文件|keep|retain)/i.test(label); + const terminalContext = /(终端|命令|shell|terminal|command|执行|运行)/i.test(context); + const terminalPromptLike = /(是否|确认|确定|同意|允许|检测到高风险|高风险命令|运行风险命令|严重后果|不可挽回|仍要执行|请在运行前检查|要.*运行|运行.*吗|执行.*吗|\\?|?|confirm|approve|allow|are you sure|do you want|run.*command|execute.*command)/i.test(context); + const terminalLabel = /(同意|允许|运行|执行|继续|确认|确定|approve|allow|run|execute|continue|confirm|ok|yes)/i.test(label); + if (wanted.has('terminal') && terminalContext && terminalPromptLike && terminalLabel && !negativeLabel && !runModeLabel && !keepLabel) { + return 'terminal'; + } + const deleteContext = /(删除|移除|废纸篓|delete|remove|trash)/i.test(context); + const deletePromptLike = /(是否|确认|确定|同意|允许|保留|要.*删除|删除.*吗|\\?|?|confirm|approve|allow|are you sure|do you want|keep|retain)/i.test(context); + const deleteLabel = /(删除|移除|确认|确定|同意|允许|继续|delete|remove|confirm|approve|allow|continue|ok|yes)/i.test(label); + if (wanted.has('delete') && deleteContext && deletePromptLike && deleteLabel && !negativeLabel && !keepLabel) { + return 'delete'; + } + if (wanted.has('keep') && deleteContext && deletePromptLike && keepLabel && !negativeLabel) { + return 'keep'; + } + return ''; + }; + + const buttons = Array.from(document.querySelectorAll('button, [role="button"]')) + .filter((button) => isVisible(button) && !isDisabled(button)); + const candidates = []; + const seen = new Set(); + for (const button of buttons) { + const label = labelOf(button); + if (!label) continue; + const root = rootOf(button); + const context = normalize(root.innerText || root.textContent); + const kind = classify(label, context); + if (!kind) continue; + const key = kind + '|' + label + '|' + context.slice(0, 200); + if (seen.has(key)) continue; + seen.add(key); + candidates.push({ + button, + kind, + label, + context, + selector: selectorOf(button), + priority: priorityOf(kind, label, context, button), + }); + } + candidates.sort((a, b) => b.priority - a.priority); + const rows = []; + let clicked = 0; + for (const candidate of candidates) { + const shouldClick = click && clicked < limit; + let selector = candidate.selector; + if (markCandidates) { + const marker = 'opencli-approval-' + rows.length + '-' + Math.random().toString(36).slice(2); + candidate.button.setAttribute('data-opencli-approval-id', marker); + selector = '[data-opencli-approval-id="' + marker + '"]'; + } + const row = { + Status: shouldClick ? 'Approved' : 'Detected', + Kind: candidate.kind, + Button: candidate.label, + Prompt: trim(candidate.context), + Selector: selector, + Action: shouldClick ? 'clicked' : 'detected', + }; + rows.push(row); + if (shouldClick) { + candidate.button.click(); + clicked += 1; + } + } + return rows; + })(${JSON.stringify(kinds)}, ${JSON.stringify(click)}, ${JSON.stringify(limit)}, ${JSON.stringify(maxChars)}, ${JSON.stringify(markCandidates)}) + `; +} + +export function approvalPromptsScript(kinds = TRAE_CN_APPROVAL_DEFAULT_KINDS, options = {}) { + return approvalPromptRowsExpression(kinds, options); +} + +export async function approveTraePrompts(page, kinds = TRAE_CN_APPROVAL_DEFAULT_KINDS, options = {}) { + const shouldClick = options.click !== false; + const limit = options.limit === undefined ? 1 : options.limit; + const maxChars = options.maxChars === undefined ? 600 : options.maxChars; + const canNativeClick = shouldClick && typeof page.click === 'function'; + const rows = await page.evaluate(approvalPromptsScript(kinds, { + click: shouldClick && !canNativeClick, + limit, + maxChars, + markCandidates: canNativeClick, + })); + const detected = Array.isArray(rows) ? rows : []; + if (!canNativeClick || detected.length === 0) return detected; + + const clickedRows = []; + let clicked = 0; + for (const row of detected) { + if (clicked < limit) { + try { + await page.click(row.Selector); + clickedRows.push({ ...row, Status: 'Approved', Action: 'clicked' }); + clicked += 1; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + clickedRows.push({ ...row, Status: 'Detected', Action: `click-failed: ${message}` }); + } + } else { + clickedRows.push(row); + } + } + return clickedRows; +} + +export function readMessagesScript(limit = 20, maxChars = 0) { + return ` + (function() { + const normalize = (text) => String(text || '').replace(/\\u00a0/g, ' ').replace(/[ \\t]+\\n/g, '\\n').replace(/\\n{3,}/g, '\\n\\n').trim(); + const truncate = (text) => { + const full = normalize(text); + const max = ${JSON.stringify(maxChars)}; + if (max > 0 && full.length > max) { + return { + text: full.slice(0, max) + '\\n...[truncated, ' + (full.length - max) + ' chars omitted]', + chars: full.length, + truncated: 'yes' + }; + } + return { text: full, chars: full.length, truncated: 'no' }; + }; + const cleanClone = (node) => { + const clone = node.cloneNode(true); + clone.querySelectorAll([ + '.chat-turn-heading', + '.icube-references-container', + '.assistant-action-bar', + '.latest-assistant-bar', + '.actions-group', + '.floating-actions', + 'button', + 'svg', + 'style', + 'script' + ].join(',')).forEach(el => el.remove()); + return clone; + }; + const textForTurn = (turn, role) => { + if (role === 'user') { + const lines = Array.from(turn.querySelectorAll('.user-chat-line')) + .map(line => normalize(line.innerText || line.textContent)) + .filter(Boolean); + if (lines.length > 0) return lines.join('\\n'); + } + const markdown = Array.from(turn.querySelectorAll('.chat-markdown, .thinking-markdown, .ai-agent-task-section-main, .task-artifact-container')) + .map(el => normalize(el.innerText || el.textContent)) + .filter(Boolean); + if (markdown.length > 0) return markdown.join('\\n\\n'); + return normalize(cleanClone(turn).innerText || cleanClone(turn).textContent); + }; + const turns = Array.from(document.querySelectorAll('${TRAE_CN_TURN_SELECTOR}')); + return turns.map((turn, index) => { + const rawRole = turn.getAttribute('data-role') || (turn.classList.contains('user') ? 'user' : turn.classList.contains('assistant') ? 'assistant' : 'unknown'); + const role = rawRole === 'user' ? 'User' : rawRole === 'assistant' ? 'Assistant' : 'Unknown'; + const text = truncate(textForTurn(turn, rawRole)); + return { + Role: role, + Text: text.text, + TextChars: text.chars, + Truncated: text.truncated, + TurnIndex: turn.getAttribute('data-turn-index') || String(index), + MessageId: turn.getAttribute('data-message-id') || '' + }; + }).filter(item => item.Text).slice(-${JSON.stringify(limit)}); + })() + `; +} + +export function countTurnsScript() { + return `document.querySelectorAll('${TRAE_CN_TURN_SELECTOR}').length`; +} + +export function latestAssistantScript(maxChars = 0) { + return ` + (function() { + const rows = ${readMessagesScript(200, maxChars)}; + return rows.reverse().find(row => row.Role === 'Assistant') || null; + })() + `; +} + +export function activityScript(maxChars = 1200) { + return ` + (function() { + const normalize = (text) => String(text || '').replace(/\\u00a0/g, ' ').replace(/[ \\t]+\\n/g, '\\n').replace(/\\n{3,}/g, '\\n\\n').trim(); + const trim = (text) => { + const full = normalize(text); + const max = ${JSON.stringify(maxChars)}; + if (max > 0 && full.length > max) return full.slice(0, max) + '\\n...[truncated, ' + (full.length - max) + ' chars omitted]'; + return full; + }; + const textOf = (node) => normalize(node?.innerText || node?.textContent || ''); + const classOf = (node) => String(node?.className || ''); + const title = document.title || ''; + const workspaceMatch = title.match(/—\\s*(.+)$/); + const model = textOf(document.querySelector('.chat-input-v2-editor-part-lower__left')) + || textOf(document.querySelector('.chat-input-v2-editor-part-lower-content')) + || ''; + const agent = textOf(document.querySelector('.chat-input-selected-agent-name')) + || textOf(document.querySelector('.chat-input-selected-agent-title')) + || ''; + const turns = Array.from(document.querySelectorAll('${TRAE_CN_TURN_SELECTOR}')); + const lastTurn = turns[turns.length - 1] || null; + const latestAssistant = turns.slice().reverse().find(turn => (turn.getAttribute('data-role') || '').toLowerCase() === 'assistant' || turn.classList.contains('assistant')) || null; + const latestUser = turns.slice().reverse().find(turn => (turn.getAttribute('data-role') || '').toLowerCase() === 'user' || turn.classList.contains('user')) || null; + const latestAssistantIndex = latestAssistant ? turns.indexOf(latestAssistant) : -1; + const latestUserIndex = latestUser ? turns.indexOf(latestUser) : -1; + const scoped = latestAssistant || lastTurn || document.body; + const scopedText = textOf(scoped); + const latestRole = lastTurn ? ((lastTurn.getAttribute('data-role') || '').toLowerCase() || (lastTurn.classList.contains('assistant') ? 'assistant' : lastTurn.classList.contains('user') ? 'user' : 'unknown')) : 'none'; + + const taskCards = Array.from(scoped.querySelectorAll('.icd-tasks-list-card-container')); + const taskScope = taskCards[taskCards.length - 1] || scoped; + const taskItems = Array.from(taskScope.querySelectorAll('.icd-tasks-list-item-container')).map((item, index) => { + const icon = item.querySelector('[class*="icd-tasks-list-item-icon"]'); + const content = item.querySelector('[class*="icd-tasks-list-item-content"]') || item; + const cls = [classOf(icon), classOf(content), classOf(item)].join(' '); + const state = /in_progress|active/.test(cls) ? 'running' : /completed|done|success/.test(cls) ? 'completed' : /pending/.test(cls) ? 'pending' : 'unknown'; + return { + index: index + 1, + state, + text: trim(textOf(content)) + }; + }).filter(item => item.text); + + const completed = taskItems.filter(item => item.state === 'completed').length; + const runningItems = taskItems.filter(item => item.state === 'running'); + const pending = taskItems.filter(item => item.state === 'pending').length; + const activeStep = runningItems[0]?.text || taskItems.find(item => item.state === 'pending')?.text || ''; + const progressText = Array.from(taskScope.querySelectorAll('.icd-tasks-list-card-header-text, .icd-tasks-list-card-header')) + .map(el => textOf(el)) + .find(Boolean) || ''; + const progressMatch = progressText.match(/(\\d+\\s*\\/\\s*\\d+)\\s*(?:已完成|completed)/i) + || progressText.match(/(?:进度|progress)[^\\d]{0,12}(\\d+\\s*%)/i) + || (taskCards.length ? scopedText.match(/(\\d+\\s*\\/\\s*\\d+)\\s*(?:已完成|completed)/i) : null); + const progress = progressMatch ? progressMatch[1].replace(/\\s+/g, '') : (taskItems.length ? completed + '/' + taskItems.length : ''); + const hasRunningClass = taskScope.querySelector('.in_progress, [class*="in_progress"], [class*="loading"], [class*="spinner"]') !== null; + const statusBarText = textOf(scoped.querySelector('.latest-assistant-bar .status, .latest-assistant-bar')); + const hasExplicitCompletionMarker = /任务完成|已完成|completed|done/i.test(statusBarText); + const hasPlainCompletionMarker = /任务完成|completed|done/i.test(scopedText); + const allTasksCompleted = taskItems.length > 0 && taskItems.every(item => item.state === 'completed'); + const progressParts = String(progress || '').match(/^(\\d+)\\/(\\d+)$/); + const progressCompleted = progressParts ? Number(progressParts[1]) > 0 && Number(progressParts[1]) === Number(progressParts[2]) : false; + let status = 'idle'; + if (latestUserIndex > latestAssistantIndex) status = 'waiting'; + else if (latestAssistantIndex >= 0 && latestAssistantIndex >= latestUserIndex) { + status = (hasExplicitCompletionMarker || allTasksCompleted || progressCompleted || (!taskItems.length && hasPlainCompletionMarker)) + ? 'completed' + : 'running'; + } + + const thinking = Array.from(scoped.querySelectorAll('.thinking-markdown, .ai-deep-thinking-state-bar, .ai-agent-task')) + .map(el => trim(textOf(el))) + .filter(Boolean) + .slice(-5) + .join('\\n---\\n'); + const approvals = ${approvalPromptRowsExpression(['terminal', 'delete', 'keep'], { click: false, limit: 1, maxChars: 300 })}; + + return { + Status: status, + Workspace: workspaceMatch ? workspaceMatch[1].trim() : '', + Model: model, + Agent: agent, + LatestRole: latestRole ? latestRole[0].toUpperCase() + latestRole.slice(1) : '', + TurnIndex: latestAssistant?.getAttribute('data-turn-index') || lastTurn?.getAttribute('data-turn-index') || '', + MessageId: latestAssistant?.getAttribute('data-message-id') || lastTurn?.getAttribute('data-message-id') || '', + Progress: progress, + ActiveStep: activeStep, + CompletedSteps: completed, + PendingSteps: pending, + TotalSteps: taskItems.length, + TaskSummary: taskItems.map(item => item.index + ':' + item.state + ':' + item.text).join('\\n'), + Thinking: thinking, + ApprovalPending: approvals.length ? 'yes' : 'no', + ApprovalKind: approvals[0]?.Kind || '', + ApprovalButton: approvals[0]?.Button || '', + ApprovalPrompt: approvals[0]?.Prompt || '', + LatestText: trim(scopedText), + TextChars: scopedText.length, + UpdatedAt: new Date().toISOString() + }; + })() + `; +} + +export function injectPromptScript(text) { + return ` + (function(text) { + const editor = document.querySelector('${TRAE_CN_COMPOSER_SELECTOR}'); + if (!editor) return { ok: false, reason: 'composer_not_found' }; + editor.focus(); + const selection = window.getSelection(); + if (selection) { + const range = document.createRange(); + range.selectNodeContents(editor); + selection.removeAllRanges(); + selection.addRange(range); + } + document.execCommand('delete', false); + document.execCommand('insertText', false, text); + return { ok: true }; + })(${JSON.stringify(text)}) + `; +} + +export function submitPromptScript() { + return ` + (function() { + const button = document.querySelector('${TRAE_CN_SEND_BUTTON_SELECTOR}'); + if (button && !button.disabled && !button.classList.contains('disabled')) { + button.click(); + return { ok: true, mode: 'button' }; + } + const editor = document.querySelector('${TRAE_CN_COMPOSER_SELECTOR}'); + if (!editor) return { ok: false, reason: 'composer_not_found' }; + return { ok: false, reason: 'send_button_disabled' }; + })() + `; +} + +export function responseAfterScript(beforeCount, maxChars = 0) { + return ` + (function() { + const turns = Array.from(document.querySelectorAll('${TRAE_CN_TURN_SELECTOR}')); + const after = turns.slice(${JSON.stringify(beforeCount)}); + const assistant = after.reverse().find(turn => (turn.getAttribute('data-role') || '').toLowerCase() === 'assistant' || turn.classList.contains('assistant')); + if (!assistant) return null; + const read = ${readMessagesScript(1, maxChars)}; + const rows = read; + const last = rows[rows.length - 1]; + return last && last.Role === 'Assistant' ? last : null; + })() + `; +} + +export async function readTraeMessages(page, limit = 20, maxChars = 0) { + const messages = await page.evaluate(readMessagesScript(limit, maxChars)); + if (!messages || messages.length === 0) { + throw new EmptyResultError('trae-cn read', 'No conversation history found in Trae CN.'); + } + return messages; +} + +export async function sendTraePrompt(page, text) { + const prompt = ensurePrompt(text); + const injected = await page.evaluate(injectPromptScript(prompt)); + if (!injected?.ok) throw selectorError('Trae CN chat input'); + await page.wait(0.3); + const submitted = await page.evaluate(submitPromptScript()); + if (!submitted?.ok) { + if (submitted?.reason === 'send_button_disabled' && typeof page.pressKey === 'function') { + await page.pressKey('Enter'); + await page.wait(0.8); + return { prompt, mode: 'keyboard' }; + } + throw selectorError('Trae CN send button'); + } + await page.wait(0.8); + return { prompt, mode: submitted.mode || 'unknown' }; +} + +export async function selectTraeModel(page, name) { + const wanted = ensurePrompt(name); + const wantedKey = normalizeModelLabel(wanted); + let models = await page.evaluate(listOpenModelItemsScript()); + if (!models || models.length === 0) { + await page.click(TRAE_CN_MODEL_TRIGGER_SELECTOR); + await page.wait(0.8); + models = await page.evaluate(listOpenModelItemsScript()); + } + const match = models.find((item) => normalizeModelLabel(item.Model) === wantedKey) + || models.find((item) => normalizeModelLabel(item.Model).includes(wantedKey)); + if (!match) { + throw new ArgumentError(`Model "${wanted}" not found in Trae CN model menu`); + } + await page.click(TRAE_CN_MODEL_ITEM_SELECTOR, { nth: match.Index }); + await page.wait(0.8); + const info = await page.evaluate(inspectTraeShellScript()); + return { + requested: wanted, + selected: info.model || match.Model, + workspace: info.workspace || '', + agent: info.agent || '', + }; +} diff --git a/clis/trae-cn/watch.js b/clis/trae-cn/watch.js new file mode 100644 index 000000000..46b79a6d3 --- /dev/null +++ b/clis/trae-cn/watch.js @@ -0,0 +1,119 @@ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + activityScript, + approveTraePrompts, + normalizeApprovalKinds, + normalizeDuration, + normalizeInterval, + normalizeMaxChars, +} from './utils.js'; + +function signature(row) { + return [ + row.Status || '', + row.MessageId || '', + row.TurnIndex || '', + row.Progress || '', + row.ActiveStep || '', + row.ApprovalPending || '', + row.ApprovalKind || '', + row.ApprovalButton || '', + row.TextChars ?? '', + row.LatestText || '', + ].join('|'); +} + +function sleep(seconds) { + return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); +} + +function normalizeStream(value) { + return value === true || String(value).toLowerCase() === 'true'; +} + +function normalizeStopOnComplete(value) { + return value === undefined || value === true || String(value).toLowerCase() === 'true'; +} + +function normalizeAutoApprove(value) { + return value === undefined || value === true || String(value).toLowerCase() === 'true'; +} + +export const watchCommand = cli({ + site: 'trae-cn', + name: 'watch', + access: 'read', + description: 'Sample Trae CN activity over time to monitor long-running tasks', + example: 'OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn watch --stream true --duration 120', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'duration', type: 'int', required: false, help: 'Seconds to observe (default: 30)', default: 30 }, + { name: 'interval', type: 'int', required: false, help: 'Seconds between samples (default: 2)', default: 2 }, + { name: 'max-chars', type: 'int', required: false, help: 'Max chars per text field; 0 returns full text (default: 600)', default: 600 }, + { name: 'timeout', type: 'int', required: false, help: 'Max seconds for the overall watch command (default: 86400)', default: 86400 }, + { name: 'stream', type: 'boolean', required: false, help: 'Write each sample immediately as JSONL and skip result rendering (default: false)', default: false }, + { name: 'stop-on-complete', type: 'boolean', required: false, help: 'Stop watching after the first completed sample (default: true)', default: true }, + { name: 'auto-approve', type: 'boolean', required: false, help: 'Approve visible terminal/delete prompts before each sample, including high-risk terminal confirmation layers (default: true; pass false to disable)', default: true }, + { name: 'approve-kinds', type: 'string', required: false, help: 'Comma-separated approval categories for --auto-approve: terminal,delete,keep,all (default: terminal,delete; keep is not default)', default: 'terminal,delete' }, + ], + columns: ['Sample', 'ElapsedSec', 'Changed', 'Status', 'Progress', 'ActiveStep', 'ApprovalPending', 'ApprovalKind', 'ApprovalButton', 'AutoApproved', 'LatestRole', 'TurnIndex', 'MessageId', 'TextChars', 'LatestText', 'UpdatedAt'], + func: async (page, kwargs) => { + const duration = normalizeDuration(kwargs.duration, 30); + const interval = normalizeInterval(kwargs.interval, 2); + const maxChars = normalizeMaxChars(kwargs['max-chars'], 600); + const stream = normalizeStream(kwargs.stream); + const stopOnComplete = normalizeStopOnComplete(kwargs['stop-on-complete']); + const autoApprove = normalizeAutoApprove(kwargs['auto-approve']); + const approvalKinds = normalizeApprovalKinds(kwargs['approve-kinds']); + const started = Date.now(); + const deadline = started + duration * 1000; + const rows = []; + let previous = ''; + let sample = 0; + + while (Date.now() <= deadline || sample === 0) { + const approved = autoApprove + ? await approveTraePrompts(page, approvalKinds, { click: true, limit: 1, maxChars: 300 }) + : []; + if (approved.length > 0) { + await sleep(0.5); + } + const activity = await page.evaluate(activityScript(maxChars)); + const current = signature(activity); + const row = { + Sample: ++sample, + ElapsedSec: Math.round((Date.now() - started) / 1000), + Changed: previous && previous !== current ? 'yes' : sample === 1 ? 'initial' : 'no', + Status: activity.Status, + Progress: activity.Progress, + ActiveStep: activity.ActiveStep, + ApprovalPending: activity.ApprovalPending, + ApprovalKind: activity.ApprovalKind, + ApprovalButton: activity.ApprovalButton, + AutoApproved: approved + .filter(item => item.Status === 'Approved' || item.Action === 'clicked') + .map(item => `${item.Kind}:${item.Button}`) + .join('\n'), + LatestRole: activity.LatestRole, + TurnIndex: activity.TurnIndex, + MessageId: activity.MessageId, + TextChars: activity.TextChars, + LatestText: activity.LatestText, + UpdatedAt: activity.UpdatedAt, + }; + if (stream) { + process.stdout.write(`${JSON.stringify(row)}\n`); + } else { + rows.push(row); + } + previous = current; + if (stopOnComplete && activity.Status === 'completed') break; + if (Date.now() + interval * 1000 > deadline) break; + await sleep(interval); + } + + return stream ? null : rows; + }, +}); diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 3ec756fa2..e7c88c2c5 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -152,6 +152,7 @@ export default defineConfig({ collapsed: false, items: [ { text: 'Cursor', link: '/adapters/desktop/cursor' }, + { text: 'Trae CN', link: '/adapters/desktop/trae-cn' }, { text: 'Codex', link: '/adapters/desktop/codex' }, { text: 'Antigravity', link: '/adapters/desktop/antigravity' }, { text: 'ChatGPT', link: '/adapters/desktop/chatgpt' }, diff --git a/docs/adapters/desktop/trae-cn.md b/docs/adapters/desktop/trae-cn.md new file mode 100644 index 000000000..f84c3de0b --- /dev/null +++ b/docs/adapters/desktop/trae-cn.md @@ -0,0 +1,127 @@ +# Trae CN + +Control the **Trae CN** desktop app from the terminal through Chrome DevTools Protocol (CDP). The adapter targets the active Trae workbench window and can start a fresh task, send prompts, read responses, switch the current model, and monitor long-running task progress. + +## Prerequisites + +1. Install Trae CN. +2. Launch it with a remote debugging port: + +```bash +open -a "Trae CN" --args --remote-debugging-port=39240 +``` + +3. Point OpenCLI at the Trae CDP endpoint: + +```bash +export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:39240" +``` + +If Trae CN is already running on another remote debugging port, do not restart it just to match this default. Point `OPENCLI_CDP_ENDPOINT` at the port that is currently listening. + +If multiple Trae workspaces are open, select the right target by title: + +```bash +export OPENCLI_CDP_TARGET="talk" +``` + +## Commands + +### Diagnostics + +- `opencli trae-cn setup` — show local launch, environment, verification, task, monitor, and read commands. +- `opencli trae-cn targets` — list Trae CDP targets and show which window/workspace is running or waiting for approval. +- `opencli trae-cn status` — check the active Trae target, workspace, model, agent, turn count, and composer readiness. +- `opencli trae-cn dump` — dump DOM and accessibility snapshot artifacts to `/tmp/trae-cn-dom.html` and `/tmp/trae-cn-snapshot.json`. +- `opencli trae-cn screenshot` — capture DOM and accessibility artifacts for debugging. + +### Task Control + +- `opencli trae-cn new` — start a fresh task in the current workspace. +- `opencli trae-cn new "prompt"` — start a fresh task and submit the first prompt. +- `opencli trae-cn send "prompt"` — send a prompt into the current task. +- `opencli trae-cn ask "prompt" --timeout 120` — send a prompt, wait for the assistant reply without treating visible approval cards as a final answer, and return it. +- `opencli trae-cn read --limit 5` — read recent visible user and assistant turns. +- `opencli trae-cn export --output /tmp/trae-cn.md` — export recent turns as Markdown. +- `opencli trae-cn approve` — approve a visible terminal-run or delete confirmation prompt. + +### Model + +- `opencli trae-cn model` — read the current composer model, agent, and workspace. +- `opencli trae-cn select-model "GPT 5.4"` — select a model from the current composer model menu. + +### Progress Monitoring + +- `opencli trae-cn activity` — read the current task state once. +- `opencli trae-cn watch --duration 60 --interval 2` — sample task state until completion or timeout. +- `opencli trae-cn watch --stream true` — emit one JSON object per sample as JSONL for agent pipelines. +- `opencli trae-cn watch --auto-approve false` — keep watching without clicking terminal/delete approval prompts. + +`watch` defaults to `--stop-on-complete true`, so `--duration` is a maximum observation window. Use `--stop-on-complete false` when you need a fixed-length observation trace. + +### Approval Prompts + +Trae CN may pause a long task for UI confirmations, especially before running terminal commands or deleting files. `ask` and `watch` approve visible terminal/delete prompts by default, because this adapter is meant for unattended local agent runs. The default is category-based: OpenCLI approves Trae's visible `terminal` confirmation UI and visible `delete` confirmation UI. It is not a semantic command allowlist limited to `rm` or `mv`. + +Default approval behavior: + +- `terminal` is enabled by default. It clicks ordinary terminal run confirmations, high-risk command-card `运行`, and the follow-up high-risk modal `仍要运行` when Trae exposes them with matching button/context text. +- `delete` is enabled by default. It clicks file deletion cards and the follow-up irreversible delete modal `确认` when Trae exposes them with matching button/context text. +- `keep` is not enabled by default. Use `--approve-kinds keep` only when the intended action is to retain files rather than delete them. +- Unknown prompts are not clicked. Use `opencli trae-cn activity`, `opencli trae-cn targets`, or `opencli trae-cn approve --dry-run true` to inspect them. + +Disable auto-approval when you need a manual checkpoint: + +```bash +opencli trae-cn watch --stream true --duration 300 --auto-approve false +``` + +You can also approve the current visible prompt directly: + +```bash +opencli trae-cn approve --approve-kinds terminal,delete -f json +``` + +Use `--dry-run true` to inspect matching prompts without clicking. Use `--approve-kinds keep` only when the intended action is to keep/retain a file instead of approving deletion. + +For long-running agent tasks, the normal monitoring command already uses terminal/delete auto-approval: + +```bash +opencli trae-cn watch --stream true --duration 300 +``` + +The auto-approval detector only clicks visible prompts whose button and surrounding prompt text match the requested approval kinds. + +In Trae CN auto-run mode, high-risk shell commands such as `mv` and `rm` can still show confirmation UI. The adapter handles both layers observed in Trae CN: the command card `运行` action and the follow-up `运行风险命令` modal with `仍要运行`. + +Observed high-risk patterns include `rm`, `delete`, `unlink`, `shred`, `dd`, `truncate`, `kill`, `chmod`, `mv`, `copy`, `move`, PowerShell write commands, `mkfs`, destructive Git operations, and destructive database commands. If Trae renders any of these as terminal confirmation UI with known wording/buttons, default `watch` and `ask` approve them through the `terminal` approval kind. Treat these prompts as current-product UI behavior rather than a stable public API. + +When multiple Trae windows are open, use: + +```bash +opencli trae-cn targets -f table +OPENCLI_CDP_TARGET=workspace opencli trae-cn watch --stream true --duration 300 +``` + +`targets` is the quickest way to find the row with `ApprovalPending=yes` and copy its `RecommendedTarget` into `OPENCLI_CDP_TARGET`. Confirm the current state with `targets`, `activity`, `watch`, or `read`. + +## Notes + +- The adapter operates the currently selected Trae workspace target. It does not yet open folders or switch historical conversations. +- `OPENCLI_CDP_TARGET` should be used when multiple Trae windows or workspaces share the same CDP endpoint. +- Trae assistant output may describe model identity differently from the UI. Use `trae-cn status` or `trae-cn model` as the source of truth for the current selected model. +- Sensitive local state under Trae data directories is not required for normal adapter operation. + +## Example + +```bash +opencli trae-cn setup + +OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 \ +OPENCLI_CDP_TARGET=talk \ +opencli trae-cn new "Please reply only: TRAE_OK" + +OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 \ +OPENCLI_CDP_TARGET=talk \ +opencli trae-cn watch --duration 30 --interval 1 --stream true +``` diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 59146a3ee..04f507c08 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -168,6 +168,7 @@ Run `opencli list` for the live registry. | ---------------------------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------- | | **[Cursor](./desktop/cursor.md)** | Control Cursor IDE | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | | **[Codex](./desktop/codex.md)** | Drive OpenAI Codex CLI agent | `status` `send` `read` `new` `extract-diff` `model` `ask` `screenshot` `history` `export` `pin` `unpin` `archive` `rename` | +| **[Trae CN](./desktop/trae-cn.md)** | Control Trae CN tasks | `setup` `targets` `status` `new` `send` `read` `ask` `approve` `model` `select-model` `activity` `watch` `dump` `screenshot` `export` | | **[Antigravity](./desktop/antigravity.md)** | Control Antigravity Ultra | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` | | **[ChatGPT App](./desktop/chatgpt-app.md)** | Automate ChatGPT macOS app | `status` `new` `send` `read` `ask` `model` | | **[ChatWise](./desktop/chatwise.md)** | Multi-LLM client | `status` `new` `send` `read` `ask` `model` `history` `export` `screenshot` | diff --git a/src/electron-apps.test.ts b/src/electron-apps.test.ts index 0fa102126..a353da669 100644 --- a/src/electron-apps.test.ts +++ b/src/electron-apps.test.ts @@ -22,6 +22,15 @@ describe('electron-apps registry', () => { expect(ports).not.toContain(9222); }); + it('returns builtin app entry for Trae CN', () => { + const app = getElectronApp('trae-cn'); + expect(app).toBeDefined(); + expect(app!.port).toBe(39240); + expect(app!.processName).toBe('Trae CN'); + expect(app!.bundleId).toBe('cn.trae.app'); + expect(app!.executableNames).toEqual(['Electron']); + }); + it('returns undefined for non-Electron sites', () => { expect(getElectronApp('bilibili')).toBeUndefined(); expect(getElectronApp('hackernews')).toBeUndefined(); @@ -33,6 +42,7 @@ describe('electron-apps registry', () => { expect(isElectronApp('chatwise')).toBe(true); expect(isElectronApp('qoder')).toBe(true); expect(isElectronApp('trae-solo')).toBe(true); + expect(isElectronApp('trae-cn')).toBe(true); }); it('registers Qoder on its own CDP port', () => { diff --git a/src/electron-apps.ts b/src/electron-apps.ts index de9a6ba76..a29ec556b 100644 --- a/src/electron-apps.ts +++ b/src/electron-apps.ts @@ -53,6 +53,13 @@ export const builtinApps: Record = { bundleId: 'com.trae.solo.app', displayName: 'Trae SOLO', }, + 'trae-cn': { + port: 39240, + processName: 'Trae CN', + executableNames: ['Electron'], + bundleId: 'cn.trae.app', + displayName: 'Trae CN', + }, }; /** Merge builtin + user-defined apps. User entries are additive only. */ From b08c5bfd6f3af8b6e841c0a70ebef83293812cec Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 31 May 2026 03:23:19 +0800 Subject: [PATCH 2/3] fix(trae-cn): harden write action boundaries --- cli-manifest.json | 12 ++--- clis/trae-cn/approve.js | 5 ++ clis/trae-cn/ask.js | 4 +- clis/trae-cn/new.js | 18 ++------ clis/trae-cn/setup.js | 10 ++-- clis/trae-cn/trae-cn.test.js | 79 +++++++++++++++++++++++++++++--- clis/trae-cn/utils.js | 50 +++++++++++++++++--- clis/trae-cn/watch.js | 8 ++-- docs/adapters/desktop/trae-cn.md | 20 ++++---- 9 files changed, 154 insertions(+), 52 deletions(-) diff --git a/cli-manifest.json b/cli-manifest.json index 99accdb3d..f6121b9f0 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -27722,9 +27722,9 @@ { "name": "auto-approve", "type": "boolean", - "default": true, + "default": false, "required": false, - "help": "While waiting, approve visible terminal/delete prompts, including high-risk terminal confirmation layers (default: true; pass false to disable)" + "help": "While waiting, approve visible terminal/delete prompts, including high-risk terminal confirmation layers (default: false)" }, { "name": "approve-kinds", @@ -28078,8 +28078,8 @@ { "site": "trae-cn", "name": "watch", - "description": "Sample Trae CN activity over time to monitor long-running tasks", - "access": "read", + "description": "Sample Trae CN activity over time to monitor long-running tasks; optionally approve visible terminal/delete prompts", + "access": "write", "example": "OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn watch --stream true --duration 120", "domain": "localhost", "strategy": "ui", @@ -28130,9 +28130,9 @@ { "name": "auto-approve", "type": "boolean", - "default": true, + "default": false, "required": false, - "help": "Approve visible terminal/delete prompts before each sample, including high-risk terminal confirmation layers (default: true; pass false to disable)" + "help": "Approve visible terminal/delete prompts before each sample, including high-risk terminal confirmation layers (default: false)" }, { "name": "approve-kinds", diff --git a/clis/trae-cn/approve.js b/clis/trae-cn/approve.js index 455a04b96..3b67b2eb6 100644 --- a/clis/trae-cn/approve.js +++ b/clis/trae-cn/approve.js @@ -1,4 +1,5 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; +import { CommandExecutionError } from '@jackwener/opencli/errors'; import { approveTraePrompts, normalizeApprovalKinds, @@ -32,6 +33,10 @@ export const approveCommand = cli({ const maxChars = normalizeMaxChars(kwargs['max-chars'], 600); const dryRun = normalizeDryRun(kwargs['dry-run']); const rows = await approveTraePrompts(page, kinds, { click: !dryRun, limit, maxChars }); + if (!dryRun && rows.some(row => row.Action && String(row.Action).startsWith('click-failed'))) { + const failed = rows.find(row => row.Action && String(row.Action).startsWith('click-failed')); + throw new CommandExecutionError(`Trae CN approval click failed: ${failed?.Action || 'unknown error'}`); + } if (rows.length === 0) { return [{ Status: 'NoPrompt', diff --git a/clis/trae-cn/ask.js b/clis/trae-cn/ask.js index f27c40c9d..b557a86ea 100644 --- a/clis/trae-cn/ask.js +++ b/clis/trae-cn/ask.js @@ -13,7 +13,7 @@ import { } from './utils.js'; function normalizeAutoApprove(value) { - return value === undefined || value === true || String(value).toLowerCase() === 'true'; + return value === true || String(value).toLowerCase() === 'true'; } export const askCommand = cli({ @@ -29,7 +29,7 @@ export const askCommand = cli({ { name: 'text', required: true, positional: true, help: 'Prompt to send into Trae CN' }, { name: 'timeout', type: 'int', required: false, help: 'Max seconds to wait for response (default: 60)', default: 60 }, { name: 'max-chars', type: 'int', required: false, help: 'Max chars to return from the assistant response; 0 returns full text (default: 12000)', default: 12000 }, - { name: 'auto-approve', type: 'boolean', required: false, help: 'While waiting, approve visible terminal/delete prompts, including high-risk terminal confirmation layers (default: true; pass false to disable)', default: true }, + { name: 'auto-approve', type: 'boolean', required: false, help: 'While waiting, approve visible terminal/delete prompts, including high-risk terminal confirmation layers (default: false)', default: false }, { name: 'approve-kinds', type: 'string', required: false, help: 'Comma-separated approval categories for --auto-approve: terminal,delete,keep,all (default: terminal,delete; keep is not default)', default: 'terminal,delete' }, ], columns: ['Role', 'Text', 'TextChars', 'Truncated', 'TurnIndex', 'MessageId'], diff --git a/clis/trae-cn/new.js b/clis/trae-cn/new.js index 70ae160f8..9160e91bd 100644 --- a/clis/trae-cn/new.js +++ b/clis/trae-cn/new.js @@ -1,5 +1,5 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; -import { selectorError } from '@jackwener/opencli/errors'; +import { CommandExecutionError, selectorError } from '@jackwener/opencli/errors'; import { clickNewTaskScript, currentTaskStateScript, ensurePrompt, normalizeTimeout, sendTraePrompt } from './utils.js'; export const newCommand = cli({ @@ -32,18 +32,10 @@ export const newCommand = cli({ } if (!state?.composerReady || state.turns !== 0) { - return [{ - Status: 'Unclear', - Action: `Clicked new task via ${clicked.method}; fresh empty composer was not confirmed`, - Workspace: state?.workspace || '', - Model: state?.model || '', - Agent: state?.agent || '', - FreshTaskConfirmed: 'no', - TurnsBeforeSubmit: state?.turns ?? '', - Turns: state?.turns ?? '', - ComposerReady: state?.composerReady ? 'yes' : 'no', - SubmitMode: '', - }]; + throw new CommandExecutionError( + `Clicked Trae CN new task via ${clicked.method}; fresh empty composer was not confirmed`, + `Observed composerReady=${state?.composerReady ? 'yes' : 'no'}, turns=${state?.turns ?? 'unavailable'}. Verify the current window is a Trae CN chat workspace and retry.`, + ); } const turnsBeforeSubmit = state.turns; diff --git a/clis/trae-cn/setup.js b/clis/trae-cn/setup.js index f1ca0bfb1..1d44dc6a0 100644 --- a/clis/trae-cn/setup.js +++ b/clis/trae-cn/setup.js @@ -44,7 +44,7 @@ export const setupCommand = cli({ { Step: '7. Monitor progress', Command: 'opencli trae-cn watch --stream true --duration 120', - Purpose: 'Read in-app running/completed state as JSONL; terminal/delete confirmations are auto-approved by default', + Purpose: 'Read in-app running/completed state as JSONL without approving terminal/delete confirmations', }, { Step: '8. Approve blockers when needed', @@ -52,9 +52,9 @@ export const setupCommand = cli({ Purpose: 'Click visible Trae prompts for terminal command or delete confirmations', }, { - Step: '9. Disable auto-approve when needed', - Command: 'opencli trae-cn watch --stream true --duration 120 --auto-approve false', - Purpose: 'Monitor without clicking terminal/delete prompts', + Step: '9. Opt in to auto-approve when needed', + Command: 'opencli trae-cn watch --stream true --duration 120 --auto-approve true', + Purpose: 'Explicitly opt in when you want watch to approve terminal/delete prompts while monitoring', }, { Step: '10. Read result', @@ -64,7 +64,7 @@ export const setupCommand = cli({ { Step: 'Auto-run boundary', Command: 'rm, mv, chmod, dd, truncate, kill, destructive git/database commands', - Purpose: 'Trae CN may still stop these as high-risk even when command mode is 自动运行; OpenCLI auto-approves them if they appear as terminal confirmation UI', + Purpose: 'Trae CN may still stop these as high-risk even when command mode is 自动运行; OpenCLI approves them only after explicit --auto-approve true or approve', }, { Step: 'Help', diff --git a/clis/trae-cn/trae-cn.test.js b/clis/trae-cn/trae-cn.test.js index 7c48d0536..3aceab9f8 100644 --- a/clis/trae-cn/trae-cn.test.js +++ b/clis/trae-cn/trae-cn.test.js @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { JSDOM } from 'jsdom'; -import { TimeoutError } from '@jackwener/opencli/errors'; +import { CommandExecutionError, TimeoutError } from '@jackwener/opencli/errors'; import { activityCommand } from './activity.js'; import { approveCommand } from './approve.js'; import { askCommand } from './ask.js'; @@ -30,6 +30,7 @@ import { normalizeTimeout, readMessagesScript, submitPromptScript, + submittedPromptScript, } from './utils.js'; function evaluateInDom(html, script) { @@ -85,6 +86,7 @@ describe('trae-cn utils', () => { it('builds injectable scripts for composer, turn count, and model selection', () => { expect(injectPromptScript('hello')).toContain('chat-input-v2-input-box-editable'); expect(submitPromptScript()).toContain('chat-input-v2-send-button'); + expect(submittedPromptScript(2, 'hello')).toContain('section.chat-turn'); expect(countTurnsScript()).toContain('section.chat-turn'); expect(normalizeModelLabel('GPT 5.4')).toBe('gpt5.4'); expect(normalizeModelLabel('GPT-5.4')).toBe('gpt5.4'); @@ -264,7 +266,7 @@ describe('trae-cn commands', () => { expect(setupText).toContain('open -a "Trae CN" --args --remote-debugging-port=39240'); expect(setupText).toContain('export OPENCLI_CDP_TARGET="talk"'); expect(setupText).toContain('opencli trae-cn approve --approve-kinds terminal,delete -f json'); - expect(setupText).toContain('opencli trae-cn watch --stream true --duration 120 --auto-approve false'); + expect(setupText).toContain('opencli trae-cn watch --stream true --duration 120 --auto-approve true'); }); it('documents endpoint/target examples for browser commands', () => { @@ -275,9 +277,12 @@ describe('trae-cn commands', () => { expect(targetsCommand.example).toContain('OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240'); }); - it('keeps ask/watch auto-approval on by default for terminal/delete and keeps keep opt-in', () => { - expect(askCommand.args.find(arg => arg.name === 'auto-approve')).toMatchObject({ type: 'boolean', default: true }); - expect(watchCommand.args.find(arg => arg.name === 'auto-approve')).toMatchObject({ type: 'boolean', default: true }); + it('keeps approval clicks opt-in and marks any approval-capable command as write', () => { + expect(askCommand.access).toBe('write'); + expect(watchCommand.access).toBe('write'); + expect(approveCommand.access).toBe('write'); + expect(askCommand.args.find(arg => arg.name === 'auto-approve')).toMatchObject({ type: 'boolean', default: false }); + expect(watchCommand.args.find(arg => arg.name === 'auto-approve')).toMatchObject({ type: 'boolean', default: false }); expect(askCommand.args.find(arg => arg.name === 'approve-kinds')).toMatchObject({ default: 'terminal,delete' }); expect(watchCommand.args.find(arg => arg.name === 'approve-kinds')).toMatchObject({ default: 'terminal,delete' }); }); @@ -304,12 +309,74 @@ describe('trae-cn commands', () => { expect(rows[0]).toMatchObject({ Status: 'NoPrompt', Kind: 'terminal', Action: 'dry-run' }); }); + it('approve fails closed when the native approval click fails', async () => { + const page = { + evaluate: vi.fn().mockResolvedValueOnce([ + { Status: 'Detected', Kind: 'terminal', Button: '运行', Prompt: '运行终端命令', Selector: '[data-opencli-approval-id="x"]', Action: 'detected' }, + ]), + click: vi.fn().mockRejectedValueOnce(new Error('stale element')), + }; + + await expect(approveCommand.func(page, { 'approve-kinds': 'terminal', limit: 1, 'dry-run': false })) + .rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('select-model fails closed when the selected model is not verified after click', async () => { + const page = { + evaluate: vi.fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ Index: 0, Model: 'GPT 5.4' }]) + .mockResolvedValueOnce({ model: 'Claude 4', workspace: 'talk', agent: '@Trae Agent' }), + click: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + }; + + await expect(selectModelCommand.func(page, { name: 'GPT 5.4' })) + .rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('new fails closed when clicking new task does not prove a fresh empty composer', async () => { + const page = { + evaluate: vi.fn() + .mockResolvedValueOnce({ ok: true, method: 'data-testid-or-class' }) + .mockResolvedValue({ composerReady: true, turns: 2, workspace: 'talk', model: 'GPT-5.4', agent: '@Trae Agent' }), + wait: vi.fn().mockResolvedValue(undefined), + }; + + await expect(newCommand.func(page, { timeout: 1 })) + .rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('send fails closed when submit does not create a matching user turn', async () => { + const page = { + evaluate: vi.fn() + .mockResolvedValueOnce(1) + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: true, mode: 'button' }) + .mockResolvedValue(false), + wait: vi.fn().mockResolvedValue(undefined), + }; + let now = 1_000; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => { + now += 1_000; + return now; + }); + try { + await expect(sendCommand.func(page, { text: 'ping' })) + .rejects.toBeInstanceOf(CommandExecutionError); + } finally { + nowSpy.mockRestore(); + } + }); + it('ask throws a typed timeout instead of returning a system sentinel row', async () => { const page = { evaluate: vi.fn() .mockResolvedValueOnce(null) + .mockResolvedValueOnce(0) .mockResolvedValueOnce({ ok: true }) - .mockResolvedValueOnce({ ok: true, mode: 'button' }), + .mockResolvedValueOnce({ ok: true, mode: 'button' }) + .mockResolvedValueOnce(true), wait: vi.fn().mockResolvedValue(undefined), }; let now = 1_000; diff --git a/clis/trae-cn/utils.js b/clis/trae-cn/utils.js index b4733ac32..2965a1c6d 100644 --- a/clis/trae-cn/utils.js +++ b/clis/trae-cn/utils.js @@ -1,4 +1,4 @@ -import { ArgumentError, EmptyResultError, selectorError } from '@jackwener/opencli/errors'; +import { ArgumentError, CommandExecutionError, EmptyResultError, selectorError } from '@jackwener/opencli/errors'; export const TRAE_CN_COMPOSER_SELECTOR = '.chat-input-v2-input-box-editable[data-lexical-editor="true"], .chat-input-v2-input-box-editable[contenteditable="true"]'; export const TRAE_CN_SEND_BUTTON_SELECTOR = '.chat-input-v2-send-button'; @@ -489,6 +489,21 @@ export function countTurnsScript() { return `document.querySelectorAll('${TRAE_CN_TURN_SELECTOR}').length`; } +export function submittedPromptScript(beforeCount, text) { + return ` + (function(beforeCount, text) { + const normalize = (value) => String(value || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim(); + const prompt = normalize(text); + const turns = Array.from(document.querySelectorAll('${TRAE_CN_TURN_SELECTOR}')).slice(Number(beforeCount) || 0); + return turns.some((turn) => { + const role = String(turn.getAttribute('data-role') || '').toLowerCase(); + const isUser = role === 'user' || turn.classList.contains('user'); + return isUser && normalize(turn.innerText || turn.textContent).includes(prompt); + }); + })(${JSON.stringify(beforeCount)}, ${JSON.stringify(text)}) + `; +} + export function latestAssistantScript(maxChars = 0) { return ` (function() { @@ -662,20 +677,34 @@ export async function readTraeMessages(page, limit = 20, maxChars = 0) { export async function sendTraePrompt(page, text) { const prompt = ensurePrompt(text); + const beforeCount = await page.evaluate(countTurnsScript()); const injected = await page.evaluate(injectPromptScript(prompt)); if (!injected?.ok) throw selectorError('Trae CN chat input'); await page.wait(0.3); const submitted = await page.evaluate(submitPromptScript()); + let mode = submitted?.mode || 'unknown'; if (!submitted?.ok) { if (submitted?.reason === 'send_button_disabled' && typeof page.pressKey === 'function') { await page.pressKey('Enter'); await page.wait(0.8); - return { prompt, mode: 'keyboard' }; + mode = 'keyboard'; + } else { + throw selectorError('Trae CN send button'); } - throw selectorError('Trae CN send button'); + } else { + await page.wait(0.8); } - await page.wait(0.8); - return { prompt, mode: submitted.mode || 'unknown' }; + const deadline = Date.now() + 2500; + while (Date.now() < deadline) { + if (await page.evaluate(submittedPromptScript(beforeCount, prompt))) { + return { prompt, mode }; + } + await page.wait(0.2); + } + throw new CommandExecutionError( + 'Trae CN prompt submission was not verified', + 'The prompt was injected and submit was triggered, but no new user turn containing that prompt appeared.', + ); } export async function selectTraeModel(page, name) { @@ -695,9 +724,18 @@ export async function selectTraeModel(page, name) { await page.click(TRAE_CN_MODEL_ITEM_SELECTOR, { nth: match.Index }); await page.wait(0.8); const info = await page.evaluate(inspectTraeShellScript()); + const selectedLabel = info.model || ''; + const selectedKey = normalizeModelLabel(selectedLabel); + const matchKey = normalizeModelLabel(match.Model); + if (!selectedKey || (selectedKey !== matchKey && selectedKey !== wantedKey)) { + throw new CommandExecutionError( + `Trae CN model switch did not verify the requested model: requested "${wanted}", current "${selectedLabel || 'unknown'}"`, + 'Open the Trae CN model menu and verify the requested model is selectable, then retry.', + ); + } return { requested: wanted, - selected: info.model || match.Model, + selected: selectedLabel, workspace: info.workspace || '', agent: info.agent || '', }; diff --git a/clis/trae-cn/watch.js b/clis/trae-cn/watch.js index 46b79a6d3..be9250b71 100644 --- a/clis/trae-cn/watch.js +++ b/clis/trae-cn/watch.js @@ -36,14 +36,14 @@ function normalizeStopOnComplete(value) { } function normalizeAutoApprove(value) { - return value === undefined || value === true || String(value).toLowerCase() === 'true'; + return value === true || String(value).toLowerCase() === 'true'; } export const watchCommand = cli({ site: 'trae-cn', name: 'watch', - access: 'read', - description: 'Sample Trae CN activity over time to monitor long-running tasks', + access: 'write', + description: 'Sample Trae CN activity over time to monitor long-running tasks; optionally approve visible terminal/delete prompts', example: 'OPENCLI_CDP_ENDPOINT=http://127.0.0.1:39240 OPENCLI_CDP_TARGET=talk opencli trae-cn watch --stream true --duration 120', domain: 'localhost', strategy: Strategy.UI, @@ -55,7 +55,7 @@ export const watchCommand = cli({ { name: 'timeout', type: 'int', required: false, help: 'Max seconds for the overall watch command (default: 86400)', default: 86400 }, { name: 'stream', type: 'boolean', required: false, help: 'Write each sample immediately as JSONL and skip result rendering (default: false)', default: false }, { name: 'stop-on-complete', type: 'boolean', required: false, help: 'Stop watching after the first completed sample (default: true)', default: true }, - { name: 'auto-approve', type: 'boolean', required: false, help: 'Approve visible terminal/delete prompts before each sample, including high-risk terminal confirmation layers (default: true; pass false to disable)', default: true }, + { name: 'auto-approve', type: 'boolean', required: false, help: 'Approve visible terminal/delete prompts before each sample, including high-risk terminal confirmation layers (default: false)', default: false }, { name: 'approve-kinds', type: 'string', required: false, help: 'Comma-separated approval categories for --auto-approve: terminal,delete,keep,all (default: terminal,delete; keep is not default)', default: 'terminal,delete' }, ], columns: ['Sample', 'ElapsedSec', 'Changed', 'Status', 'Progress', 'ActiveStep', 'ApprovalPending', 'ApprovalKind', 'ApprovalButton', 'AutoApproved', 'LatestRole', 'TurnIndex', 'MessageId', 'TextChars', 'LatestText', 'UpdatedAt'], diff --git a/docs/adapters/desktop/trae-cn.md b/docs/adapters/desktop/trae-cn.md index f84c3de0b..0f9e629ce 100644 --- a/docs/adapters/desktop/trae-cn.md +++ b/docs/adapters/desktop/trae-cn.md @@ -55,25 +55,25 @@ export OPENCLI_CDP_TARGET="talk" - `opencli trae-cn activity` — read the current task state once. - `opencli trae-cn watch --duration 60 --interval 2` — sample task state until completion or timeout. - `opencli trae-cn watch --stream true` — emit one JSON object per sample as JSONL for agent pipelines. -- `opencli trae-cn watch --auto-approve false` — keep watching without clicking terminal/delete approval prompts. +- `opencli trae-cn watch --auto-approve true` — explicitly approve matching terminal/delete prompts while watching. `watch` defaults to `--stop-on-complete true`, so `--duration` is a maximum observation window. Use `--stop-on-complete false` when you need a fixed-length observation trace. ### Approval Prompts -Trae CN may pause a long task for UI confirmations, especially before running terminal commands or deleting files. `ask` and `watch` approve visible terminal/delete prompts by default, because this adapter is meant for unattended local agent runs. The default is category-based: OpenCLI approves Trae's visible `terminal` confirmation UI and visible `delete` confirmation UI. It is not a semantic command allowlist limited to `rm` or `mv`. +Trae CN may pause a long task for UI confirmations, especially before running terminal commands or deleting files. `ask` and `watch` do not click approval prompts unless you pass `--auto-approve true`; `approve` clicks the current matching prompt directly. The approval detector is category-based: OpenCLI approves Trae's visible `terminal` confirmation UI and visible `delete` confirmation UI. It is not a semantic command allowlist limited to `rm` or `mv`. -Default approval behavior: +Opt-in approval behavior: -- `terminal` is enabled by default. It clicks ordinary terminal run confirmations, high-risk command-card `运行`, and the follow-up high-risk modal `仍要运行` when Trae exposes them with matching button/context text. -- `delete` is enabled by default. It clicks file deletion cards and the follow-up irreversible delete modal `确认` when Trae exposes them with matching button/context text. +- `terminal` is enabled when you opt in. It clicks ordinary terminal run confirmations, high-risk command-card `运行`, and the follow-up high-risk modal `仍要运行` when Trae exposes them with matching button/context text. +- `delete` is enabled when you opt in. It clicks file deletion cards and the follow-up irreversible delete modal `确认` when Trae exposes them with matching button/context text. - `keep` is not enabled by default. Use `--approve-kinds keep` only when the intended action is to retain files rather than delete them. - Unknown prompts are not clicked. Use `opencli trae-cn activity`, `opencli trae-cn targets`, or `opencli trae-cn approve --dry-run true` to inspect them. -Disable auto-approval when you need a manual checkpoint: +Monitor without approval clicks: ```bash -opencli trae-cn watch --stream true --duration 300 --auto-approve false +opencli trae-cn watch --stream true --duration 300 ``` You can also approve the current visible prompt directly: @@ -84,17 +84,17 @@ opencli trae-cn approve --approve-kinds terminal,delete -f json Use `--dry-run true` to inspect matching prompts without clicking. Use `--approve-kinds keep` only when the intended action is to keep/retain a file instead of approving deletion. -For long-running agent tasks, the normal monitoring command already uses terminal/delete auto-approval: +For long-running agent tasks where you explicitly want OpenCLI to approve terminal/delete prompts while watching: ```bash -opencli trae-cn watch --stream true --duration 300 +opencli trae-cn watch --stream true --duration 300 --auto-approve true ``` The auto-approval detector only clicks visible prompts whose button and surrounding prompt text match the requested approval kinds. In Trae CN auto-run mode, high-risk shell commands such as `mv` and `rm` can still show confirmation UI. The adapter handles both layers observed in Trae CN: the command card `运行` action and the follow-up `运行风险命令` modal with `仍要运行`. -Observed high-risk patterns include `rm`, `delete`, `unlink`, `shred`, `dd`, `truncate`, `kill`, `chmod`, `mv`, `copy`, `move`, PowerShell write commands, `mkfs`, destructive Git operations, and destructive database commands. If Trae renders any of these as terminal confirmation UI with known wording/buttons, default `watch` and `ask` approve them through the `terminal` approval kind. Treat these prompts as current-product UI behavior rather than a stable public API. +Observed high-risk patterns include `rm`, `delete`, `unlink`, `shred`, `dd`, `truncate`, `kill`, `chmod`, `mv`, `copy`, `move`, PowerShell write commands, `mkfs`, destructive Git operations, and destructive database commands. If Trae renders any of these as terminal confirmation UI with known wording/buttons, opt-in `watch --auto-approve true` and `ask --auto-approve true` approve them through the `terminal` approval kind. Treat these prompts as current-product UI behavior rather than a stable public API. When multiple Trae windows are open, use: From 2232de635f6946b29737cd877b53e64398ee4abe Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 31 May 2026 03:38:27 +0800 Subject: [PATCH 3/3] fix(trae-cn): reject ambiguous model selections --- clis/trae-cn/trae-cn.test.js | 20 +++++++++++++++++++- clis/trae-cn/utils.js | 12 ++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/clis/trae-cn/trae-cn.test.js b/clis/trae-cn/trae-cn.test.js index 3aceab9f8..756f717e2 100644 --- a/clis/trae-cn/trae-cn.test.js +++ b/clis/trae-cn/trae-cn.test.js @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { JSDOM } from 'jsdom'; -import { CommandExecutionError, TimeoutError } from '@jackwener/opencli/errors'; +import { ArgumentError, CommandExecutionError, TimeoutError } from '@jackwener/opencli/errors'; import { activityCommand } from './activity.js'; import { approveCommand } from './approve.js'; import { askCommand } from './ask.js'; @@ -335,6 +335,24 @@ describe('trae-cn commands', () => { .rejects.toBeInstanceOf(CommandExecutionError); }); + it('select-model rejects ambiguous partial model names before changing selection', async () => { + const page = { + evaluate: vi.fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { Index: 0, Model: 'GPT 5.4' }, + { Index: 1, Model: 'GPT 5.5' }, + ]), + click: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + }; + + await expect(selectModelCommand.func(page, { name: 'GPT 5' })) + .rejects.toBeInstanceOf(ArgumentError); + expect(page.click).toHaveBeenCalledTimes(1); + expect(page.click).toHaveBeenCalledWith(expect.stringContaining('icd-model-select-trigger')); + }); + it('new fails closed when clicking new task does not prove a fresh empty composer', async () => { const page = { evaluate: vi.fn() diff --git a/clis/trae-cn/utils.js b/clis/trae-cn/utils.js index 2965a1c6d..864e4341a 100644 --- a/clis/trae-cn/utils.js +++ b/clis/trae-cn/utils.js @@ -716,8 +716,16 @@ export async function selectTraeModel(page, name) { await page.wait(0.8); models = await page.evaluate(listOpenModelItemsScript()); } - const match = models.find((item) => normalizeModelLabel(item.Model) === wantedKey) - || models.find((item) => normalizeModelLabel(item.Model).includes(wantedKey)); + const exactMatch = models.find((item) => normalizeModelLabel(item.Model) === wantedKey); + const containsMatches = exactMatch + ? [] + : models.filter((item) => normalizeModelLabel(item.Model).includes(wantedKey)); + if (!exactMatch && containsMatches.length > 1) { + throw new ArgumentError( + `Model "${wanted}" is ambiguous in Trae CN model menu: ${containsMatches.map(item => item.Model).join(', ')}`, + ); + } + const match = exactMatch || containsMatches[0]; if (!match) { throw new ArgumentError(`Model "${wanted}" not found in Trae CN model menu`); }