From e8f42840a2a8adfef7400c6ef0ec65a7f2e7822f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 3 Apr 2026 18:33:31 +0800 Subject: [PATCH] feat: enable Computer Use with Python bridge replacing private native modules Bypass all three gating layers (compile-time feature flag, subscription check, GrowthBook remote config) and replace Anthropic's private native modules (@ant/computer-use-swift, @ant/computer-use-input) with a Python bridge using pyautogui + mss + pyobjc. Works on any macOS with any Anthropic-protocol compatible model. --- .gitignore | 4 + README.en.md | 21 + README.md | 21 + bun.lock | 816 ++++ docs/computer-use.en.md | 241 ++ docs/computer-use.md | 312 ++ runtime/mac_helper.py | 659 +++ runtime/requirements.txt | 6 + .../ComputerUseApproval.tsx | 6 +- src/entrypoints/cli.tsx | 2 +- src/main.tsx | 4 +- src/query.ts | 4 +- src/query/stopHooks.ts | 2 +- src/services/analytics/metadata.ts | 14 +- src/services/mcp/client.ts | 25 +- src/services/mcp/config.ts | 24 +- src/utils/computerUse/drainRunLoop.ts | 82 +- src/utils/computerUse/escHotkey.ts | 56 +- src/utils/computerUse/executor.ts | 642 +-- src/utils/computerUse/gates.ts | 28 +- src/utils/computerUse/hostAdapter.ts | 28 +- src/utils/computerUse/inputLoader.ts | 31 +- src/utils/computerUse/mcpServer.ts | 2 +- src/utils/computerUse/pythonBridge.ts | 110 + src/utils/computerUse/setup.ts | 2 +- src/utils/computerUse/swiftLoader.ts | 24 +- src/utils/computerUse/wrapper.tsx | 2 +- src/vendor/computer-use-mcp/deniedApps.ts | 553 +++ src/vendor/computer-use-mcp/executor.ts | 100 + src/vendor/computer-use-mcp/imageResize.ts | 108 + src/vendor/computer-use-mcp/index.ts | 69 + src/vendor/computer-use-mcp/keyBlocklist.ts | 153 + src/vendor/computer-use-mcp/mcpServer.ts | 313 ++ src/vendor/computer-use-mcp/pixelCompare.ts | 171 + src/vendor/computer-use-mcp/sentinelApps.ts | 43 + src/vendor/computer-use-mcp/subGates.ts | 19 + src/vendor/computer-use-mcp/toolCalls.ts | 3652 +++++++++++++++++ src/vendor/computer-use-mcp/tools.ts | 706 ++++ src/vendor/computer-use-mcp/types.ts | 621 +++ 39 files changed, 8869 insertions(+), 807 deletions(-) create mode 100644 bun.lock create mode 100644 docs/computer-use.en.md create mode 100644 docs/computer-use.md create mode 100755 runtime/mac_helper.py create mode 100644 runtime/requirements.txt create mode 100644 src/utils/computerUse/pythonBridge.ts create mode 100644 src/vendor/computer-use-mcp/deniedApps.ts create mode 100644 src/vendor/computer-use-mcp/executor.ts create mode 100644 src/vendor/computer-use-mcp/imageResize.ts create mode 100644 src/vendor/computer-use-mcp/index.ts create mode 100644 src/vendor/computer-use-mcp/keyBlocklist.ts create mode 100644 src/vendor/computer-use-mcp/mcpServer.ts create mode 100644 src/vendor/computer-use-mcp/pixelCompare.ts create mode 100644 src/vendor/computer-use-mcp/sentinelApps.ts create mode 100644 src/vendor/computer-use-mcp/subGates.ts create mode 100644 src/vendor/computer-use-mcp/toolCalls.ts create mode 100644 src/vendor/computer-use-mcp/tools.ts create mode 100644 src/vendor/computer-use-mcp/types.ts diff --git a/.gitignore b/.gitignore index 2dc4818c..b9ae2c37 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ .env.* !.env.example node_modules + +# Computer Use runtime (auto-generated) +.runtime/ +extracted-natives/ diff --git a/README.en.md b/README.en.md index afb19c87..a37390f3 100644 --- a/README.en.md +++ b/README.en.md @@ -17,6 +17,7 @@ A **locally runnable version** repaired from the leaked Claude Code source, with - [Quick Start](#quick-start) - [Environment Variables](#environment-variables) - [Fallback Mode](#fallback-mode) +- [Computer Use Desktop Control](#computer-use-desktop-control) - [FAQ](#faq) - [Fixes Compared with the Original Leaked Source](#fixes-compared-with-the-original-leaked-source) - [Project Structure](#project-structure) @@ -30,8 +31,11 @@ A **locally runnable version** repaired from the leaked Claude Code source, with - `--print` headless mode for scripts and CI - MCP server, plugin, and Skills support - Custom API endpoint and model support ([Third-Party Models Guide](docs/third-party-models.en.md)) +- **Computer Use desktop control** (screenshots, mouse, keyboard, app management) — [Guide](docs/computer-use.en.md) - Fallback Recovery CLI mode +> **Computer Use Note**: This project includes a **modified version of Computer Use**. The official implementation relies on Anthropic's private native modules. We replaced the entire underlying operation layer with a Python bridge (`pyautogui` + `mss` + `pyobjc`), enabling anyone to use Computer Use on macOS. See the [Computer Use Guide](docs/computer-use.en.md) for details. + --- ## Architecture Overview @@ -214,6 +218,23 @@ CLAUDE_CODE_FORCE_RECOVERY_CLI=1 ./bin/claude-haha --- +## Computer Use Desktop Control + +This project enables and modifies Claude Code's Computer Use feature (internal codename "Chicago"), allowing AI models to directly control your macOS desktop — screenshots, mouse clicks, keyboard input, app management. + +**Underlying modification**: The official implementation depends on Anthropic's private native modules (`@ant/computer-use-swift`, `@ant/computer-use-input`). This project replaces them entirely with a Python bridge using `pyautogui` (mouse/keyboard), `mss` (screenshots), and `pyobjc` (macOS APIs) — no closed-source binaries required. + +```bash +# Ensure Python 3 and macOS Accessibility/Screen Recording permissions, then: +./bin/claude-haha +> Take a screenshot +> Open Safari and search for something +``` + +For supported platforms, technical architecture, and approaches we tried, see: **[Computer Use Guide](docs/computer-use.en.md)** + +--- + ## Fixes Compared with the Original Leaked Source The leaked source could not run directly. This repository mainly fixes the following issues: diff --git a/README.md b/README.md index c715228d..b4998cd8 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ - [快速开始](#快速开始) - [环境变量说明](#环境变量说明) - [降级模式](#降级模式) +- [Computer Use 桌面控制](#computer-use-桌面控制) - [常见问题](#常见问题) - [相对于原始泄露源码的修复](#相对于原始泄露源码的修复) - [项目结构](#项目结构) @@ -30,8 +31,11 @@ - `--print` 无头模式(脚本/CI 场景) - 支持 MCP 服务器、插件、Skills - 支持自定义 API 端点和模型([第三方模型使用指南](docs/third-party-models.md)) +- **Computer Use 桌面控制**(截屏、鼠标、键盘、应用管理)— [使用指南](docs/computer-use.md) - 降级 Recovery CLI 模式 +> **Computer Use 说明**:本项目包含**魔改版的 Computer Use** 功能。官方实现依赖 Anthropic 私有原生模块,我们替换了整个底层操作层,使用 Python bridge(`pyautogui` + `mss` + `pyobjc`)实现,使得任何人都可以在 macOS 上使用。详见 [Computer Use 功能指南](docs/computer-use.md)。 + --- ## 架构概览 @@ -214,6 +218,23 @@ CLAUDE_CODE_FORCE_RECOVERY_CLI=1 ./bin/claude-haha --- +## Computer Use 桌面控制 + +本项目启用并改造了 Claude Code 的 Computer Use 功能(内部代号 "Chicago"),让 AI 模型可以直接控制你的 macOS 桌面——截屏、鼠标点击、键盘输入、应用管理。 + +**底层改造**:官方实现依赖 Anthropic 私有原生模块(`@ant/computer-use-swift`、`@ant/computer-use-input`),本项目用 Python bridge 完全替代,使用 `pyautogui`(鼠标键盘)、`mss`(截图)、`pyobjc`(macOS API),无需任何闭源二进制。 + +```bash +# 确保有 Python 3 和 macOS 辅助功能/屏幕录制权限,然后直接使用: +./bin/claude-haha +> 帮我截个屏 +> 打开网易云音乐搜索一首歌 +``` + +详细说明、支持的设备列表、技术架构和尝试过的方案请参考:**[Computer Use 功能指南](docs/computer-use.md)** + +--- + ## 相对于原始泄露源码的修复 泄露的源码无法直接运行,主要修复了以下问题: diff --git a/bun.lock b/bun.lock new file mode 100644 index 00000000..0d26313e --- /dev/null +++ b/bun.lock @@ -0,0 +1,816 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "claude-code-local", + "dependencies": { + "@anthropic-ai/sandbox-runtime": "^0.0.44", + "@anthropic-ai/sdk": "^0.80.0", + "@aws-sdk/client-bedrock-runtime": "^3.1020.0", + "@commander-js/extra-typings": "^14.0.0", + "@growthbook/growthbook": "^1.6.5", + "@modelcontextprotocol/sdk": "^1.29.0", + "@opentelemetry/api-logs": "^0.214.0", + "@opentelemetry/core": "^2.6.1", + "@opentelemetry/resources": "^2.6.1", + "@opentelemetry/sdk-logs": "^0.214.0", + "@opentelemetry/sdk-metrics": "^2.6.1", + "@opentelemetry/sdk-trace-base": "^2.6.1", + "@opentelemetry/semantic-conventions": "^1.40.0", + "ajv": "^8.18.0", + "asciichart": "^1.5.25", + "auto-bind": "^5.0.1", + "axios": "^1.14.0", + "bidi-js": "^1.0.3", + "chalk": "^5.6.2", + "chokidar": "^5.0.0", + "cli-boxes": "^4.0.1", + "code-excerpt": "^4.0.0", + "diff": "^8.0.4", + "emoji-regex": "^10.6.0", + "env-paths": "^4.0.0", + "execa": "^9.6.1", + "figures": "^6.1.0", + "fuse.js": "^7.1.0", + "get-east-asian-width": "^1.5.0", + "google-auth-library": "^10.6.2", + "highlight.js": "^11.11.1", + "https-proxy-agent": "^8.0.0", + "ignore": "^7.0.5", + "indent-string": "^5.0.0", + "ink": "^6.8.0", + "jsonc-parser": "^3.3.1", + "lodash-es": "^4.17.23", + "lru-cache": "^11.2.7", + "marked": "^17.0.5", + "p-map": "^7.0.4", + "picomatch": "^4.0.4", + "proper-lockfile": "^4.1.2", + "qrcode": "^1.5.4", + "react": "^19.2.4", + "react-reconciler": "^0.33.0", + "semver": "^7.7.4", + "shell-quote": "^1.8.3", + "signal-exit": "^4.1.0", + "stack-utils": "^2.0.6", + "strip-ansi": "^7.2.0", + "supports-hyperlinks": "^4.4.0", + "tree-kill": "^1.2.2", + "type-fest": "^5.5.0", + "undici": "^7.24.6", + "usehooks-ts": "^3.1.1", + "vscode-jsonrpc": "^8.2.1", + "vscode-languageserver-types": "^3.17.5", + "wrap-ansi": "^10.0.0", + "ws": "^8.20.0", + "xss": "^1.0.15", + "yaml": "^2.8.3", + "zod": "^4.3.6", + }, + }, + "packages/computer-use-input": { + "name": "@ant/computer-use-input", + "version": "0.1.0", + }, + "packages/computer-use-swift": { + "name": "@ant/computer-use-swift", + "version": "0.1.0", + }, + }, + "packages": { + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "https://registry.npmmirror.com/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.5.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], + + "@ant/computer-use-input": ["@ant/computer-use-input@workspace:packages/computer-use-input"], + + "@ant/computer-use-swift": ["@ant/computer-use-swift@workspace:packages/computer-use-swift"], + + "@anthropic-ai/sandbox-runtime": ["@anthropic-ai/sandbox-runtime@0.0.44", "https://registry.npmmirror.com/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.44.tgz", { "dependencies": { "@pondwader/socks5-server": "^1.0.10", "@types/lodash-es": "^4.17.12", "commander": "^12.1.0", "lodash-es": "^4.17.23", "shell-quote": "^1.8.3", "zod": "^3.24.1" }, "bin": { "srt": "dist/cli.js" } }, "sha512-mmyjq0mzsHnQZyiU+FGYyaiJcPckuQpP78VB8iqFi2IOu8rcb9i5SmaOKyJENJNfY8l/1grzLMQgWq4Apvmozw=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.80.0", "https://registry.npmmirror.com/@anthropic-ai/sdk/-/sdk-0.80.0.tgz", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-WeXLn7zNVk3yjeshn+xZHvld6AoFUOR3Sep6pSoHho5YbSi6HwcirqgPA5ccFuW8QTVJAAU7N8uQQC6Wa9TG+g=="], + + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "https://registry.npmmirror.com/@aws-crypto/crc32/-/crc32-5.2.0.tgz", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], + + "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "https://registry.npmmirror.com/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], + + "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "https://registry.npmmirror.com/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], + + "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "https://registry.npmmirror.com/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], + + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "https://registry.npmmirror.com/@aws-crypto/util/-/util-5.2.0.tgz", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + + "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1020.0", "https://registry.npmmirror.com/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1020.0.tgz", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.26", "@aws-sdk/credential-provider-node": "^3.972.28", "@aws-sdk/eventstream-handler-node": "^3.972.12", "@aws-sdk/middleware-eventstream": "^3.972.8", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.9", "@aws-sdk/middleware-user-agent": "^3.972.27", "@aws-sdk/middleware-websocket": "^3.972.14", "@aws-sdk/region-config-resolver": "^3.972.10", "@aws-sdk/token-providers": "3.1020.0", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.13", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.13", "@smithy/eventstream-serde-browser": "^4.2.12", "@smithy/eventstream-serde-config-resolver": "^4.3.12", "@smithy/eventstream-serde-node": "^4.2.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.28", "@smithy/middleware-retry": "^4.4.45", "@smithy/middleware-serde": "^4.2.16", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.1", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.44", "@smithy/util-defaults-mode-node": "^4.2.48", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-stream": "^4.5.21", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-nqDCbaB05gRc3FuIEN74Mo04+k8RNI0YT2YBAU/9nioqgDyoqzMx8Ia2QWaw9UhUyIHMBjcFEfKIPfCZx7caCw=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.973.26", "https://registry.npmmirror.com/@aws-sdk/core/-/core-3.973.26.tgz", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/xml-builder": "^3.972.16", "@smithy/core": "^3.23.13", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-A/E6n2W42ruU+sfWk+mMUOyVXbsSgGrY3MJ9/0Az5qUdG67y8I6HYzzoAa+e/lzxxl1uCYmEL6BTMi9ZiZnplQ=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.24", "https://registry.npmmirror.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.24.tgz", { "dependencies": { "@aws-sdk/core": "^3.973.26", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-FWg8uFmT6vQM7VuzELzwVo5bzExGaKHdubn0StjgrcU5FvuLExUe+k06kn/40uKv59rYzhez8eFNM4yYE/Yb/w=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.26", "https://registry.npmmirror.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.26.tgz", { "dependencies": { "@aws-sdk/core": "^3.973.26", "@aws-sdk/types": "^3.973.6", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.1", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.21", "tslib": "^2.6.2" } }, "sha512-CY4ppZ+qHYqcXqBVi//sdHST1QK3KzOEiLtpLsc9W2k2vfZPKExGaQIsOwcyvjpjUEolotitmd3mUNY56IwDEA=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.27", "https://registry.npmmirror.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.27.tgz", { "dependencies": { "@aws-sdk/core": "^3.973.26", "@aws-sdk/credential-provider-env": "^3.972.24", "@aws-sdk/credential-provider-http": "^3.972.26", "@aws-sdk/credential-provider-login": "^3.972.27", "@aws-sdk/credential-provider-process": "^3.972.24", "@aws-sdk/credential-provider-sso": "^3.972.27", "@aws-sdk/credential-provider-web-identity": "^3.972.27", "@aws-sdk/nested-clients": "^3.996.17", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Um26EsNSUfVUX0wUXnUA1W3wzKhVy6nviEElsh5lLZUYj9bk6DXOPnpte0gt+WHubcVfVsRk40bbm4KaroTEag=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.27", "https://registry.npmmirror.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.27.tgz", { "dependencies": { "@aws-sdk/core": "^3.973.26", "@aws-sdk/nested-clients": "^3.996.17", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-t3ehEtHomGZwg5Gixw4fYbYtG9JBnjfAjSDabxhPEu/KLLUp0BB37/APX7MSKXQhX6ZH7pseuACFJ19NrAkNdg=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.28", "https://registry.npmmirror.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.28.tgz", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.24", "@aws-sdk/credential-provider-http": "^3.972.26", "@aws-sdk/credential-provider-ini": "^3.972.27", "@aws-sdk/credential-provider-process": "^3.972.24", "@aws-sdk/credential-provider-sso": "^3.972.27", "@aws-sdk/credential-provider-web-identity": "^3.972.27", "@aws-sdk/types": "^3.973.6", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-rren+P6k5rShG5PX61iVi40kKdueyuMLBRTctQbyR5LooO9Ygr5L6R7ilG7RF1957NSH3KC3TU206fZuKwjSpQ=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.24", "https://registry.npmmirror.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.24.tgz", { "dependencies": { "@aws-sdk/core": "^3.973.26", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Q2k/XLrFXhEztPHqj4SLCNID3hEPdlhh1CDLBpNnM+1L8fq7P+yON9/9M1IGN/dA5W45v44ylERfXtDAlmMNmw=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.27", "https://registry.npmmirror.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.27.tgz", { "dependencies": { "@aws-sdk/core": "^3.973.26", "@aws-sdk/nested-clients": "^3.996.17", "@aws-sdk/token-providers": "3.1020.0", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-CWXeGjlbBuHcm9appZUgXKP2zHDyTti0/+gXpSFJ2J3CnSwf1KWjicjN0qG2ozkMH6blrrzMrimeIOEYNl238Q=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.27", "https://registry.npmmirror.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.27.tgz", { "dependencies": { "@aws-sdk/core": "^3.973.26", "@aws-sdk/nested-clients": "^3.996.17", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-CUY4hQIFswdQNEsRGEzGBUKGMK5KpqmNDdu2ROMgI+45PLFS8H0y3Tm7kvM16uvvw3n1pVxk85tnRVUTgtaa1w=="], + + "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.12", "https://registry.npmmirror.com/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.12.tgz", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-ruyc/MNR6e+cUrGCth7fLQ12RXBZDy/bV06tgqB9Z5n/0SN/C0m6bsQEV8FF9zPI6VSAOaRd0rNgmpYVnGawrQ=="], + + "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.8", "https://registry.npmmirror.com/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.8.tgz", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-r+oP+tbCxgqXVC3pu3MUVePgSY0ILMjA+aEwOosS77m3/DRbtvHrHwqvMcw+cjANMeGzJ+i0ar+n77KXpRA8RQ=="], + + "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.8", "https://registry.npmmirror.com/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.8.tgz", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ=="], + + "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.8", "https://registry.npmmirror.com/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA=="], + + "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.9", "https://registry.npmmirror.com/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.9.tgz", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-/Wt5+CT8dpTFQxEJ9iGy/UGrXr7p2wlIOEHvIr/YcHYByzoLjrqkYqXdJjd9UIgWjv7eqV2HnFJen93UTuwfTQ=="], + + "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.27", "https://registry.npmmirror.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.27.tgz", { "dependencies": { "@aws-sdk/core": "^3.973.26", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@smithy/core": "^3.23.13", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-retry": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-TIRLO5UR2+FVUGmhYoAwVkKhcVzywEDX/5LzR9tjy1h8FQAXOtFg2IqgmwvxU7y933rkTn9rl6AdgcAUgQ1/Kg=="], + + "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.14", "https://registry.npmmirror.com/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.14.tgz", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-format-url": "^3.972.8", "@smithy/eventstream-codec": "^4.2.12", "@smithy/eventstream-serde-browser": "^4.2.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/protocol-http": "^5.3.12", "@smithy/signature-v4": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-qnfDlIHjm6DrTYNvWOUbnZdVKgtoKbO/Qzj+C0Wp5Y7VUrsvBRQtGKxD+hc+mRTS4N0kBJ6iZ3+zxm4N1OSyjg=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.17", "https://registry.npmmirror.com/@aws-sdk/nested-clients/-/nested-clients-3.996.17.tgz", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.26", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.9", "@aws-sdk/middleware-user-agent": "^3.972.27", "@aws-sdk/region-config-resolver": "^3.972.10", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.13", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.13", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.28", "@smithy/middleware-retry": "^4.4.45", "@smithy/middleware-serde": "^4.2.16", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.1", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.44", "@smithy/util-defaults-mode-node": "^4.2.48", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-7B0HIX0tEFmOSJuWzdHZj1WhMXSryM+h66h96ZkqSncoY7J6wq61KOu4Kr57b/YnJP3J/EeQYVFulgR281h+7A=="], + + "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.10", "https://registry.npmmirror.com/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.10.tgz", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/config-resolver": "^4.4.13", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-1dq9ToC6e070QvnVhhbAs3bb5r6cQ10gTVc6cyRV5uvQe7P138TV2uG2i6+Yok4bAkVAcx5AqkTEBUvWEtBlsQ=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1020.0", "https://registry.npmmirror.com/@aws-sdk/token-providers/-/token-providers-3.1020.0.tgz", { "dependencies": { "@aws-sdk/core": "^3.973.26", "@aws-sdk/nested-clients": "^3.996.17", "@aws-sdk/types": "^3.973.6", "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-T61KA/VKl0zVUubdxigr1ut7SEpwE1/4CIKb14JDLyTAOne2yWKtQE1dDCSHl0UqrZNwW/bTt+EBHfQbslZJdw=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.973.6", "https://registry.npmmirror.com/@aws-sdk/types/-/types-3.973.6.tgz", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw=="], + + "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.5", "https://registry.npmmirror.com/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" } }, "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw=="], + + "@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.972.8", "https://registry.npmmirror.com/@aws-sdk/util-format-url/-/util-format-url-3.972.8.tgz", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-J6DS9oocrgxM8xlUTTmQOuwRF6rnAGEujAN9SAzllcrQmwn5iJ58ogxy3SEhD0Q7JZvlA5jvIXBkpQRqEqlE9A=="], + + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "https://registry.npmmirror.com/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="], + + "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.8", "https://registry.npmmirror.com/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.8.tgz", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA=="], + + "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.13", "https://registry.npmmirror.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.13.tgz", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.27", "@aws-sdk/types": "^3.973.6", "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-s1dCJ0J9WU9UPkT3FFqhKTSquYTkqWXGRaapHFyWwwJH86ZussewhNST5R5TwXVL1VSHq4aJVl9fWK+svaRVCQ=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.16", "https://registry.npmmirror.com/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz", { "dependencies": { "@smithy/types": "^4.13.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" } }, "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "https://registry.npmmirror.com/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], + + "@babel/runtime": ["@babel/runtime@7.29.2", "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + + "@commander-js/extra-typings": ["@commander-js/extra-typings@14.0.0", "https://registry.npmmirror.com/@commander-js/extra-typings/-/extra-typings-14.0.0.tgz", { "peerDependencies": { "commander": "~14.0.0" } }, "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg=="], + + "@growthbook/growthbook": ["@growthbook/growthbook@1.6.5", "https://registry.npmmirror.com/@growthbook/growthbook/-/growthbook-1.6.5.tgz", { "dependencies": { "dom-mutator": "^0.6.0" } }, "sha512-mUaMsgeUTpRIUOTn33EUXHRK6j7pxBjwqH4WpQyq+pukjd1AIzWlEa6w7i6bInJUcweGgP2beXZmaP6b6UPn7A=="], + + "@hono/node-server": ["@hono/node-server@1.19.12", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.12.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "https://registry.npmmirror.com/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "https://registry.npmmirror.com/@opentelemetry/api/-/api-1.9.1.tgz", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.214.0", "https://registry.npmmirror.com/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.6.1", "https://registry.npmmirror.com/@opentelemetry/core/-/core-2.6.1.tgz", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "https://registry.npmmirror.com/@opentelemetry/resources/-/resources-2.6.1.tgz", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], + + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.214.0", "https://registry.npmmirror.com/@opentelemetry/sdk-logs/-/sdk-logs-0.214.0.tgz", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA=="], + + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "https://registry.npmmirror.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.1.tgz", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "https://registry.npmmirror.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "https://registry.npmmirror.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], + + "@pondwader/socks5-server": ["@pondwader/socks5-server@1.0.10", "https://registry.npmmirror.com/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz", {}, "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg=="], + + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "https://registry.npmmirror.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "https://registry.npmmirror.com/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + + "@smithy/config-resolver": ["@smithy/config-resolver@4.4.13", "https://registry.npmmirror.com/@smithy/config-resolver/-/config-resolver-4.4.13.tgz", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg=="], + + "@smithy/core": ["@smithy/core@3.23.13", "https://registry.npmmirror.com/@smithy/core/-/core-3.23.13.tgz", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.21", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-J+2TT9D6oGsUVXVEMvz8h2EmdVnkBiy2auCie4aSJMvKlzUtO5hqjEzXhoCUkIMo7gAYjbQcN0g/MMSXEhDs1Q=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "https://registry.npmmirror.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.12.tgz", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="], + + "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "https://registry.npmmirror.com/@smithy/eventstream-codec/-/eventstream-codec-4.2.12.tgz", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="], + + "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.12", "https://registry.npmmirror.com/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.12.tgz", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A=="], + + "@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.12", "https://registry.npmmirror.com/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.12.tgz", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q=="], + + "@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.12", "https://registry.npmmirror.com/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.12.tgz", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA=="], + + "@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "https://registry.npmmirror.com/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.12.tgz", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.15", "https://registry.npmmirror.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A=="], + + "@smithy/hash-node": ["@smithy/hash-node@4.2.12", "https://registry.npmmirror.com/@smithy/hash-node/-/hash-node-4.2.12.tgz", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w=="], + + "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.12", "https://registry.npmmirror.com/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g=="], + + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "https://registry.npmmirror.com/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="], + + "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.12", "https://registry.npmmirror.com/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA=="], + + "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.28", "https://registry.npmmirror.com/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.28.tgz", { "dependencies": { "@smithy/core": "^3.23.13", "@smithy/middleware-serde": "^4.2.16", "@smithy/node-config-provider": "^4.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-p1gfYpi91CHcs5cBq982UlGlDrxoYUX6XdHSo91cQ2KFuz6QloHosO7Jc60pJiVmkWrKOV8kFYlGFFbQ2WUKKQ=="], + + "@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.46", "https://registry.npmmirror.com/@smithy/middleware-retry/-/middleware-retry-4.4.46.tgz", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/service-error-classification": "^4.2.12", "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.13", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-SpvWNNOPOrKQGUqZbEPO+es+FRXMWvIyzUKUOYdDgdlA6BdZj/R58p4umoQ76c2oJC44PiM7mKizyyex1IJzow=="], + + "@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.16", "https://registry.npmmirror.com/@smithy/middleware-serde/-/middleware-serde-4.2.16.tgz", { "dependencies": { "@smithy/core": "^3.23.13", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-beqfV+RZ9RSv+sQqor3xroUUYgRFCGRw6niGstPG8zO9LgTl0B0MCucxjmrH/2WwksQN7UUgI7KNANoZv+KALA=="], + + "@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.12", "https://registry.npmmirror.com/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw=="], + + "@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.12", "https://registry.npmmirror.com/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.5.1", "https://registry.npmmirror.com/@smithy/node-http-handler/-/node-http-handler-4.5.1.tgz", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-ejjxdAXjkPIs9lyYyVutOGNOraqUE9v/NjGMKwwFrfOM354wfSD8lmlj8hVwUzQmlLLF4+udhfCX9Exnbmvfzw=="], + + "@smithy/property-provider": ["@smithy/property-provider@4.2.12", "https://registry.npmmirror.com/@smithy/property-provider/-/property-provider-4.2.12.tgz", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="], + + "@smithy/protocol-http": ["@smithy/protocol-http@5.3.12", "https://registry.npmmirror.com/@smithy/protocol-http/-/protocol-http-5.3.12.tgz", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw=="], + + "@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "https://registry.npmmirror.com/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="], + + "@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.12", "https://registry.npmmirror.com/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw=="], + + "@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "https://registry.npmmirror.com/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="], + + "@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "https://registry.npmmirror.com/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "https://registry.npmmirror.com/@smithy/signature-v4/-/signature-v4-5.3.12.tgz", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="], + + "@smithy/smithy-client": ["@smithy/smithy-client@4.12.8", "https://registry.npmmirror.com/@smithy/smithy-client/-/smithy-client-4.12.8.tgz", { "dependencies": { "@smithy/core": "^3.23.13", "@smithy/middleware-endpoint": "^4.4.28", "@smithy/middleware-stack": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.21", "tslib": "^2.6.2" } }, "sha512-aJaAX7vHe5i66smoSSID7t4rKY08PbD8EBU7DOloixvhOozfYWdcSYE4l6/tjkZ0vBZhGjheWzB2mh31sLgCMA=="], + + "@smithy/types": ["@smithy/types@4.13.1", "https://registry.npmmirror.com/@smithy/types/-/types-4.13.1.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="], + + "@smithy/url-parser": ["@smithy/url-parser@4.2.12", "https://registry.npmmirror.com/@smithy/url-parser/-/url-parser-4.2.12.tgz", { "dependencies": { "@smithy/querystring-parser": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA=="], + + "@smithy/util-base64": ["@smithy/util-base64@4.3.2", "https://registry.npmmirror.com/@smithy/util-base64/-/util-base64-4.3.2.tgz", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ=="], + + "@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.2", "https://registry.npmmirror.com/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ=="], + + "@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.3", "https://registry.npmmirror.com/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g=="], + + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "https://registry.npmmirror.com/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="], + + "@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.2", "https://registry.npmmirror.com/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ=="], + + "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.44", "https://registry.npmmirror.com/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.44.tgz", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-eZg6XzaCbVr2S5cAErU5eGBDaOVTuTo1I65i4tQcHENRcZ8rMWhQy1DaIYUSLyZjsfXvmCqZrstSMYyGFocvHA=="], + + "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.48", "https://registry.npmmirror.com/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.48.tgz", { "dependencies": { "@smithy/config-resolver": "^4.4.13", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-FqOKTlqSaoV3nzO55pMs5NBnZX8EhoI0DGmn9kbYeXWppgHD6dchyuj2HLqp4INJDJbSrj6OFYJkAh/WhSzZPg=="], + + "@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.3", "https://registry.npmmirror.com/@smithy/util-endpoints/-/util-endpoints-3.3.3.tgz", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig=="], + + "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.2", "https://registry.npmmirror.com/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="], + + "@smithy/util-middleware": ["@smithy/util-middleware@4.2.12", "https://registry.npmmirror.com/@smithy/util-middleware/-/util-middleware-4.2.12.tgz", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ=="], + + "@smithy/util-retry": ["@smithy/util-retry@4.2.13", "https://registry.npmmirror.com/@smithy/util-retry/-/util-retry-4.2.13.tgz", { "dependencies": { "@smithy/service-error-classification": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-qQQsIvL0MGIbUjeSrg0/VlQ3jGNKyM3/2iU3FPNgy01z+Sp4OvcaxbgIoFOTvB61ZoohtutuOvOcgmhbD0katQ=="], + + "@smithy/util-stream": ["@smithy/util-stream@4.5.21", "https://registry.npmmirror.com/@smithy/util-stream/-/util-stream-4.5.21.tgz", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.1", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-KzSg+7KKywLnkoKejRtIBXDmwBfjGvg1U1i/etkC7XSWUyFCoLno1IohV2c74IzQqdhX5y3uE44r/8/wuK+A7Q=="], + + "@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.2", "https://registry.npmmirror.com/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="], + + "@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "https://registry.npmmirror.com/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], + + "@smithy/uuid": ["@smithy/uuid@1.1.2", "https://registry.npmmirror.com/@smithy/uuid/-/uuid-1.1.2.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="], + + "@types/lodash": ["@types/lodash@4.17.24", "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="], + + "@types/lodash-es": ["@types/lodash-es@4.17.12", "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="], + + "accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "agent-base": ["agent-base@8.0.0", "https://registry.npmmirror.com/agent-base/-/agent-base-8.0.0.tgz", {}, "sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg=="], + + "ajv": ["ajv@8.18.0", "https://registry.npmmirror.com/ajv/-/ajv-8.18.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "ajv-formats": ["ajv-formats@3.0.1", "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", { "dependencies": { "ajv": "^8.0.0" }, "peerDependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "ansi-escapes": ["ansi-escapes@7.3.0", "https://registry.npmmirror.com/ansi-escapes/-/ansi-escapes-7.3.0.tgz", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], + + "ansi-regex": ["ansi-regex@6.2.2", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@6.2.3", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "asciichart": ["asciichart@1.5.25", "https://registry.npmmirror.com/asciichart/-/asciichart-1.5.25.tgz", {}, "sha512-PNxzXIPPOtWq8T7bgzBtk9cI2lgS4SJZthUHEiQ1aoIc3lNzGfUvIvo9LiAnq26TACo9t1/4qP6KTGAUbzX9Xg=="], + + "asynckit": ["asynckit@0.4.0", "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "auto-bind": ["auto-bind@5.0.1", "https://registry.npmmirror.com/auto-bind/-/auto-bind-5.0.1.tgz", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + + "axios": ["axios@1.14.0", "https://registry.npmmirror.com/axios/-/axios-1.14.0.tgz", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="], + + "base64-js": ["base64-js@1.5.1", "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bidi-js": ["bidi-js@1.0.3", "https://registry.npmmirror.com/bidi-js/-/bidi-js-1.0.3.tgz", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + + "bignumber.js": ["bignumber.js@9.3.1", "https://registry.npmmirror.com/bignumber.js/-/bignumber.js-9.3.1.tgz", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "body-parser": ["body-parser@2.2.2", "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.2.tgz", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "bowser": ["bowser@2.14.1", "https://registry.npmmirror.com/bowser/-/bowser-2.14.1.tgz", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bytes": ["bytes@3.1.2", "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "camelcase": ["camelcase@5.3.1", "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + + "chalk": ["chalk@5.6.2", "https://registry.npmmirror.com/chalk/-/chalk-5.6.2.tgz", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "chokidar": ["chokidar@5.0.0", "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + + "cli-boxes": ["cli-boxes@4.0.1", "https://registry.npmmirror.com/cli-boxes/-/cli-boxes-4.0.1.tgz", {}, "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw=="], + + "cli-cursor": ["cli-cursor@4.0.0", "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-4.0.0.tgz", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], + + "cli-truncate": ["cli-truncate@5.2.0", "https://registry.npmmirror.com/cli-truncate/-/cli-truncate-5.2.0.tgz", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], + + "cliui": ["cliui@6.0.0", "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + + "code-excerpt": ["code-excerpt@4.0.0", "https://registry.npmmirror.com/code-excerpt/-/code-excerpt-4.0.0.tgz", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + + "color-convert": ["color-convert@2.0.1", "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "combined-stream": ["combined-stream@1.0.8", "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "commander": ["commander@12.1.0", "https://registry.npmmirror.com/commander/-/commander-12.1.0.tgz", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "content-disposition": ["content-disposition@1.0.1", "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.0.1.tgz", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], + + "content-type": ["content-type@1.0.5", "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "convert-to-spaces": ["convert-to-spaces@2.0.1", "https://registry.npmmirror.com/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], + + "cookie": ["cookie@0.7.2", "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "https://registry.npmmirror.com/cors/-/cors-2.8.6.tgz", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssfilter": ["cssfilter@0.0.10", "https://registry.npmmirror.com/cssfilter/-/cssfilter-0.0.10.tgz", {}, "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decamelize": ["decamelize@1.2.0", "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + + "delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "depd": ["depd@2.0.0", "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "diff": ["diff@8.0.4", "https://registry.npmmirror.com/diff/-/diff-8.0.4.tgz", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + + "dijkstrajs": ["dijkstrajs@1.0.3", "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + + "dom-mutator": ["dom-mutator@0.6.0", "https://registry.npmmirror.com/dom-mutator/-/dom-mutator-0.6.0.tgz", {}, "sha512-iCt9o0aYfXMUkz/43ZOAUFQYotjGB+GNbYJiJdz4TgXkyToXbbRy5S6FbTp72lRBtfpUMwEc1KmpFEU4CZeoNg=="], + + "dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "ee-first": ["ee-first@1.1.1", "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "emoji-regex": ["emoji-regex@10.6.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "encodeurl": ["encodeurl@2.0.0", "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "env-paths": ["env-paths@4.0.0", "https://registry.npmmirror.com/env-paths/-/env-paths-4.0.0.tgz", { "dependencies": { "is-safe-filename": "^0.1.0" } }, "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw=="], + + "environment": ["environment@1.1.0", "https://registry.npmmirror.com/environment/-/environment-1.1.0.tgz", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + + "es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "es-toolkit": ["es-toolkit@1.45.1", "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.45.1.tgz", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="], + + "escape-html": ["escape-html@1.0.3", "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "escape-string-regexp": ["escape-string-regexp@2.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + + "etag": ["etag@1.8.1", "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "https://registry.npmmirror.com/eventsource/-/eventsource-3.0.7.tgz", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.6", "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.0.6.tgz", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + + "execa": ["execa@9.6.1", "https://registry.npmmirror.com/execa/-/execa-9.6.1.tgz", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + + "express": ["express@5.2.1", "https://registry.npmmirror.com/express/-/express-5.2.1.tgz", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.3.2", "https://registry.npmmirror.com/express-rate-limit/-/express-rate-limit-8.3.2.tgz", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="], + + "extend": ["extend@3.0.2", "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.0", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "fast-xml-builder": ["fast-xml-builder@1.1.4", "https://registry.npmmirror.com/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="], + + "fast-xml-parser": ["fast-xml-parser@5.5.8", "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="], + + "fetch-blob": ["fetch-blob@3.2.0", "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "figures": ["figures@6.1.0", "https://registry.npmmirror.com/figures/-/figures-6.1.0.tgz", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + + "finalhandler": ["finalhandler@2.1.1", "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "find-up": ["find-up@4.1.0", "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + + "follow-redirects": ["follow-redirects@1.15.11", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + + "form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "https://registry.npmmirror.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "forwarded": ["forwarded@0.2.0", "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "fuse.js": ["fuse.js@7.1.0", "https://registry.npmmirror.com/fuse.js/-/fuse.js-7.1.0.tgz", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="], + + "gaxios": ["gaxios@7.1.4", "https://registry.npmmirror.com/gaxios/-/gaxios-7.1.4.tgz", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "https://registry.npmmirror.com/gcp-metadata/-/gcp-metadata-8.1.2.tgz", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.5.0", "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-stream": ["get-stream@9.0.1", "https://registry.npmmirror.com/get-stream/-/get-stream-9.0.1.tgz", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + + "google-auth-library": ["google-auth-library@10.6.2", "https://registry.npmmirror.com/google-auth-library/-/google-auth-library-10.6.2.tgz", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "https://registry.npmmirror.com/google-logging-utils/-/google-logging-utils-1.1.3.tgz", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "gopd": ["gopd@1.2.0", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-flag": ["has-flag@5.0.1", "https://registry.npmmirror.com/has-flag/-/has-flag-5.0.1.tgz", {}, "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA=="], + + "has-symbols": ["has-symbols@1.1.0", "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "highlight.js": ["highlight.js@11.11.1", "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], + + "hono": ["hono@4.12.9", "https://registry.npmmirror.com/hono/-/hono-4.12.9.tgz", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="], + + "http-errors": ["http-errors@2.0.1", "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "https-proxy-agent": ["https-proxy-agent@8.0.0", "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-8.0.0.tgz", { "dependencies": { "agent-base": "8.0.0", "debug": "^4.3.4" } }, "sha512-YYeW+iCnAS3xhvj2dvVoWgsbca3RfQy/IlaNHHOtDmU0jMqPI9euIq3Y9BJETdxk16h9NHHCKqp/KB9nIMStCQ=="], + + "human-signals": ["human-signals@8.0.1", "https://registry.npmmirror.com/human-signals/-/human-signals-8.0.1.tgz", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "indent-string": ["indent-string@5.0.0", "https://registry.npmmirror.com/indent-string/-/indent-string-5.0.0.tgz", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + + "inherits": ["inherits@2.0.4", "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ink": ["ink@6.8.0", "https://registry.npmmirror.com/ink/-/ink-6.8.0.tgz", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="], + + "ip-address": ["ip-address@10.1.0", "https://registry.npmmirror.com/ip-address/-/ip-address-10.1.0.tgz", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "is-in-ci": ["is-in-ci@2.0.0", "https://registry.npmmirror.com/is-in-ci/-/is-in-ci-2.0.0.tgz", { "bin": "cli.js" }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-promise": ["is-promise@4.0.0", "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "is-safe-filename": ["is-safe-filename@0.1.1", "https://registry.npmmirror.com/is-safe-filename/-/is-safe-filename-0.1.1.tgz", {}, "sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g=="], + + "is-stream": ["is-stream@4.0.1", "https://registry.npmmirror.com/is-stream/-/is-stream-4.0.1.tgz", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + + "is-unicode-supported": ["is-unicode-supported@2.1.0", "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + + "isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.2", "https://registry.npmmirror.com/jose/-/jose-6.2.2.tgz", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + + "json-bigint": ["json-bigint@1.0.0", "https://registry.npmmirror.com/json-bigint/-/json-bigint-1.0.0.tgz", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "https://registry.npmmirror.com/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "https://registry.npmmirror.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "https://registry.npmmirror.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "jwa": ["jwa@2.0.1", "https://registry.npmmirror.com/jwa/-/jwa-2.0.1.tgz", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "locate-path": ["locate-path@5.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + + "lodash-es": ["lodash-es@4.17.23", "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.23.tgz", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="], + + "lodash.debounce": ["lodash.debounce@4.0.8", "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + + "lru-cache": ["lru-cache@11.2.7", "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.2.7.tgz", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="], + + "marked": ["marked@17.0.5", "https://registry.npmmirror.com/marked/-/marked-17.0.5.tgz", { "bin": "bin/marked.js" }, "sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "mimic-fn": ["mimic-fn@2.1.0", "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + + "ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.0.0", "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "node-domexception": ["node-domexception@1.0.0", "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "npm-run-path": ["npm-run-path@6.0.0", "https://registry.npmmirror.com/npm-run-path/-/npm-run-path-6.0.0.tgz", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], + + "object-assign": ["object-assign@4.1.1", "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "onetime": ["onetime@5.1.2", "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "p-limit": ["p-limit@2.3.0", "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + + "p-locate": ["p-locate@4.1.0", "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + + "p-map": ["p-map@7.0.4", "https://registry.npmmirror.com/p-map/-/p-map-7.0.4.tgz", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], + + "p-try": ["p-try@2.2.0", "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + + "parse-ms": ["parse-ms@4.0.0", "https://registry.npmmirror.com/parse-ms/-/parse-ms-4.0.0.tgz", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + + "parseurl": ["parseurl@1.3.3", "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "patch-console": ["patch-console@2.0.0", "https://registry.npmmirror.com/patch-console/-/patch-console-2.0.0.tgz", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + + "path-exists": ["path-exists@4.0.0", "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-expression-matcher": ["path-expression-matcher@1.2.0", "https://registry.npmmirror.com/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", {}, "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ=="], + + "path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.1", "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.4.1.tgz", {}, "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw=="], + + "picomatch": ["picomatch@4.0.4", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "https://registry.npmmirror.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "pngjs": ["pngjs@5.0.0", "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + + "pretty-ms": ["pretty-ms@9.3.0", "https://registry.npmmirror.com/pretty-ms/-/pretty-ms-9.3.0.tgz", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + + "proper-lockfile": ["proper-lockfile@4.1.2", "https://registry.npmmirror.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + + "proxy-addr": ["proxy-addr@2.0.7", "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "proxy-from-env": ["proxy-from-env@2.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], + + "qrcode": ["qrcode@1.5.4", "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": "bin/qrcode" }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + + "qs": ["qs@6.15.0", "https://registry.npmmirror.com/qs/-/qs-6.15.0.tgz", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], + + "range-parser": ["range-parser@1.2.1", "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "react": ["react@19.2.4", "https://registry.npmmirror.com/react/-/react-19.2.4.tgz", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-reconciler": ["react-reconciler@0.33.0", "https://registry.npmmirror.com/react-reconciler/-/react-reconciler-0.33.0.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], + + "readdirp": ["readdirp@5.0.0", "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + + "require-directory": ["require-directory@2.1.1", "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "require-from-string": ["require-from-string@2.0.2", "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "require-main-filename": ["require-main-filename@2.0.0", "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + + "restore-cursor": ["restore-cursor@4.0.0", "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-4.0.0.tgz", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], + + "retry": ["retry@0.12.0", "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "router": ["router@2.2.0", "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "send": ["send@1.2.1", "https://registry.npmmirror.com/send/-/send-1.2.1.tgz", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "https://registry.npmmirror.com/serve-static/-/serve-static-2.2.1.tgz", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "set-blocking": ["set-blocking@2.0.0", "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "shell-quote": ["shell-quote@1.8.3", "https://registry.npmmirror.com/shell-quote/-/shell-quote-1.8.3.tgz", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + + "side-channel": ["side-channel@1.1.0", "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "signal-exit": ["signal-exit@4.1.0", "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "slice-ansi": ["slice-ansi@8.0.0", "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-8.0.0.tgz", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], + + "stack-utils": ["stack-utils@2.0.6", "https://registry.npmmirror.com/stack-utils/-/stack-utils-2.0.6.tgz", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + + "statuses": ["statuses@2.0.2", "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "string-width": ["string-width@8.2.0", "https://registry.npmmirror.com/string-width/-/string-width-8.2.0.tgz", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], + + "strip-ansi": ["strip-ansi@7.2.0", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-final-newline": ["strip-final-newline@4.0.0", "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-4.0.0.tgz", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + + "strnum": ["strnum@2.2.2", "https://registry.npmmirror.com/strnum/-/strnum-2.2.2.tgz", {}, "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA=="], + + "supports-color": ["supports-color@10.2.2", "https://registry.npmmirror.com/supports-color/-/supports-color-10.2.2.tgz", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + + "supports-hyperlinks": ["supports-hyperlinks@4.4.0", "https://registry.npmmirror.com/supports-hyperlinks/-/supports-hyperlinks-4.4.0.tgz", { "dependencies": { "has-flag": "^5.0.1", "supports-color": "^10.2.2" } }, "sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg=="], + + "tagged-tag": ["tagged-tag@1.0.0", "https://registry.npmmirror.com/tagged-tag/-/tagged-tag-1.0.0.tgz", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + + "terminal-size": ["terminal-size@4.0.1", "https://registry.npmmirror.com/terminal-size/-/terminal-size-4.0.1.tgz", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], + + "toidentifier": ["toidentifier@1.0.1", "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "tree-kill": ["tree-kill@1.2.2", "https://registry.npmmirror.com/tree-kill/-/tree-kill-1.2.2.tgz", { "bin": "cli.js" }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + + "ts-algebra": ["ts-algebra@2.0.0", "https://registry.npmmirror.com/ts-algebra/-/ts-algebra-2.0.0.tgz", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + + "tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-fest": ["type-fest@5.5.0", "https://registry.npmmirror.com/type-fest/-/type-fest-5.5.0.tgz", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g=="], + + "type-is": ["type-is@2.0.1", "https://registry.npmmirror.com/type-is/-/type-is-2.0.1.tgz", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "undici": ["undici@7.24.6", "https://registry.npmmirror.com/undici/-/undici-7.24.6.tgz", {}, "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA=="], + + "unicorn-magic": ["unicorn-magic@0.3.0", "https://registry.npmmirror.com/unicorn-magic/-/unicorn-magic-0.3.0.tgz", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], + + "unpipe": ["unpipe@1.0.0", "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "usehooks-ts": ["usehooks-ts@3.1.1", "https://registry.npmmirror.com/usehooks-ts/-/usehooks-ts-3.1.1.tgz", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="], + + "vary": ["vary@1.1.2", "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "https://registry.npmmirror.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="], + + "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "https://registry.npmmirror.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "which-module": ["which-module@2.0.1", "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + + "widest-line": ["widest-line@6.0.0", "https://registry.npmmirror.com/widest-line/-/widest-line-6.0.0.tgz", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], + + "wrap-ansi": ["wrap-ansi@10.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-10.0.0.tgz", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], + + "wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.20.0", "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + + "xss": ["xss@1.0.15", "https://registry.npmmirror.com/xss/-/xss-1.0.15.tgz", { "dependencies": { "commander": "^2.20.3", "cssfilter": "0.0.10" }, "bin": "bin/xss" }, "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg=="], + + "y18n": ["y18n@4.0.3", "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + + "yaml": ["yaml@2.8.3", "https://registry.npmmirror.com/yaml/-/yaml-2.8.3.tgz", { "bin": "bin.mjs" }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + + "yargs": ["yargs@15.4.1", "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + + "yargs-parser": ["yargs-parser@18.1.3", "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + + "yoctocolors": ["yoctocolors@2.1.2", "https://registry.npmmirror.com/yoctocolors/-/yoctocolors-2.1.2.tgz", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + + "yoga-layout": ["yoga-layout@3.2.1", "https://registry.npmmirror.com/yoga-layout/-/yoga-layout-3.2.1.tgz", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + + "zod": ["zod@4.3.6", "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "https://registry.npmmirror.com/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "@anthropic-ai/sandbox-runtime/zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "https://registry.npmmirror.com/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "https://registry.npmmirror.com/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@commander-js/extra-typings/commander": ["commander@14.0.3", "https://registry.npmmirror.com/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "cliui/string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "cliui/strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "cliui/wrap-ansi": ["wrap-ansi@6.2.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "form-data/mime-types": ["mime-types@2.1.35", "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "ink/cli-boxes": ["cli-boxes@3.0.0", "https://registry.npmmirror.com/cli-boxes/-/cli-boxes-3.0.0.tgz", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + + "ink/signal-exit": ["signal-exit@3.0.7", "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "ink/wrap-ansi": ["wrap-ansi@9.0.2", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "npm-run-path/path-key": ["path-key@4.0.0", "https://registry.npmmirror.com/path-key/-/path-key-4.0.0.tgz", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + + "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "restore-cursor/signal-exit": ["signal-exit@3.0.7", "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "xss/commander": ["commander@2.20.3", "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "yargs/string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "https://registry.npmmirror.com/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "https://registry.npmmirror.com/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ink/wrap-ansi/string-width": ["string-width@7.2.0", "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "https://registry.npmmirror.com/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "https://registry.npmmirror.com/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + } +} diff --git a/docs/computer-use.en.md b/docs/computer-use.en.md new file mode 100644 index 00000000..031a0930 --- /dev/null +++ b/docs/computer-use.en.md @@ -0,0 +1,241 @@ +# Computer Use Guide + +

中文 | English

+ +> **Modified Version**: This feature is a **heavily modified version** of the Computer Use (internal codename "Chicago") found in the leaked Claude Code source. The official implementation relies on Anthropic's private native modules (`@ant/computer-use-swift`, `@ant/computer-use-input`) that are not publicly available. We **replaced the entire underlying operation layer** with a Python bridge (`pyautogui` + `mss` + `pyobjc`), enabling anyone to run Computer Use on macOS. + +--- + +## Table of Contents + +- [Overview](#overview) +- [Supported Platforms](#supported-platforms) +- [How It Works](#how-it-works) +- [Quick Start](#quick-start) +- [Usage](#usage) +- [Security](#security) +- [Environment Variables](#environment-variables) +- [Technical Architecture](#technical-architecture) +- [Approaches We Tried](#approaches-we-tried) +- [Known Limitations](#known-limitations) +- [References and Credits](#references-and-credits) + +--- + +## Overview + +Computer Use allows AI models to **directly control your computer** — taking screenshots, moving the mouse, clicking buttons, typing text, and managing application windows. + +24 MCP tools are available: + +| Category | Tools | +|----------|-------| +| Screenshot | `screenshot`, `zoom` | +| Mouse | `left_click`, `right_click`, `middle_click`, `double_click`, `triple_click`, `left_click_drag`, `mouse_move`, `left_mouse_down`, `left_mouse_up`, `cursor_position`, `scroll` | +| Keyboard | `type`, `key`, `hold_key` | +| Apps | `open_application`, `switch_display` | +| Permissions | `request_access`, `list_granted_applications` | +| Clipboard | `read_clipboard`, `write_clipboard` | +| Other | `wait`, `computer_batch` | + +--- + +## Supported Platforms + +| Platform | Architecture | Status | Notes | +|----------|-------------|--------|-------| +| macOS | Apple Silicon (M1/M2/M3/M4) | ✅ Fully supported | Recommended | +| macOS | Intel x86_64 | ✅ Fully supported | | +| Windows | Any | ⚠️ Theoretically possible | Core libs (`pyautogui` + `mss`) are cross-platform, but `pyobjc` parts (app management) need to be replaced with `win32com`. Not yet adapted | +| Linux | Any | ⚠️ Theoretically possible | Same as above — `pyobjc` needs to be replaced with `wmctrl` + `xdotool`. Not yet adapted | + +### Requirements + +- [Bun](https://bun.sh) >= 1.1.0 +- Python >= 3.8 (venv and dependencies are auto-installed on first use) +- macOS permissions: Accessibility + Screen Recording + +--- + +## How It Works + +Computer Use operates through a **screenshot → analyze → act** feedback loop: + +``` +┌────────────────────────────────────────────────────┐ +│ AI Model (Claude / any Anthropic-protocol model) │ +│ │ +│ 1. Receives user request: "open Music app" │ +│ 2. Calls screenshot tool → receives screen image │ +│ 3. Model analyzes pixels, identifies UI elements │ +│ → "search box is at (756, 342)" │ +│ 4. Calls left_click { coordinate: [756, 342] } │ +│ 5. Calls type { text: "search query" } │ +│ 6. Calls screenshot again → verify → next step... │ +└───────────────┬────────────────────────────────────┘ + │ MCP Tool Call + ▼ +┌────────────────────────────────────────────────────┐ +│ TypeScript Tool Layer (vendor/computer-use-mcp) │ +│ - Security checks (app allowlist, TCC permissions) │ +│ - Coordinate transformation │ +│ - Tool dispatch → executor │ +└───────────────┬────────────────────────────────────┘ + │ callPythonHelper() + ▼ +┌────────────────────────────────────────────────────┐ +│ Python Bridge (runtime/mac_helper.py) │ +│ pyautogui.click(756, 342) ← mouse control │ +│ mss.grab(monitor) ← screenshot │ +│ NSWorkspace.open(bundleId) ← app management │ +└────────────────────────────────────────────────────┘ +``` + +**Key**: Coordinate analysis is performed entirely by the model's vision capabilities — it "sees" the screenshot like a human sees a screen, identifying buttons, text fields, and other UI elements directly from pixels. + +--- + +## Quick Start + +### 1. Install dependencies + +```bash +bun install +``` + +### 2. Ensure Python 3 is available + +```bash +python3 --version # >= 3.8 required +``` + +> Python dependencies are **automatically installed** into `.runtime/venv/` on first Computer Use invocation. + +### 3. Grant macOS permissions + +**Accessibility:** + +```bash +open "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" +``` + +Add your **terminal app** (iTerm, Terminal, Ghostty, etc.) to the allow list. + +**Screen Recording:** + +```bash +open "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture" +``` + +Add your terminal app as well. You may need to **restart your terminal** after granting permission. + +### 4. Start + +```bash +./bin/claude-haha +``` + +### 5. Use + +Just ask in natural language: + +``` +> Take a screenshot of my desktop +> Open Safari and search for something +> Type "hello" in the text editor +``` + +--- + +## Security + +| Mechanism | Description | +|-----------|-------------| +| **App allowlist** | Each session requires explicit authorization for which apps Claude can interact with | +| **Concurrency lock** | Only one Claude session can use Computer Use at a time (file lock) | +| **Clipboard guard** | Original clipboard content is saved and restored when typing via clipboard | +| **Sensitive action gates** | System keyboard shortcuts require additional authorization | + +> Note: Since we replaced the native modules with Python bridge, the global Escape hotkey abort and auto-hide features from the original implementation are not available. Use `Ctrl+C` to abort instead. + +--- + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `CLAUDE_COMPUTER_USE_ENABLED` | `1` | Set to `0` to disable Computer Use | +| `CLAUDE_COMPUTER_USE_COORDINATE_MODE` | `pixels` | Coordinate mode: `pixels` or `normalized_0_100` | +| `CLAUDE_COMPUTER_USE_CLIPBOARD_PASTE` | `1` | Enable clipboard-based text input | +| `CLAUDE_COMPUTER_USE_MOUSE_ANIMATION` | `1` | Enable mouse animation | +| `CLAUDE_COMPUTER_USE_DEBUG` | `0` | Debug mode | + +--- + +## Technical Architecture + +### Gate Bypass + +The official Claude Code gates Computer Use behind three layers: + +| Layer | Original Mechanism | Our Approach | +|-------|-------------------|--------------| +| Compile-time | `feature('CHICAGO_MCP')` (Bun macro) | Replaced with `true` | +| Subscription | `hasRequiredSubscription()` (Max/Pro only) | `getChicagoEnabled()` returns `true` directly | +| Remote config | GrowthBook `tengu_malort_pedway` | Same — no remote dependency | +| Default-disabled | `isDefaultDisabledBuiltin('computer-use')` | Returns `false` | + +### Python Bridge + +On first invocation, the bridge automatically: +1. Creates a Python virtual environment (`.runtime/venv/`) +2. Installs pip +3. Installs dependencies (`mss`, `Pillow`, `pyautogui`, `pyobjc-*`) +4. Validates via SHA256 hash (only reinstalls when `requirements.txt` changes) + +--- + +## Approaches We Tried + +### Approach 1: Extract native .node modules from Claude Code binary ❌ + +Extracted `computer-use-swift.node` and `computer-use-input.node` from the installed Claude Code Mach-O binary. Synchronous methods worked, but async Swift methods (screenshot) hung due to N-API async incompatibility between Bun versions. + +### Approach 2: Create empty stub packages ❌ + +Stub packages allowed compilation but provided no actual functionality. + +### Approach 3: Python Bridge ✅ (current) + +Replaced all native module calls with Python subprocess calls via `callPythonHelper()`. Zero binary dependencies, auto-bootstrapping, full functionality on any macOS. + +--- + +## Known Limitations + +| Limitation | Description | +|------------|-------------| +| macOS only | Windows/Linux need `pyobjc` replacements | +| No global Escape abort | Original used CGEventTap; use `Ctrl+C` instead | +| No auto-hide windows | Original's `prepareDisplay` relied on Swift | +| Slightly higher latency | ~100ms Python process startup overhead per call | + +--- + +## References and Credits + +| Project | License | Contribution | +|---------|---------|-------------| +| [wimi321/macos-computer-use-skill](https://github.com/wimi321/macos-computer-use-skill) | MIT | Python bridge architecture, `mac_helper.py` runtime, executor adaptation | +| [domdomegg/computer-use-mcp](https://github.com/domdomegg/computer-use-mcp) | MIT | Independent Computer Use MCP server (nut.js based), used as reference | +| [paoloanzn/free-code](https://github.com/paoloanzn/free-code) | - | Feature flag system analysis | +| [oboard/claude-code-rev](https://github.com/oboard/claude-code-rev) | - | Early leaked source restoration, stub package reference | + +### Underlying Libraries + +| Library | Purpose | +|---------|---------| +| [pyautogui](https://github.com/asweigart/pyautogui) | Mouse and keyboard control | +| [mss](https://github.com/BoboTiG/python-mss) | Screenshot capture | +| [Pillow](https://github.com/python-pillow/Pillow) | Image processing and compression | +| [pyobjc](https://github.com/ronaldoussoren/pyobjc) | macOS Cocoa/Quartz framework bindings | diff --git a/docs/computer-use.md b/docs/computer-use.md new file mode 100644 index 00000000..ee43ef90 --- /dev/null +++ b/docs/computer-use.md @@ -0,0 +1,312 @@ +# Computer Use 功能指南 + +

中文 | English

+ +> **魔改说明**:本功能是基于 Claude Code 泄露源码中的 Computer Use(内部代号 "Chicago")进行的**深度改造版本**。官方实现依赖 Anthropic 内部私有原生模块(`@ant/computer-use-swift`、`@ant/computer-use-input`),无法公开获取。我们**替换了整个底层操作层**,使用 Python bridge(`pyautogui` + `mss` + `pyobjc`)实现所有系统交互,使得任何人都可以在 macOS 上运行 Computer Use 功能。 + +--- + +## 目录 + +- [功能简介](#功能简介) +- [支持的设备与平台](#支持的设备与平台) +- [工作原理](#工作原理) +- [快速开始](#快速开始) +- [使用方式](#使用方式) +- [安全机制](#安全机制) +- [环境变量](#环境变量) +- [技术架构详解](#技术架构详解) +- [我们尝试过的方案](#我们尝试过的方案) +- [已知限制](#已知限制) +- [参考项目与致谢](#参考项目与致谢) + +--- + +## 功能简介 + +Computer Use 让 AI 模型能够**直接控制你的电脑**——截屏、移动鼠标、点击按钮、输入文字、管理应用窗口。 + +支持的操作(共 24 个 MCP 工具): + +| 类别 | 工具 | +|------|------| +| 截屏 | `screenshot`、`zoom` | +| 鼠标 | `left_click`、`right_click`、`middle_click`、`double_click`、`triple_click`、`left_click_drag`、`mouse_move`、`left_mouse_down`、`left_mouse_up`、`cursor_position`、`scroll` | +| 键盘 | `type`、`key`、`hold_key` | +| 应用 | `open_application`、`switch_display` | +| 权限 | `request_access`、`list_granted_applications` | +| 剪贴板 | `read_clipboard`、`write_clipboard` | +| 其他 | `wait`、`computer_batch` | + +--- + +## 支持的设备与平台 + +| 平台 | 芯片 | 状态 | 说明 | +|------|------|------|------| +| macOS | Apple Silicon (M1/M2/M3/M4) | ✅ 完整支持 | 推荐平台 | +| macOS | Intel x86_64 | ✅ 完整支持 | | +| Windows | 任意 | ⚠️ 理论可行 | `pyautogui` + `mss` 跨平台,但 `pyobjc` 部分(应用管理)需替换为 `win32com`,当前未适配 | +| Linux | 任意 | ⚠️ 理论可行 | 同上,需替换 `pyobjc` 为 `wmctrl` + `xdotool`,当前未适配 | + +### 运行环境要求 + +- [Bun](https://bun.sh) >= 1.1.0 +- Python >= 3.8(首次运行自动创建 venv 并安装依赖) +- macOS 系统权限:Accessibility(辅助功能)+ Screen Recording(屏幕录制) + +--- + +## 工作原理 + +Computer Use 的核心是一个**截图-分析-操作**的闭环: + +``` +┌──────────────────────────────────────────────┐ +│ AI 模型(Claude / 其他 Anthropic 协议模型) │ +│ │ +│ 1. 收到用户请求 "打开网易云搜索喜欢你" │ +│ 2. 调用 screenshot 工具 → 收到屏幕截图 │ +│ 3. 模型分析截图像素,识别 UI 元素位置 │ +│ → "搜索框在 (756, 342)" │ +│ 4. 调用 left_click { coordinate: [756, 342] } │ +│ 5. 调用 type { text: "喜欢你" } │ +│ 6. 再次 screenshot → 确认结果 → 下一步... │ +└──────────────┬───────────────────────────────┘ + │ MCP Tool Call + ▼ +┌──────────────────────────────────────────────┐ +│ TypeScript 工具层 │ +│ (vendor/computer-use-mcp) │ +│ │ +│ - 安全检查(应用白名单、TCC 权限) │ +│ - 坐标模式转换(pixels / normalized) │ +│ - 工具分发 → executor │ +└──────────────┬───────────────────────────────┘ + │ callPythonHelper() + ▼ +┌──────────────────────────────────────────────┐ +│ Python Bridge │ +│ (runtime/mac_helper.py) │ +│ │ +│ pyautogui.click(756, 342) ← 鼠标控制 │ +│ mss.grab(monitor) ← 截图 │ +│ NSWorkspace.open(bundleId) ← 应用管理 │ +└──────────────────────────────────────────────┘ +``` + +**关键:坐标分析完全由模型的视觉能力完成**——模型"看"截图就像人看屏幕一样,直接从像素中识别按钮、输入框等 UI 元素的位置。 + +--- + +## 快速开始 + +### 1. 安装依赖 + +```bash +bun install +``` + +### 2. 确保 Python 3 可用 + +```bash +python3 --version # 需要 >= 3.8 +``` + +> Python 依赖会在首次使用 Computer Use 时**自动安装**到 `.runtime/venv/`,无需手动操作。 + +### 3. 授予 macOS 权限 + +#### Accessibility(辅助功能) + +```bash +open "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" +``` + +将你的**终端应用**(如 iTerm、Terminal、Ghostty 等)添加到允许列表。 + +#### Screen Recording(屏幕录制) + +```bash +open "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture" +``` + +同样添加你的终端应用。授权后可能需要**重启终端**。 + +### 4. 启动 + +```bash +./bin/claude-haha +``` + +### 5. 使用 + +在对话中用自然语言请求即可: + +``` +> 帮我打开网易云音乐,搜索一首歌 +> 截个屏看看当前桌面 +> 帮我在 VS Code 里打开终端 +``` + +--- + +## 使用方式 + +首次使用 Computer Use 时,系统会弹出**应用授权对话框**,你需要选择允许 Claude 操作的应用。 + +- 模型会先调用 `request_access` 请求权限 +- 你在终端中确认允许哪些应用 +- 之后模型就可以截图、点击、输入了 + +--- + +## 安全机制 + +| 机制 | 说明 | +|------|------| +| **应用白名单** | 每次会话需要明确授权允许操作的应用 | +| **并发保护** | 同一时间只有一个 Claude 会话可使用 Computer Use(文件锁机制) | +| **剪贴板保护** | 通过剪贴板输入文本时会自动保存和恢复原始剪贴板内容 | +| **操作确认** | 敏感操作(如系统快捷键)需要额外授权 | + +> 注意:由于底层改为 Python bridge,原生方案中的全局 Escape 快捷键中止和操作前自动隐藏应用功能暂不可用。可使用 `Ctrl+C` 中止。 + +--- + +## 环境变量 + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `CLAUDE_COMPUTER_USE_ENABLED` | `1` | 设为 `0` 可禁用 Computer Use | +| `CLAUDE_COMPUTER_USE_COORDINATE_MODE` | `pixels` | 坐标模式:`pixels` 或 `normalized_0_100` | +| `CLAUDE_COMPUTER_USE_CLIPBOARD_PASTE` | `1` | 是否启用剪贴板粘贴输入 | +| `CLAUDE_COMPUTER_USE_MOUSE_ANIMATION` | `1` | 是否启用鼠标动画 | +| `CLAUDE_COMPUTER_USE_HIDE_BEFORE_ACTION` | `0` | 操作前是否隐藏其他窗口 | +| `CLAUDE_COMPUTER_USE_DEBUG` | `0` | 调试模式 | + +--- + +## 技术架构详解 + +### 整体分层 + +``` +src/ +├── vendor/computer-use-mcp/ ← MCP 工具定义与分发(12 个文件) +│ ├── tools.ts ← 24 个工具的 schema 定义 +│ ├── toolCalls.ts ← 安全检查 + 工具分发 +│ ├── mcpServer.ts ← MCP 服务器创建 +│ ├── types.ts ← 全部类型定义 +│ └── ... +├── utils/computerUse/ +│ ├── executor.ts ← 执行器(调用 Python bridge) +│ ├── pythonBridge.ts ← Python 子进程管理 +│ ├── hostAdapter.ts ← 权限检查适配器 +│ ├── gates.ts ← 功能开关(已绕过灰度) +│ ├── wrapper.tsx ← MCP 工具覆写层 +│ ├── setup.ts ← MCP 配置初始化 +│ └── ... +└── runtime/ + ├── mac_helper.py ← Python 实现(659 行) + └── requirements.txt ← Python 依赖 +``` + +### 灰度控制绕过 + +官方 Claude Code 中 Computer Use 通过三层门控限制访问: + +| 层级 | 原始机制 | 我们的处理 | +|------|----------|-----------| +| 编译时 | `feature('CHICAGO_MCP')` (Bun 编译宏) | 替换为 `true` | +| 订阅检查 | `hasRequiredSubscription()` (Max/Pro) | `getChicagoEnabled()` 直接返回 `true` | +| 远程配置 | GrowthBook `tengu_malort_pedway` | 同上,不再依赖远程配置 | +| 默认禁用 | `isDefaultDisabledBuiltin('computer-use')` | `isDefaultDisabledBuiltin()` 返回 `false` | + +### Python Bridge 工作机制 + +```typescript +// pythonBridge.ts +async function callPythonHelper(command: string, payload: object): Promise { + await ensureBootstrapped() // 首次调用自动创建 venv + pip install + + // 调用: python3 runtime/mac_helper.py --payload '{...}' + const result = execFile(pythonBin, ['mac_helper.py', command, '--payload', JSON.stringify(payload)]) + + return JSON.parse(result.stdout) // { ok: true, result: T } +} +``` + +首次运行自动完成: +1. 创建 Python 虚拟环境 (`.runtime/venv/`) +2. 安装 pip +3. 安装依赖 (`mss`, `Pillow`, `pyautogui`, `pyobjc-*`) +4. SHA256 哈希验证(仅 requirements.txt 变更时重新安装) + +--- + +## 我们尝试过的方案 + +### 方案一:从 Claude Code 二进制提取原生 .node 模块 ❌ + +**思路**:从已安装的 Claude Code 二进制 (`~/.local/share/claude/versions/2.1.91`,189MB Mach-O) 中定位并提取嵌入的原生 NAPI 模块。 + +**实施**: +- 成功从 Bun `$bunfs` 虚拟文件系统中提取了 `computer-use-swift.node` (ARM64 424KB + x64 430KB) 和 `computer-use-input.node` (ARM64 836KB + x64 821KB) +- 同步方法(TCC 权限检查、显示枚举)正常工作 +- 创建了 npm 包装包并通过 workspace 注册 + +**失败原因**: +- Swift 异步方法(`screenshot.captureExcluding`)的 continuation 永远不会 resume +- 根因:提取的 .node 文件是针对 Claude Code 内置的 Bun 运行时编译的,与用户系统的 Bun 版本的 N-API 异步实现不兼容 +- 错误信息:`SWIFT TASK CONTINUATION MISUSE: captureScreenWithExclusion leaked its continuation without resuming it` + +### 方案二:创建空 Stub 包 ❌ + +**思路**:为 `@ant/computer-use-mcp`、`@ant/computer-use-input`、`@ant/computer-use-swift` 创建最小化的 stub 包,使代码能编译加载。 + +**失败原因**:代码能编译但 MCP 服务器注册后无法执行任何实际操作——截图、点击等全部报错。 + +### 方案三:Python Bridge 替代原生模块 ✅(当前方案) + +**思路**:参考 [wimi321/macos-computer-use-skill](https://github.com/wimi321/macos-computer-use-skill),用 Python 子进程替代所有原生模块调用。 + +**优势**: +- 零二进制依赖,不依赖特定 Bun/Node 版本 +- 纯 Python 实现,首次运行自动安装 +- 截图、鼠标、键盘、应用管理全部可用 +- macOS ARM64 + x86_64 均支持 + +--- + +## 已知限制 + +| 限制 | 说明 | +|------|------| +| 仅 macOS | Windows/Linux 需要适配 `pyobjc` 部分 | +| 无全局 Escape 中止 | 原生方案用 CGEventTap 实现,Python 版暂不支持,用 `Ctrl+C` 代替 | +| 操作前不自动隐藏窗口 | 原生方案的 `prepareDisplay` 依赖 Swift,Python 版未实现 | +| 性能略低 | Python 进程启动 ~100ms 开销,但模型思考时间通常是秒级,用户感知不到 | +| 像素验证关闭 | `pixelValidation` 默认关闭 | + +--- + +## 参考项目与致谢 + +本功能的实现参考了以下开源项目,在此致以感谢: + +| 项目 | 许可证 | 贡献 | +|------|--------|------| +| [wimi321/macos-computer-use-skill](https://github.com/wimi321/macos-computer-use-skill) | MIT | Python bridge 架构、`mac_helper.py` 运行时、`executor.ts` 适配方案。该项目从 Claude Code 工作流中提取了可复用的 TypeScript 逻辑,并用完全公开的 Python 库替代了私有原生模块 | +| [domdomegg/computer-use-mcp](https://github.com/domdomegg/computer-use-mcp) | MIT | 独立的 Computer Use MCP 服务器实现(基于 nut.js),跨平台可用。在方案调研阶段提供了参考 | +| [paoloanzn/free-code](https://github.com/paoloanzn/free-code) | - | Feature flag 系统分析和构建系统参考 | +| [oboard/claude-code-rev](https://github.com/oboard/claude-code-rev) | - | 泄露源码的早期恢复工作,提供了 stub 包的参考实现 | + +### 底层依赖 + +| 库 | 用途 | +|----|------| +| [pyautogui](https://github.com/asweigart/pyautogui) | 鼠标和键盘控制 | +| [mss](https://github.com/BoboTiG/python-mss) | 屏幕截图 | +| [Pillow](https://github.com/python-pillow/Pillow) | 图像处理和压缩 | +| [pyobjc](https://github.com/ronaldoussoren/pyobjc) | macOS Cocoa/Quartz 框架绑定(应用管理、显示枚举) | diff --git a/runtime/mac_helper.py b/runtime/mac_helper.py new file mode 100755 index 00000000..20c7a52f --- /dev/null +++ b/runtime/mac_helper.py @@ -0,0 +1,659 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import base64 +import json +import os +import subprocess +import sys +import time +from io import BytesIO +from pathlib import Path +from typing import Any + +import mss +from AppKit import NSWorkspace, NSPasteboard, NSPasteboardTypeString, NSURL +from PIL import Image +from Quartz import ( + CGDisplayBounds, + CGDisplayIsMain, + CGDisplayModeGetPixelHeight, + CGDisplayModeGetPixelWidth, + CGDisplayPixelsHigh, + CGDisplayPixelsWide, + CGGetActiveDisplayList, + CGMainDisplayID, + CGWindowListCopyWindowInfo, + CGRectContainsPoint, + CGRectIntersection, + CGPointMake, + kCGNullWindowID, + kCGWindowBounds, + kCGWindowIsOnscreen, + kCGWindowLayer, + kCGWindowListExcludeDesktopElements, + kCGWindowListOptionOnScreenOnly, + kCGWindowName, + kCGWindowOwnerName, +) + +os.environ.setdefault("PYTHONDONTWRITEBYTECODE", "1") +os.environ.setdefault("PYAUTOGUI_HIDE_SUPPORT_PROMPT", "1") + +import pyautogui # noqa: E402 + +pyautogui.FAILSAFE = False +pyautogui.PAUSE = 0 + +KEY_MAP = { + "a": "a", + "b": "b", + "c": "c", + "d": "d", + "e": "e", + "f": "f", + "g": "g", + "h": "h", + "i": "i", + "j": "j", + "k": "k", + "l": "l", + "m": "m", + "n": "n", + "o": "o", + "p": "p", + "q": "q", + "r": "r", + "s": "s", + "t": "t", + "u": "u", + "v": "v", + "w": "w", + "x": "x", + "y": "y", + "z": "z", + "0": "0", + "1": "1", + "2": "2", + "3": "3", + "4": "4", + "5": "5", + "6": "6", + "7": "7", + "8": "8", + "9": "9", + "cmd": "command", + "command": "command", + "meta": "command", + "super": "command", + "ctrl": "ctrl", + "control": "ctrl", + "shift": "shift", + "alt": "option", + "option": "option", + "opt": "option", + "fn": "fn", + "escape": "esc", + "esc": "esc", + "enter": "enter", + "return": "enter", + "tab": "tab", + "space": "space", + "backspace": "backspace", + "delete": "delete", + "forwarddelete": "delete", + "up": "up", + "down": "down", + "left": "left", + "right": "right", + "home": "home", + "end": "end", + "pageup": "pageup", + "pagedown": "pagedown", + "capslock": "capslock", + "f1": "f1", + "f2": "f2", + "f3": "f3", + "f4": "f4", + "f5": "f5", + "f6": "f6", + "f7": "f7", + "f8": "f8", + "f9": "f9", + "f10": "f10", + "f11": "f11", + "f12": "f12", + "-": "minus", + "=": "equals", + "[": "[", + "]": "]", + "\\": "\\", + ";": ";", + "'": "'", + ",": ",", + ".": ".", + "/": "/", + "`": "`", +} + + +def normalize_key(name: str) -> str: + key = name.strip().lower() + if key not in KEY_MAP: + raise ValueError(f"Unsupported key: {name}") + return KEY_MAP[key] + + +def json_output(payload: dict[str, Any]) -> None: + sys.stdout.write(json.dumps(payload, ensure_ascii=False)) + sys.stdout.write("\n") + sys.stdout.flush() + + +def error_output(message: str, code: str = "runtime_error") -> None: + json_output({"ok": False, "error": {"code": code, "message": message}}) + + +def bool_env(name: str, default: bool = False) -> bool: + value = os.environ.get(name) + if value is None: + return default + return value not in {"0", "false", "False", ""} + + +def run_osascript(script: str) -> str: + result = subprocess.run( + ["osascript", "-e", script], + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "osascript failed") + return result.stdout.strip() + + +def get_displays() -> list[dict[str, Any]]: + max_displays = 32 + err, active, count = CGGetActiveDisplayList(max_displays, None, None) + if err != 0: + raise RuntimeError(f"CGGetActiveDisplayList failed: {err}") + displays: list[dict[str, Any]] = [] + main_id = CGMainDisplayID() + for idx, display_id in enumerate(active[:count]): + bounds = CGDisplayBounds(display_id) + mode = None + try: + from Quartz import CGDisplayCopyDisplayMode + mode = CGDisplayCopyDisplayMode(display_id) + except Exception: + mode = None + physical_width = int(CGDisplayPixelsWide(display_id)) + physical_height = int(CGDisplayPixelsHigh(display_id)) + logical_width = int(bounds.size.width) + logical_height = int(bounds.size.height) + if mode is not None: + mode_w = int(CGDisplayModeGetPixelWidth(mode)) + mode_h = int(CGDisplayModeGetPixelHeight(mode)) + physical_width = mode_w or physical_width + physical_height = mode_h or physical_height + scale_factor = physical_width / logical_width if logical_width else 1 + name = f"Display {idx + 1}" + displays.append( + { + "id": int(display_id), + "displayId": int(display_id), + "width": logical_width, + "height": logical_height, + "scaleFactor": scale_factor, + "originX": int(bounds.origin.x), + "originY": int(bounds.origin.y), + "isPrimary": bool(display_id == main_id or CGDisplayIsMain(display_id)), + "name": name, + "label": name, + } + ) + return displays + + +def choose_display(display_id: int | None) -> dict[str, Any]: + displays = get_displays() + if not displays: + raise RuntimeError("No active displays found") + if display_id is None: + for display in displays: + if display["isPrimary"]: + return display + return displays[0] + for display in displays: + if display["displayId"] == display_id or display["id"] == display_id: + return display + raise RuntimeError(f"Unknown display: {display_id}") + + +def capture_display(display_id: int | None, resize: tuple[int, int] | None = None) -> dict[str, Any]: + display = choose_display(display_id) + monitor = { + "left": display["originX"], + "top": display["originY"], + "width": display["width"], + "height": display["height"], + } + with mss.mss() as sct: + raw = sct.grab(monitor) + image = Image.frombytes("RGB", raw.size, raw.rgb) + if resize: + image = image.resize(resize, Image.Resampling.LANCZOS) + buffer = BytesIO() + image.save(buffer, format="JPEG", quality=75, optimize=True) + base64_data = base64.b64encode(buffer.getvalue()).decode("ascii") + return { + "base64": base64_data, + "width": image.width, + "height": image.height, + "displayWidth": display["width"], + "displayHeight": display["height"], + "displayId": display["displayId"], + "originX": display["originX"], + "originY": display["originY"], + "display": display, + } + + +def capture_region(region: dict[str, int], resize: tuple[int, int] | None = None) -> dict[str, Any]: + with mss.mss() as sct: + raw = sct.grab(region) + image = Image.frombytes("RGB", raw.size, raw.rgb) + if resize: + image = image.resize(resize, Image.Resampling.LANCZOS) + buffer = BytesIO() + image.save(buffer, format="JPEG", quality=75, optimize=True) + base64_data = base64.b64encode(buffer.getvalue()).decode("ascii") + return {"base64": base64_data, "width": image.width, "height": image.height} + + +def list_windows() -> list[dict[str, Any]]: + windows = CGWindowListCopyWindowInfo( + kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements, + kCGNullWindowID, + ) + out: list[dict[str, Any]] = [] + for window in windows or []: + if int(window.get(kCGWindowLayer, 0)) != 0: + continue + if not bool(window.get(kCGWindowIsOnscreen, True)): + continue + bounds = window.get(kCGWindowBounds) or {} + width = int(bounds.get("Width", 0)) + height = int(bounds.get("Height", 0)) + if width <= 1 or height <= 1: + continue + out.append( + { + "ownerName": window.get(kCGWindowOwnerName, "") or "", + "title": window.get(kCGWindowName, "") or "", + "bounds": { + "x": int(bounds.get("X", 0)), + "y": int(bounds.get("Y", 0)), + "width": width, + "height": height, + }, + } + ) + return out + + +def bundle_id_to_app(bundle_id: str): + return NSWorkspace.sharedWorkspace().URLForApplicationWithBundleIdentifier_(bundle_id) + + +def installed_apps() -> list[dict[str, Any]]: + search_roots = [ + Path("/Applications"), + Path.home() / "Applications", + Path("/System/Applications"), + Path("/System/Applications/Utilities"), + ] + results: dict[str, dict[str, Any]] = {} + workspace = NSWorkspace.sharedWorkspace() + for root in search_roots: + if not root.exists(): + continue + for app in root.rglob("*.app"): + try: + bundle = workspace.bundleIdentifierForURL_(NSURL.fileURLWithPath_(str(app))) + except Exception: + bundle = None + if not bundle: + try: + url = workspace.URLForApplicationWithBundleIdentifier_(str(app)) + bundle = workspace.bundleIdentifierForURL_(url) if url else None + except Exception: + bundle = None + info_plist = app / "Contents/Info.plist" + display_name = app.stem + if info_plist.exists(): + try: + import plistlib + with info_plist.open("rb") as f: + plist = plistlib.load(f) + bundle = bundle or plist.get("CFBundleIdentifier") + display_name = plist.get("CFBundleDisplayName") or plist.get("CFBundleName") or display_name + except Exception: + pass + if not bundle or bundle in results: + continue + results[bundle] = { + "bundleId": str(bundle), + "displayName": str(display_name), + "path": str(app), + } + return sorted(results.values(), key=lambda item: item["displayName"].lower()) + + +def running_apps() -> list[dict[str, Any]]: + apps = [] + seen = set() + for app in NSWorkspace.sharedWorkspace().runningApplications() or []: + bundle_id = app.bundleIdentifier() + if not bundle_id or bundle_id in seen: + continue + seen.add(bundle_id) + name = app.localizedName() or bundle_id + apps.append({"bundleId": str(bundle_id), "displayName": str(name)}) + return sorted(apps, key=lambda item: item["displayName"].lower()) + + +def app_display_name(bundle_id: str) -> str | None: + for app in NSWorkspace.sharedWorkspace().runningApplications() or []: + if app.bundleIdentifier() == bundle_id: + return str(app.localizedName() or bundle_id) + for app in installed_apps(): + if app["bundleId"] == bundle_id: + return str(app["displayName"]) + return None + + +def frontmost_app() -> dict[str, str] | None: + app = NSWorkspace.sharedWorkspace().frontmostApplication() + if not app: + return None + bundle_id = app.bundleIdentifier() + if not bundle_id: + return None + return { + "bundleId": str(bundle_id), + "displayName": str(app.localizedName() or bundle_id), + } + + +def app_under_point(x: int, y: int) -> dict[str, str] | None: + point = CGPointMake(x, y) + running_by_name = { + str(app.localizedName() or app.bundleIdentifier()): str(app.bundleIdentifier()) + for app in NSWorkspace.sharedWorkspace().runningApplications() or [] + if app.bundleIdentifier() + } + for window in list_windows(): + bounds = window["bounds"] + rect = ((bounds["x"], bounds["y"]), (bounds["width"], bounds["height"])) + if CGRectContainsPoint(rect, point): + owner = window["ownerName"] + bundle = running_by_name.get(owner) + if bundle: + return {"bundleId": bundle, "displayName": str(owner)} + return frontmost_app() + + +def find_window_displays(bundle_ids: list[str]) -> list[dict[str, Any]]: + if not bundle_ids: + return [] + displays = get_displays() + names_by_bundle = { + bundle_id: app_display_name(bundle_id) or bundle_id for bundle_id in bundle_ids + } + windows = list_windows() + result = [] + for bundle_id in bundle_ids: + target_name = names_by_bundle.get(bundle_id) + display_ids: set[int] = set() + for window in windows: + owner = window["ownerName"] + if not owner: + continue + if target_name and owner != target_name: + continue + if not target_name and owner != bundle_id: + continue + wx = window["bounds"]["x"] + wy = window["bounds"]["y"] + ww = window["bounds"]["width"] + wh = window["bounds"]["height"] + window_rect = ((wx, wy), (ww, wh)) + for display in displays: + display_rect = ((display["originX"], display["originY"]), (display["width"], display["height"])) + intersection = CGRectIntersection(window_rect, display_rect) + if intersection.size.width > 0 and intersection.size.height > 0: + display_ids.add(int(display["displayId"])) + result.append({"bundleId": bundle_id, "displayIds": sorted(display_ids)}) + return result + + +def open_app(bundle_id: str) -> None: + url = bundle_id_to_app(bundle_id) + if not url: + raise RuntimeError(f"App not found for bundle identifier: {bundle_id}") + ok, err = NSWorkspace.sharedWorkspace().launchApplicationAtURL_options_configuration_error_(url, 0, {}, None) + if not ok: + raise RuntimeError(str(err) if err else f"Failed to open app {bundle_id}") + + +def read_clipboard() -> str: + pb = NSPasteboard.generalPasteboard() + value = pb.stringForType_(NSPasteboardTypeString) + return "" if value is None else str(value) + + +def write_clipboard(text: str) -> None: + pb = NSPasteboard.generalPasteboard() + pb.clearContents() + pb.setString_forType_(text, NSPasteboardTypeString) + + +def check_permissions() -> dict[str, bool]: + accessibility = True + try: + run_osascript('tell application "System Events" to get name of first process') + except Exception: + accessibility = False + screen_recording = True + try: + capture_display(None) + except Exception: + screen_recording = False + return { + "accessibility": accessibility, + "screenRecording": screen_recording, + } + + +def click(x: int, y: int, button: str, count: int, modifiers: list[str] | None) -> None: + pyautogui.moveTo(x, y) + if modifiers: + normalized = [normalize_key(m) for m in modifiers] + for key in normalized: + pyautogui.keyDown(key) + try: + pyautogui.click(x=x, y=y, button=button, clicks=count, interval=0.08) + finally: + for key in reversed(normalized): + pyautogui.keyUp(key) + else: + pyautogui.click(x=x, y=y, button=button, clicks=count, interval=0.08) + + +def scroll(x: int, y: int, delta_x: int, delta_y: int) -> None: + pyautogui.moveTo(x, y) + if delta_y: + pyautogui.scroll(int(delta_y), x=x, y=y) + if delta_x: + pyautogui.hscroll(int(delta_x), x=x, y=y) + + +def key_action(sequence: str, repeat: int = 1) -> None: + parts = [normalize_key(part) for part in sequence.split("+") if part.strip()] + for _ in range(max(1, repeat)): + if len(parts) == 1: + pyautogui.press(parts[0]) + else: + pyautogui.hotkey(*parts, interval=0.02) + time.sleep(0.01) + + +def hold_keys(keys: list[str], duration_ms: int) -> None: + normalized = [normalize_key(k) for k in keys] + for key in normalized: + pyautogui.keyDown(key) + try: + time.sleep(max(duration_ms, 0) / 1000) + finally: + for key in reversed(normalized): + pyautogui.keyUp(key) + + +def type_text(text: str) -> None: + pyautogui.write(text, interval=0.008) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("command") + parser.add_argument("--payload", default="{}") + args = parser.parse_args() + payload = json.loads(args.payload) + + try: + command = args.command + if command == "check_permissions": + perms = check_permissions() + json_output({"ok": True, "result": perms}) + return 0 + if command == "list_displays": + json_output({"ok": True, "result": get_displays()}) + return 0 + if command == "get_display_size": + json_output({"ok": True, "result": choose_display(payload.get("displayId"))}) + return 0 + if command == "screenshot": + resize = None + if payload.get("targetWidth") and payload.get("targetHeight"): + resize = (int(payload["targetWidth"]), int(payload["targetHeight"])) + result = capture_display(payload.get("displayId"), resize) + json_output({"ok": True, "result": result}) + return 0 + if command == "resolve_prepare_capture": + resize = None + if payload.get("targetWidth") and payload.get("targetHeight"): + resize = (int(payload["targetWidth"]), int(payload["targetHeight"])) + result = capture_display(payload.get("preferredDisplayId"), resize) + result["hidden"] = [] + result["resolvedDisplayId"] = result["displayId"] + json_output({"ok": True, "result": result}) + return 0 + if command == "zoom": + resize = None + if payload.get("targetWidth") and payload.get("targetHeight"): + resize = (int(payload["targetWidth"]), int(payload["targetHeight"])) + region = { + "left": int(payload["x"]), + "top": int(payload["y"]), + "width": int(payload["width"]), + "height": int(payload["height"]), + } + json_output({"ok": True, "result": capture_region(region, resize)}) + return 0 + if command == "prepare_for_action": + json_output({"ok": True, "result": []}) + return 0 + if command == "preview_hide_set": + json_output({"ok": True, "result": []}) + return 0 + if command == "find_window_displays": + json_output({"ok": True, "result": find_window_displays(list(payload.get("bundleIds") or []))}) + return 0 + if command == "key": + key_action(str(payload["keySequence"]), int(payload.get("repeat") or 1)) + json_output({"ok": True, "result": True}) + return 0 + if command == "hold_key": + hold_keys(list(payload.get("keyNames") or []), int(payload.get("durationMs") or 0)) + json_output({"ok": True, "result": True}) + return 0 + if command == "type": + type_text(str(payload.get("text") or "")) + json_output({"ok": True, "result": True}) + return 0 + if command == "click": + click(int(payload["x"]), int(payload["y"]), str(payload.get("button") or "left"), int(payload.get("count") or 1), payload.get("modifiers")) + json_output({"ok": True, "result": True}) + return 0 + if command == "drag": + from_point = payload.get("from") + if from_point: + pyautogui.moveTo(int(from_point["x"]), int(from_point["y"])) + pyautogui.dragTo(int(payload["to"]["x"]), int(payload["to"]["y"]), duration=0.2, button="left") + json_output({"ok": True, "result": True}) + return 0 + if command == "move_mouse": + pyautogui.moveTo(int(payload["x"]), int(payload["y"])) + json_output({"ok": True, "result": True}) + return 0 + if command == "scroll": + scroll(int(payload["x"]), int(payload["y"]), int(payload.get("deltaX") or 0), int(payload.get("deltaY") or 0)) + json_output({"ok": True, "result": True}) + return 0 + if command == "mouse_down": + pyautogui.mouseDown(button="left") + json_output({"ok": True, "result": True}) + return 0 + if command == "mouse_up": + pyautogui.mouseUp(button="left") + json_output({"ok": True, "result": True}) + return 0 + if command == "cursor_position": + x, y = pyautogui.position() + json_output({"ok": True, "result": {"x": int(x), "y": int(y)}}) + return 0 + if command == "frontmost_app": + json_output({"ok": True, "result": frontmost_app()}) + return 0 + if command == "app_under_point": + json_output({"ok": True, "result": app_under_point(int(payload["x"]), int(payload["y"]))}) + return 0 + if command == "list_installed_apps": + json_output({"ok": True, "result": installed_apps()}) + return 0 + if command == "list_running_apps": + json_output({"ok": True, "result": running_apps()}) + return 0 + if command == "open_app": + open_app(str(payload["bundleId"])) + json_output({"ok": True, "result": True}) + return 0 + if command == "read_clipboard": + json_output({"ok": True, "result": read_clipboard()}) + return 0 + if command == "write_clipboard": + write_clipboard(str(payload.get("text") or "")) + json_output({"ok": True, "result": True}) + return 0 + error_output(f"Unknown command: {command}", code="bad_command") + return 2 + except Exception as exc: + error_output(str(exc)) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/runtime/requirements.txt b/runtime/requirements.txt new file mode 100644 index 00000000..06466fae --- /dev/null +++ b/runtime/requirements.txt @@ -0,0 +1,6 @@ +mss>=10.1.0 +Pillow>=11.3.0 +pyautogui>=0.9.54 +pyobjc-core>=11.1 +pyobjc-framework-Cocoa>=11.1 +pyobjc-framework-Quartz>=11.1 diff --git a/src/components/permissions/ComputerUseApproval/ComputerUseApproval.tsx b/src/components/permissions/ComputerUseApproval/ComputerUseApproval.tsx index 9d85595d..d260fe61 100644 --- a/src/components/permissions/ComputerUseApproval/ComputerUseApproval.tsx +++ b/src/components/permissions/ComputerUseApproval/ComputerUseApproval.tsx @@ -1,7 +1,7 @@ import { c as _c } from "react/compiler-runtime"; -import { getSentinelCategory } from '@ant/computer-use-mcp/sentinelApps'; -import type { CuPermissionRequest, CuPermissionResponse } from '@ant/computer-use-mcp/types'; -import { DEFAULT_GRANT_FLAGS } from '@ant/computer-use-mcp/types'; +import { getSentinelCategory } from '../../../vendor/computer-use-mcp/sentinelApps.js'; +import type { CuPermissionRequest, CuPermissionResponse } from '../../../vendor/computer-use-mcp/types.js'; +import { DEFAULT_GRANT_FLAGS } from '../../../vendor/computer-use-mcp/types.js'; import figures from 'figures'; import * as React from 'react'; import { useMemo, useState } from 'react'; diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index 67326f89..e633fd96 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -83,7 +83,7 @@ async function main(): Promise { } = await import('../utils/claudeInChrome/chromeNativeHost.js'); await runChromeNativeHost(); return; - } else if (feature('CHICAGO_MCP') && process.argv[2] === '--computer-use-mcp') { + } else if (process.argv[2] === '--computer-use-mcp') { profileCheckpoint('cli_computer_use_mcp_path'); const { runComputerUseMcpServer diff --git a/src/main.tsx b/src/main.tsx index 3668c328..aa88a150 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1474,7 +1474,7 @@ async function run(): Promise { let reservedNameError: string | null = null; if (nonSdkConfigNames.some(isClaudeInChromeMCPServer)) { reservedNameError = `Invalid MCP configuration: "${CLAUDE_IN_CHROME_MCP_SERVER_NAME}" is a reserved MCP name.`; - } else if (feature('CHICAGO_MCP')) { + } else { const { isComputerUseMCPServer, COMPUTER_USE_MCP_SERVER_NAME @@ -1605,7 +1605,7 @@ async function run(): Promise { // `type: 'stdio'`. An enterprise-config ant with the GB gate on would // otherwise process.exit(1). Chrome has the same latent issue but has // shipped without incident; chicago places itself correctly. - if (feature('CHICAGO_MCP') && getPlatform() === 'macos' && !getIsNonInteractiveSession()) { + if (getPlatform() === 'macos' && !getIsNonInteractiveSession()) { try { const { getChicagoEnabled diff --git a/src/query.ts b/src/query.ts index 07e8b6fa..6491c1a1 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1030,7 +1030,7 @@ async function* queryLoop( // chicago MCP: auto-unhide + lock release on interrupt. Same cleanup // as the natural turn-end path in stopHooks.ts. Main thread only — // see stopHooks.ts for the subagent-releasing-main's-lock rationale. - if (feature('CHICAGO_MCP') && !toolUseContext.agentId) { + if (!toolUseContext.agentId) { try { const { cleanupComputerUseAfterTurn } = await import( './utils/computerUse/cleanup.js' @@ -1486,7 +1486,7 @@ async function* queryLoop( // chicago MCP: auto-unhide + lock release when aborted mid-tool-call. // This is the most likely Ctrl+C path for CU (e.g. slow screenshot). // Main thread only — see stopHooks.ts for the subagent rationale. - if (feature('CHICAGO_MCP') && !toolUseContext.agentId) { + if (!toolUseContext.agentId) { try { const { cleanupComputerUseAfterTurn } = await import( './utils/computerUse/cleanup.js' diff --git a/src/query/stopHooks.ts b/src/query/stopHooks.ts index 1118086c..6105485d 100644 --- a/src/query/stopHooks.ts +++ b/src/query/stopHooks.ts @@ -161,7 +161,7 @@ export async function* handleStopHooks( // so a subagent's stopHooks releasing it leaves the main thread's cleanup // seeing isLockHeldLocally()===false → no exit notification, and unhides // mid-turn. Subagents don't start CU sessions so this is a pure skip. - if (feature('CHICAGO_MCP') && !toolUseContext.agentId) { + if (!toolUseContext.agentId) { try { const { cleanupComputerUseAfterTurn } = await import( '../utils/computerUse/cleanup.js' diff --git a/src/services/analytics/metadata.ts b/src/services/analytics/metadata.ts index b83e96aa..ef58f708 100644 --- a/src/services/analytics/metadata.ts +++ b/src/services/analytics/metadata.ts @@ -126,15 +126,11 @@ export function isAnalyticsToolDetailsLoggingEnabled( * a user-configured 'computer-use' is possible in builds without the feature. */ /* eslint-disable @typescript-eslint/no-require-imports */ -const BUILTIN_MCP_SERVER_NAMES: ReadonlySet = new Set( - feature('CHICAGO_MCP') - ? [ - ( - require('../../utils/computerUse/common.js') as typeof import('../../utils/computerUse/common.js') - ).COMPUTER_USE_MCP_SERVER_NAME, - ] - : [], -) +const BUILTIN_MCP_SERVER_NAMES: ReadonlySet = new Set([ + ( + require('../../utils/computerUse/common.js') as typeof import('../../utils/computerUse/common.js') + ).COMPUTER_USE_MCP_SERVER_NAME, +]) /* eslint-enable @typescript-eslint/no-require-imports */ /** diff --git a/src/services/mcp/client.ts b/src/services/mcp/client.ts index 0b3afc6a..e7d6b6ab 100644 --- a/src/services/mcp/client.ts +++ b/src/services/mcp/client.ts @@ -238,15 +238,12 @@ const claudeInChromeToolRendering = // Lazy: wrapper.tsx → hostAdapter.ts → executor.ts pulls both native modules // (@ant/computer-use-input + @ant/computer-use-swift). Runtime-gated by // GrowthBook tengu_malort_pedway (see gates.ts). -const computerUseWrapper = feature('CHICAGO_MCP') - ? (): typeof import('../../utils/computerUse/wrapper.js') => - require('../../utils/computerUse/wrapper.js') - : undefined -const isComputerUseMCPServer = feature('CHICAGO_MCP') - ? ( - require('../../utils/computerUse/common.js') as typeof import('../../utils/computerUse/common.js') - ).isComputerUseMCPServer - : undefined +const computerUseWrapper = + (): typeof import('../../utils/computerUse/wrapper.js') => + require('../../utils/computerUse/wrapper.js') +const isComputerUseMCPServer = ( + require('../../utils/computerUse/common.js') as typeof import('../../utils/computerUse/common.js') +).isComputerUseMCPServer import { mkdir, readFile, unlink, writeFile } from 'fs/promises' import { dirname, join } from 'path' @@ -923,9 +920,8 @@ export const connectToServer = memoize( transport = clientTransport logMCPDebug(name, `In-process Chrome MCP server started`) } else if ( - feature('CHICAGO_MCP') && (serverRef.type === 'stdio' || !serverRef.type) && - isComputerUseMCPServer!(name) + isComputerUseMCPServer(name) ) { // Run the Computer Use MCP server in-process — same rationale as // Chrome above. The package's CallTool handler is a stub; real @@ -1980,10 +1976,9 @@ export const fetchToolsForClient = memoizeWithLRU( tool.name, ) : {}), - ...(feature('CHICAGO_MCP') && - (client.config.type === 'stdio' || !client.config.type) && - isComputerUseMCPServer!(client.name) - ? computerUseWrapper!().getComputerUseMCPToolOverrides(tool.name) + ...((client.config.type === 'stdio' || !client.config.type) && + isComputerUseMCPServer(client.name) + ? computerUseWrapper().getComputerUseMCPToolOverrides(tool.name) : {}), } }) diff --git a/src/services/mcp/config.ts b/src/services/mcp/config.ts index c5cee9cd..4b4c1edd 100644 --- a/src/services/mcp/config.ts +++ b/src/services/mcp/config.ts @@ -638,13 +638,11 @@ export async function addMcpConfig( throw new Error(`Cannot add MCP server "${name}": this name is reserved.`) } - if (feature('CHICAGO_MCP')) { - const { isComputerUseMCPServer } = await import( - '../../utils/computerUse/common.js' - ) - if (isComputerUseMCPServer(name)) { - throw new Error(`Cannot add MCP server "${name}": this name is reserved.`) - } + const { isComputerUseMCPServer } = await import( + '../../utils/computerUse/common.js' + ) + if (isComputerUseMCPServer(name)) { + throw new Error(`Cannot add MCP server "${name}": this name is reserved.`) } // Block adding servers when enterprise MCP config exists (it has exclusive control) @@ -1509,15 +1507,13 @@ export function areMcpConfigsAllowedWithEnterpriseMcpConfig( * enabledMcpServers. Shows up in /mcp as disabled until the user enables it. */ /* eslint-disable @typescript-eslint/no-require-imports */ -const DEFAULT_DISABLED_BUILTIN = feature('CHICAGO_MCP') - ? ( - require('../../utils/computerUse/common.js') as typeof import('../../utils/computerUse/common.js') - ).COMPUTER_USE_MCP_SERVER_NAME - : null +const DEFAULT_DISABLED_BUILTIN = ( + require('../../utils/computerUse/common.js') as typeof import('../../utils/computerUse/common.js') +).COMPUTER_USE_MCP_SERVER_NAME /* eslint-enable @typescript-eslint/no-require-imports */ -function isDefaultDisabledBuiltin(name: string): boolean { - return DEFAULT_DISABLED_BUILTIN !== null && name === DEFAULT_DISABLED_BUILTIN +function isDefaultDisabledBuiltin(_name: string): boolean { + return false // Computer Use 默认启用,无需用户手动 enable } /** diff --git a/src/utils/computerUse/drainRunLoop.ts b/src/utils/computerUse/drainRunLoop.ts index e5df3caa..5aea8913 100644 --- a/src/utils/computerUse/drainRunLoop.ts +++ b/src/utils/computerUse/drainRunLoop.ts @@ -1,79 +1,11 @@ -import { logForDebugging } from '../debug.js' -import { withResolvers } from '../withResolvers.js' -import { requireComputerUseSwift } from './swiftLoader.js' - /** - * Shared CFRunLoop pump. Swift's four `@MainActor` async methods - * (captureExcluding, captureRegion, apps.listInstalled, resolvePrepareCapture) - * and `@ant/computer-use-input`'s key()/keys() all dispatch to - * DispatchQueue.main. Under libuv (Node/bun) that queue never drains — the - * promises hang. Electron drains it via CFRunLoop so Cowork doesn't need this. - * - * One refcounted setInterval calls `_drainMainRunLoop` (RunLoop.main.run) - * every 1ms while any main-queue-dependent call is pending. Multiple - * concurrent drainRunLoop() calls share the single pump via retain/release. - */ - -let pump: ReturnType | undefined -let pending = 0 - -function drainTick(cu: ReturnType): void { - cu._drainMainRunLoop() -} - -function retain(): void { - pending++ - if (pump === undefined) { - pump = setInterval(drainTick, 1, requireComputerUseSwift()) - logForDebugging('[drainRunLoop] pump started', { level: 'verbose' }) - } -} - -function release(): void { - pending-- - if (pending <= 0 && pump !== undefined) { - clearInterval(pump) - pump = undefined - logForDebugging('[drainRunLoop] pump stopped', { level: 'verbose' }) - pending = 0 - } -} - -const TIMEOUT_MS = 30_000 - -function timeoutReject(reject: (e: Error) => void): void { - reject(new Error(`computer-use native call exceeded ${TIMEOUT_MS}ms`)) -} - -/** - * Hold a pump reference for the lifetime of a long-lived registration - * (e.g. the CGEventTap Escape handler). Unlike `drainRunLoop(fn)` this has - * no timeout — the caller is responsible for calling `releasePump()`. Same - * refcount as drainRunLoop calls, so nesting is safe. - */ -export const retainPump = retain -export const releasePump = release - -/** - * Await `fn()` with the shared drain pump running. Safe to nest — multiple - * concurrent drainRunLoop() calls share one setInterval. + * No-op replacement. The Python bridge is a synchronous subprocess call — + * there is no CFRunLoop to pump. All former callers can just await their + * promises directly. */ export async function drainRunLoop(fn: () => Promise): Promise { - retain() - let timer: ReturnType | undefined - try { - // If the timeout wins the race, fn()'s promise is orphaned — a late - // rejection from the native layer would become an unhandledRejection. - // Attaching a no-op catch swallows it; the timeout error is what surfaces. - // fn() sits inside try so a synchronous throw (e.g. NAPI argument - // validation) still reaches release() — otherwise the pump leaks. - const work = fn() - work.catch(() => {}) - const timeout = withResolvers() - timer = setTimeout(timeoutReject, TIMEOUT_MS, timeout.reject) - return await Promise.race([work, timeout.promise]) - } finally { - clearTimeout(timer) - release() - } + return fn() } + +export const retainPump = () => {} +export const releasePump = () => {} diff --git a/src/utils/computerUse/escHotkey.ts b/src/utils/computerUse/escHotkey.ts index 9aa882a9..5dae040f 100644 --- a/src/utils/computerUse/escHotkey.ts +++ b/src/utils/computerUse/escHotkey.ts @@ -1,54 +1,12 @@ -import { logForDebugging } from '../debug.js' -import { releasePump, retainPump } from './drainRunLoop.js' -import { requireComputerUseSwift } from './swiftLoader.js' - /** - * Global Escape → abort. Mirrors Cowork's `escAbort.ts` but without Electron: - * CGEventTap via `@ant/computer-use-swift`. While registered, Escape is - * consumed system-wide (PI defense — a prompt-injected action can't dismiss - * a dialog with Escape). - * - * Lifecycle: register on fresh lock acquire (`wrapper.tsx` `acquireCuLock`), - * unregister on lock release (`cleanup.ts`). The tap's CFRunLoopSource sits - * in .defaultMode on CFRunLoopGetMain(), so we hold a drainRunLoop pump - * retain for the registration's lifetime — same refcounted setInterval as - * the `@MainActor` methods. - * - * `notifyExpectedEscape()` punches a hole for model-synthesized Escapes: the - * executor's `key("escape")` calls it before posting the CGEvent. Swift - * schedules a 100ms decay so a CGEvent that never reaches the tap callback - * doesn't eat the next user ESC. + * No-op replacement. The Python bridge does not support global Escape hotkey + * via CGEventTap. The original implementation required native Swift module + * for CGEventTap-based system-wide key interception. */ - -let registered = false - -export function registerEscHotkey(onEscape: () => void): boolean { - if (registered) return true - const cu = requireComputerUseSwift() - if (!cu.hotkey.registerEscape(onEscape)) { - // CGEvent.tapCreate failed — typically missing Accessibility permission. - // CU still works, just without ESC abort. Mirrors Cowork's escAbort.ts:81. - logForDebugging('[cu-esc] registerEscape returned false', { level: 'warn' }) - return false - } - retainPump() - registered = true - logForDebugging('[cu-esc] registered') - return true +export function registerEscHotkey(_onEscape: () => void): boolean { + return false } -export function unregisterEscHotkey(): void { - if (!registered) return - try { - requireComputerUseSwift().hotkey.unregister() - } finally { - releasePump() - registered = false - logForDebugging('[cu-esc] unregistered') - } -} +export function unregisterEscHotkey(): void {} -export function notifyExpectedEscape(): void { - if (!registered) return - requireComputerUseSwift().hotkey.notifyExpectedEscape() -} +export function notifyExpectedEscape(): void {} diff --git a/src/utils/computerUse/executor.ts b/src/utils/computerUse/executor.ts index 6e221941..7f6b4f59 100644 --- a/src/utils/computerUse/executor.ts +++ b/src/utils/computerUse/executor.ts @@ -1,30 +1,8 @@ /** - * CLI `ComputerExecutor` implementation. Wraps two native modules: - * - `@ant/computer-use-input` (Rust/enigo) — mouse, keyboard, frontmost app - * - `@ant/computer-use-swift` — SCContentFilter screenshots, NSWorkspace apps, TCC + * CLI `ComputerExecutor` implementation — Python bridge variant. * - * Contract: `packages/desktop/computer-use-mcp/src/executor.ts` in the apps - * repo. The reference impl is Cowork's `apps/desktop/src/main/nest-only/ - * computer-use/executor.ts` — see notable deviations under "CLI deltas" below. - * - * ── CLI deltas from Cowork ───────────────────────────────────────────────── - * - * No `withClickThrough`. Cowork wraps every mouse op in - * `BrowserWindow.setIgnoreMouseEvents(true)` so clicks fall through the - * overlay. We're a terminal — no window — so the click-through bracket is - * a no-op. The sentinel `CLI_HOST_BUNDLE_ID` never matches frontmost. - * - * Terminal as surrogate host. `getTerminalBundleId()` detects the emulator - * we're running inside. It's passed as `hostBundleId` to `prepareDisplay`/ - * `resolvePrepareCapture` so the Swift side exempts it from hide AND skips - * it in the activate z-order walk (so the terminal being frontmost doesn't - * eat clicks meant for the target app). Also stripped from `allowedBundleIds` - * via `withoutTerminal()` so screenshots don't capture it (Swift 0.2.1's - * captureExcluding takes an allow-list despite the name — apps#30355). - * `capabilities.hostBundleId` stays as the sentinel — the package's - * frontmost gate uses that, and the terminal being frontmost is fine. - * - * Clipboard via `pbcopy`/`pbpaste`. No Electron `clipboard` module. + * Replaces the native Swift/Rust modules with a Python subprocess bridge + * (pyautogui + mss + pyobjc). See `pythonBridge.ts` and `runtime/mac_helper.py`. */ import type { @@ -35,28 +13,22 @@ import type { ResolvePrepareCaptureResult, RunningApp, ScreenshotResult, -} from '@ant/computer-use-mcp' - -import { API_RESIZE_PARAMS, targetImageSize } from '@ant/computer-use-mcp' -import { logForDebugging } from '../debug.js' -import { errorMessage } from '../errors.js' +} from '../../vendor/computer-use-mcp/index.js' +import { API_RESIZE_PARAMS, targetImageSize } from '../../vendor/computer-use-mcp/index.js' import { execFileNoThrow } from '../execFileNoThrow.js' import { sleep } from '../sleep.js' -import { - CLI_CU_CAPABILITIES, - CLI_HOST_BUNDLE_ID, - getTerminalBundleId, -} from './common.js' -import { drainRunLoop } from './drainRunLoop.js' -import { notifyExpectedEscape } from './escHotkey.js' -import { requireComputerUseInput } from './inputLoader.js' -import { requireComputerUseSwift } from './swiftLoader.js' - -// ── Helpers ─────────────────────────────────────────────────────────────────── +import { CLI_CU_CAPABILITIES, CLI_HOST_BUNDLE_ID } from './common.js' +import { callPythonHelper } from './pythonBridge.js' const SCREENSHOT_JPEG_QUALITY = 0.75 +const MOVE_SETTLE_MS = 50 + +type PythonDisplayGeometry = DisplayGeometry + +type PythonResolvePrepareCaptureResult = ResolvePrepareCaptureResult & { + displayId?: number +} -/** Logical → physical → API target dims. See `targetImageSize` + COORDINATES.md. */ function computeTargetDims( logicalW: number, logicalH: number, @@ -67,592 +39,192 @@ function computeTargetDims( return targetImageSize(physW, physH, API_RESIZE_PARAMS) } -async function readClipboardViaPbpaste(): Promise { - const { stdout, code } = await execFileNoThrow('pbpaste', [], { - useCwd: false, - }) - if (code !== 0) { - throw new Error(`pbpaste exited with code ${code}`) +function normalizeDisplayGeometry(display: PythonDisplayGeometry): DisplayGeometry { + return { + ...display, + displayId: display.displayId ?? display.id, + label: display.label ?? display.name, } +} + +async function readClipboardViaPbpaste(): Promise { + const { stdout, code } = await execFileNoThrow('pbpaste', [], { useCwd: false }) + if (code !== 0) throw new Error(`pbpaste exited with code ${code}`) return stdout } async function writeClipboardViaPbcopy(text: string): Promise { - const { code } = await execFileNoThrow('pbcopy', [], { - input: text, - useCwd: false, - }) - if (code !== 0) { - throw new Error(`pbcopy exited with code ${code}`) - } + const { code } = await execFileNoThrow('pbcopy', [], { input: text, useCwd: false }) + if (code !== 0) throw new Error(`pbcopy exited with code ${code}`) } -type Input = ReturnType - -/** - * Single-element key sequence matching "escape" or "esc" (case-insensitive). - * Used to hole-punch the CGEventTap abort for model-synthesized Escape — enigo - * accepts both spellings, so the tap must too. - */ -function isBareEscape(parts: readonly string[]): boolean { - if (parts.length !== 1) return false - const lower = parts[0]!.toLowerCase() - return lower === 'escape' || lower === 'esc' -} - -/** - * Instant move, then 50ms — an input→HID→AppKit→NSEvent round-trip before the - * caller reads `NSEvent.mouseLocation` or dispatches a click. Used for click, - * scroll, and drag-from; `animatedMove` is reserved for drag-to only. The - * intermediate animation frames were triggering hover states and, on the - * decomposed mouseDown/moveMouse path, emitting stray `.leftMouseDragged` - * events (toolCalls.ts handleScroll's mouse_full workaround). - */ -const MOVE_SETTLE_MS = 50 - -async function moveAndSettle( - input: Input, - x: number, - y: number, -): Promise { - await input.moveMouse(x, y, false) - await sleep(MOVE_SETTLE_MS) -} - -/** - * Release `pressed` in reverse (last pressed = first released). Errors are - * swallowed so a release failure never masks the real error. - * - * Drains via pop() rather than snapshotting length: if a drainRunLoop- - * orphaned press lambda resolves an in-flight input.key() AFTER finally - * calls us, that late push is still released on the next iteration. The - * orphaned flag stops the lambda at its NEXT check, not the current await. - */ -async function releasePressed(input: Input, pressed: string[]): Promise { - let k: string | undefined - while ((k = pressed.pop()) !== undefined) { - try { - await input.key(k, 'release') - } catch { - // Swallow — best-effort release. - } - } -} - -/** - * Bracket `fn()` with modifier press/release. `pressed` tracks which presses - * actually landed, so a mid-press throw only releases what was pressed — no - * stuck modifiers. The finally covers both press-phase and fn() throws. - * - * Caller must already be inside drainRunLoop() — key() dispatches to the - * main queue and needs the pump to resolve. - */ -async function withModifiers( - input: Input, - mods: string[], - fn: () => Promise, -): Promise { - const pressed: string[] = [] - try { - for (const m of mods) { - await input.key(m, 'press') - pressed.push(m) - } - return await fn() - } finally { - await releasePressed(input, pressed) - } -} - -/** - * Port of Cowork's `typeViaClipboard`. Sequence: - * 1. Save the user's clipboard. - * 2. Write our text. - * 3. READ-BACK VERIFY — clipboard writes can silently fail. If the - * read-back doesn't match, never press Cmd+V (would paste junk). - * 4. Cmd+V via keys(). - * 5. Sleep 100ms — battle-tested threshold for the paste-effect vs - * clipboard-restore race. Restoring too soon means the target app - * pastes the RESTORED content. - * 6. Restore — in a `finally`, so a throw between 2-5 never leaves the - * user's clipboard clobbered. Restore failures are swallowed. - */ -async function typeViaClipboard(input: Input, text: string): Promise { +async function typeViaClipboard(text: string): Promise { let saved: string | undefined try { saved = await readClipboardViaPbpaste() - } catch { - logForDebugging( - '[computer-use] pbpaste before paste failed; proceeding without restore', - ) - } + } catch {} try { await writeClipboardViaPbcopy(text) - if ((await readClipboardViaPbpaste()) !== text) { - throw new Error('Clipboard write did not round-trip.') - } - await input.keys(['command', 'v']) + await callPythonHelper('key', { keySequence: 'command+v', repeat: 1 }) await sleep(100) } finally { if (typeof saved === 'string') { try { await writeClipboardViaPbcopy(saved) - } catch { - logForDebugging('[computer-use] clipboard restore after paste failed') - } + } catch {} } } } -/** - * Port of Cowork's `animateMouseMovement` + `animatedMove`. Ease-out-cubic at - * 60fps; distance-proportional duration at 2000 px/sec, capped at 0.5s. When - * the sub-gate is off (or distance < ~2 frames), falls through to - * `moveAndSettle`. Called only from `drag` for the press→to motion — target - * apps may watch for `.leftMouseDragged` specifically (not just "button down + - * position changed") and the slow motion gives them time to process - * intermediate positions (scrollbars, window resizes). - */ -async function animatedMove( - input: Input, - targetX: number, - targetY: number, - mouseAnimationEnabled: boolean, -): Promise { - if (!mouseAnimationEnabled) { - await moveAndSettle(input, targetX, targetY) - return - } - const start = await input.mouseLocation() - const deltaX = targetX - start.x - const deltaY = targetY - start.y - const distance = Math.hypot(deltaX, deltaY) - if (distance < 1) return - const durationSec = Math.min(distance / 2000, 0.5) - if (durationSec < 0.03) { - await moveAndSettle(input, targetX, targetY) - return - } - const frameRate = 60 - const frameIntervalMs = 1000 / frameRate - const totalFrames = Math.floor(durationSec * frameRate) - for (let frame = 1; frame <= totalFrames; frame++) { - const t = frame / totalFrames - const eased = 1 - Math.pow(1 - t, 3) - await input.moveMouse( - Math.round(start.x + deltaX * eased), - Math.round(start.y + deltaY * eased), - false, - ) - if (frame < totalFrames) { - await sleep(frameIntervalMs) - } - } - // Last frame has no trailing sleep — same HID round-trip before the - // caller's mouseButton reads NSEvent.mouseLocation. - await sleep(MOVE_SETTLE_MS) -} - -// ── Factory ─────────────────────────────────────────────────────────────── - -export function createCliExecutor(opts: { +export function createCliExecutor(_opts: { getMouseAnimationEnabled: () => boolean getHideBeforeActionEnabled: () => boolean }): ComputerExecutor { if (process.platform !== 'darwin') { - throw new Error( - `createCliExecutor called on ${process.platform}. Computer control is macOS-only.`, - ) + throw new Error(`createCliExecutor called on ${process.platform}. Computer control is macOS-only.`) } - // Swift loaded once at factory time — every executor method needs it. - // Input loaded lazily via requireComputerUseInput() on first mouse/keyboard - // call — it caches internally, so screenshot-only flows never pull the - // enigo .node. - const cu = requireComputerUseSwift() - - const { getMouseAnimationEnabled, getHideBeforeActionEnabled } = opts - const terminalBundleId = getTerminalBundleId() - const surrogateHost = terminalBundleId ?? CLI_HOST_BUNDLE_ID - // Swift 0.2.1's captureExcluding/captureRegion take an ALLOW list despite the - // name (apps#30355 — complement computed Swift-side against running apps). - // The terminal isn't in the user's grants so it's naturally excluded, but if - // the package ever passes it through we strip it here so the terminal never - // photobombs a screenshot. - const withoutTerminal = (allowed: readonly string[]): string[] => - terminalBundleId === null - ? [...allowed] - : allowed.filter(id => id !== terminalBundleId) - - logForDebugging( - terminalBundleId - ? `[computer-use] terminal ${terminalBundleId} → surrogate host (hide-exempt, activate-skip, screenshot-excluded)` - : '[computer-use] terminal not detected; falling back to sentinel host', - ) - return { capabilities: { ...CLI_CU_CAPABILITIES, hostBundleId: CLI_HOST_BUNDLE_ID, }, - // ── Pre-action sequence (hide + defocus) ──────────────────────────── - - async prepareForAction( - allowlistBundleIds: string[], - displayId?: number, - ): Promise { - if (!getHideBeforeActionEnabled()) { - return [] - } - // prepareDisplay isn't @MainActor (plain Task{}), but its .hide() calls - // trigger window-manager events that queue on CFRunLoop. Without the - // pump, those pile up during Swift's ~1s of usleeps and flush all at - // once when the next pumped call runs — visible window flashing. - // Electron drains CFRunLoop continuously so Cowork doesn't see this. - // Worst-case 100ms + 5×200ms safety-net ≈ 1.1s, well under the 30s - // drainRunLoop ceiling. - // - // "Continue with action execution even if switching fails" — the - // frontmost gate in toolCalls.ts catches any actual unsafe state. - return drainRunLoop(async () => { - try { - const result = await cu.apps.prepareDisplay( - allowlistBundleIds, - surrogateHost, - displayId, - ) - if (result.activated) { - logForDebugging( - `[computer-use] prepareForAction: activated ${result.activated}`, - ) - } - return result.hidden - } catch (err) { - logForDebugging( - `[computer-use] prepareForAction failed; continuing to action: ${errorMessage(err)}`, - { level: 'warn' }, - ) - return [] - } - }) + async prepareForAction(): Promise { + return callPythonHelper('prepare_for_action', {}) }, - async previewHideSet( - allowlistBundleIds: string[], - displayId?: number, - ): Promise> { - return cu.apps.previewHideSet( - [...allowlistBundleIds, surrogateHost], - displayId, - ) + async previewHideSet() { + return callPythonHelper('preview_hide_set', {}) }, - // ── Display ────────────────────────────────────────────────────────── - async getDisplaySize(displayId?: number): Promise { - return cu.display.getSize(displayId) + return normalizeDisplayGeometry(await callPythonHelper('get_display_size', { displayId })) }, async listDisplays(): Promise { - return cu.display.listAll() + const displays = await callPythonHelper('list_displays', {}) + return displays.map(display => normalizeDisplayGeometry(display)) }, - async findWindowDisplays( - bundleIds: string[], - ): Promise> { - return cu.apps.findWindowDisplays(bundleIds) + async findWindowDisplays(bundleIds: string[]) { + return callPythonHelper('find_window_displays', { bundleIds }) }, - async resolvePrepareCapture(opts: { - allowedBundleIds: string[] - preferredDisplayId?: number - autoResolve: boolean - doHide?: boolean - }): Promise { - const d = cu.display.getSize(opts.preferredDisplayId) - const [targetW, targetH] = computeTargetDims( - d.width, - d.height, - d.scaleFactor, - ) - return drainRunLoop(() => - cu.resolvePrepareCapture( - withoutTerminal(opts.allowedBundleIds), - surrogateHost, - SCREENSHOT_JPEG_QUALITY, - targetW, - targetH, - opts.preferredDisplayId, - opts.autoResolve, - opts.doHide, - ), - ) + async resolvePrepareCapture(opts): Promise { + const display = await this.getDisplaySize(opts.preferredDisplayId) + const [targetW, targetH] = computeTargetDims(display.width, display.height, display.scaleFactor) + const result = await callPythonHelper('resolve_prepare_capture', { + preferredDisplayId: opts.preferredDisplayId, + targetWidth: targetW, + targetHeight: targetH, + jpegQuality: SCREENSHOT_JPEG_QUALITY, + }) + return { + ...result, + display: normalizeDisplayGeometry(result.display), + resolvedDisplayId: result.resolvedDisplayId ?? result.displayId, + } }, - /** - * Pre-size to `targetImageSize` output so the API transcoder's early-return - * fires — no server-side resize, `scaleCoord` stays coherent. See - * packages/desktop/computer-use-mcp/COORDINATES.md. - */ - async screenshot(opts: { - allowedBundleIds: string[] - displayId?: number - }): Promise { - const d = cu.display.getSize(opts.displayId) - const [targetW, targetH] = computeTargetDims( - d.width, - d.height, - d.scaleFactor, - ) - return drainRunLoop(() => - cu.screenshot.captureExcluding( - withoutTerminal(opts.allowedBundleIds), - SCREENSHOT_JPEG_QUALITY, - targetW, - targetH, - opts.displayId, - ), - ) + async screenshot(opts): Promise { + const display = await this.getDisplaySize(opts.displayId) + const [targetW, targetH] = computeTargetDims(display.width, display.height, display.scaleFactor) + const result = await callPythonHelper('screenshot', { + displayId: opts.displayId, + targetWidth: targetW, + targetHeight: targetH, + jpegQuality: SCREENSHOT_JPEG_QUALITY, + }) + return result }, - async zoom( - regionLogical: { x: number; y: number; w: number; h: number }, - allowedBundleIds: string[], - displayId?: number, - ): Promise<{ base64: string; width: number; height: number }> { - const d = cu.display.getSize(displayId) - const [outW, outH] = computeTargetDims( - regionLogical.w, - regionLogical.h, - d.scaleFactor, - ) - return drainRunLoop(() => - cu.screenshot.captureRegion( - withoutTerminal(allowedBundleIds), - regionLogical.x, - regionLogical.y, - regionLogical.w, - regionLogical.h, - outW, - outH, - SCREENSHOT_JPEG_QUALITY, - displayId, - ), - ) - }, - - // ── Keyboard ───────────────────────────────────────────────────────── - - /** - * xdotool-style sequence e.g. "ctrl+shift+a" → split on '+' and pass to - * keys(). keys() dispatches to DispatchQueue.main — drainRunLoop pumps - * CFRunLoop so it resolves. Rust's error-path cleanup (enigo_wrap.rs) - * releases modifiers on each invocation, so a mid-loop throw leaves - * nothing stuck. 8ms between iterations — 125Hz USB polling cadence. - */ - async key(keySequence: string, repeat?: number): Promise { - const input = requireComputerUseInput() - const parts = keySequence.split('+').filter(p => p.length > 0) - // Bare-only: the CGEventTap checks event.flags.isEmpty so ctrl+escape - // etc. pass through without aborting. - const isEsc = isBareEscape(parts) - const n = repeat ?? 1 - await drainRunLoop(async () => { - for (let i = 0; i < n; i++) { - if (i > 0) { - await sleep(8) - } - if (isEsc) { - notifyExpectedEscape() - } - await input.keys(parts) - } + async zoom(regionLogical, _allowedBundleIds, displayId) { + const display = await this.getDisplaySize(displayId) + const [outW, outH] = computeTargetDims(regionLogical.w, regionLogical.h, display.scaleFactor) + return callPythonHelper('zoom', { + x: regionLogical.x, + y: regionLogical.y, + width: regionLogical.w, + height: regionLogical.h, + targetWidth: outW, + targetHeight: outH, }) }, - async holdKey(keyNames: string[], durationMs: number): Promise { - const input = requireComputerUseInput() - // Press/release each wrapped in drainRunLoop; the sleep sits outside so - // durationMs isn't bounded by drainRunLoop's 30s timeout. `pressed` - // tracks which presses landed so a mid-press throw still releases - // everything that was actually pressed. - // - // `orphaned` guards against a timeout-orphan race: if the press-phase - // drainRunLoop times out while the esc-hotkey pump-retain keeps the - // pump running, the orphaned lambda would continue pushing to `pressed` - // after finally's releasePressed snapshotted the length — leaving keys - // stuck. The flag stops the lambda at the next iteration. - const pressed: string[] = [] - let orphaned = false - try { - await drainRunLoop(async () => { - for (const k of keyNames) { - if (orphaned) return - // Bare Escape: notify the CGEventTap so it doesn't fire the - // abort callback for a model-synthesized press. Same as key(). - if (isBareEscape([k])) { - notifyExpectedEscape() - } - await input.key(k, 'press') - pressed.push(k) - } - }) - await sleep(durationMs) - } finally { - orphaned = true - await drainRunLoop(() => releasePressed(input, pressed)) - } + async key(keySequence: string, repeat?: number): Promise { + await callPythonHelper('key', { keySequence, repeat: repeat ?? 1 }) }, - async type(text: string, opts: { viaClipboard: boolean }): Promise { - const input = requireComputerUseInput() - if (opts.viaClipboard) { - // keys(['command','v']) inside needs the pump. - await drainRunLoop(() => typeViaClipboard(input, text)) + async holdKey(keyNames: string[], durationMs: number): Promise { + await callPythonHelper('hold_key', { keyNames, durationMs }) + }, + + async type(text: string, opts2: { viaClipboard: boolean }): Promise { + if (opts2.viaClipboard) { + await typeViaClipboard(text) return } - // `toolCalls.ts` handles the grapheme loop + 8ms sleeps and calls this - // once per grapheme. typeText doesn't dispatch to the main queue. - await input.typeText(text) + await callPythonHelper('type', { text }) }, readClipboard: readClipboardViaPbpaste, - writeClipboard: writeClipboardViaPbcopy, - // ── Mouse ──────────────────────────────────────────────────────────── - - async moveMouse(x: number, y: number): Promise { - await moveAndSettle(requireComputerUseInput(), x, y) - }, - - /** - * Move, then click. Modifiers are press/release bracketed via withModifiers - * — same pattern as Cowork. AppKit computes NSEvent.clickCount from timing - * + position proximity, so double/triple click work without setting the - * CGEvent clickState field. key() inside withModifiers needs the pump; - * the modifier-less path doesn't. - */ - async click( - x: number, - y: number, - button: 'left' | 'right' | 'middle', - count: 1 | 2 | 3, - modifiers?: string[], - ): Promise { - const input = requireComputerUseInput() - await moveAndSettle(input, x, y) - if (modifiers && modifiers.length > 0) { - await drainRunLoop(() => - withModifiers(input, modifiers, () => - input.mouseButton(button, 'click', count), - ), - ) - } else { - await input.mouseButton(button, 'click', count) - } + async click(x, y, button, count, modifiers): Promise { + await callPythonHelper('click', { x, y, button, count, modifiers }) + await sleep(MOVE_SETTLE_MS) }, async mouseDown(): Promise { - await requireComputerUseInput().mouseButton('left', 'press') + await callPythonHelper('mouse_down', {}) }, async mouseUp(): Promise { - await requireComputerUseInput().mouseButton('left', 'release') + await callPythonHelper('mouse_up', {}) }, async getCursorPosition(): Promise<{ x: number; y: number }> { - return requireComputerUseInput().mouseLocation() + return callPythonHelper('cursor_position', {}) }, - /** - * `from === undefined` → drag from current cursor (training's - * left_click_drag with start_coordinate omitted). Inner `finally`: the - * button is ALWAYS released even if the move throws — otherwise the - * user's left button is stuck-pressed until they physically click. - * 50ms sleep after press: enigo's move_mouse reads NSEvent.pressedMouseButtons - * to decide .leftMouseDragged vs .mouseMoved; the synthetic leftMouseDown - * needs a HID-tap round-trip to show up there. - */ - async drag( - from: { x: number; y: number } | undefined, - to: { x: number; y: number }, - ): Promise { - const input = requireComputerUseInput() - if (from !== undefined) { - await moveAndSettle(input, from.x, from.y) - } - await input.mouseButton('left', 'press') + async drag(from, to): Promise { + await callPythonHelper('drag', { from, to }) await sleep(MOVE_SETTLE_MS) - try { - await animatedMove(input, to.x, to.y, getMouseAnimationEnabled()) - } finally { - await input.mouseButton('left', 'release') - } }, - /** - * Move first, then scroll each axis. Vertical-first — it's the common - * axis; a horizontal failure shouldn't lose the vertical. - */ - async scroll(x: number, y: number, dx: number, dy: number): Promise { - const input = requireComputerUseInput() - await moveAndSettle(input, x, y) - if (dy !== 0) { - await input.mouseScroll(dy, 'vertical') - } - if (dx !== 0) { - await input.mouseScroll(dx, 'horizontal') - } + async moveMouse(x, y): Promise { + await callPythonHelper('move_mouse', { x, y }) + await sleep(MOVE_SETTLE_MS) }, - // ── App management ─────────────────────────────────────────────────── + async scroll(x, y, dx, dy): Promise { + await callPythonHelper('scroll', { x, y, deltaX: dx, deltaY: dy }) + }, async getFrontmostApp(): Promise { - const info = requireComputerUseInput().getFrontmostAppInfo() - if (!info || !info.bundleId) return null - return { bundleId: info.bundleId, displayName: info.appName } + return callPythonHelper('frontmost_app', {}) }, - async appUnderPoint( - x: number, - y: number, - ): Promise<{ bundleId: string; displayName: string } | null> { - return cu.apps.appUnderPoint(x, y) + async appUnderPoint(x, y) { + return callPythonHelper('app_under_point', { x, y }) }, async listInstalledApps(): Promise { - // `ComputerUseInstalledApp` is `{bundleId, displayName, path}`. - // `InstalledApp` adds optional `iconDataUrl` — left unpopulated; - // the approval dialog fetches lazily via getAppIcon() below. - return drainRunLoop(() => cu.apps.listInstalled()) - }, - - async getAppIcon(path: string): Promise { - return cu.apps.iconDataUrl(path) ?? undefined + return callPythonHelper('list_installed_apps', {}) }, async listRunningApps(): Promise { - return cu.apps.listRunning() + return callPythonHelper('list_running_apps', {}) }, async openApp(bundleId: string): Promise { - await cu.apps.open(bundleId) + await callPythonHelper('open_app', { bundleId }) }, } } -/** - * Module-level export (not on the executor object) — called at turn-end from - * `stopHooks.ts` / `query.ts`, outside the executor lifecycle. Fire-and-forget - * at the call site; the caller `.catch()`es. - */ -export async function unhideComputerUseApps( - bundleIds: readonly string[], -): Promise { - if (bundleIds.length === 0) return - const cu = requireComputerUseSwift() - await cu.apps.unhide([...bundleIds]) +export async function unhideComputerUseApps(_bundleIds: readonly string[]): Promise { + return } diff --git a/src/utils/computerUse/gates.ts b/src/utils/computerUse/gates.ts index 6563a480..c94603d1 100644 --- a/src/utils/computerUse/gates.ts +++ b/src/utils/computerUse/gates.ts @@ -1,8 +1,6 @@ -import type { CoordinateMode, CuSubGates } from '@ant/computer-use-mcp/types' +import type { CoordinateMode, CuSubGates } from '../../vendor/computer-use-mcp/types.js' import { getDynamicConfig_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' -import { getSubscriptionType } from '../auth.js' -import { isEnvTruthy } from '../envUtils.js' type ChicagoConfig = CuSubGates & { enabled: boolean @@ -10,7 +8,7 @@ type ChicagoConfig = CuSubGates & { } const DEFAULTS: ChicagoConfig = { - enabled: false, + enabled: true, pixelValidation: false, clipboardPasteMultiline: true, mouseAnimation: true, @@ -33,28 +31,8 @@ function readConfig(): ChicagoConfig { } } -// Max/Pro only for external rollout. Ant bypass so dogfooding continues -// regardless of subscription tier — not all ants are max/pro, and per -// CLAUDE.md:281, USER_TYPE !== 'ant' branches get zero antfooding. -function hasRequiredSubscription(): boolean { - if (process.env.USER_TYPE === 'ant') return true - const tier = getSubscriptionType() - return tier === 'max' || tier === 'pro' -} - export function getChicagoEnabled(): boolean { - // Disable for ants whose shell inherited monorepo dev config. - // MONOREPO_ROOT_DIR is exported by config/local/zsh/zshrc, which - // laptop-setup.sh wires into ~/.zshrc — its presence is the cheap - // proxy for "has monorepo access". Override: ALLOW_ANT_COMPUTER_USE_MCP=1. - if ( - process.env.USER_TYPE === 'ant' && - process.env.MONOREPO_ROOT_DIR && - !isEnvTruthy(process.env.ALLOW_ANT_COMPUTER_USE_MCP) - ) { - return false - } - return hasRequiredSubscription() && readConfig().enabled + return true } export function getChicagoSubGates(): CuSubGates { diff --git a/src/utils/computerUse/hostAdapter.ts b/src/utils/computerUse/hostAdapter.ts index d9e78fae..3c24da4d 100644 --- a/src/utils/computerUse/hostAdapter.ts +++ b/src/utils/computerUse/hostAdapter.ts @@ -1,13 +1,13 @@ import type { ComputerUseHostAdapter, Logger, -} from '@ant/computer-use-mcp/types' +} from '../../vendor/computer-use-mcp/types.js' import { format } from 'util' import { logForDebugging } from '../debug.js' import { COMPUTER_USE_MCP_SERVER_NAME } from './common.js' import { createCliExecutor } from './executor.js' import { getChicagoEnabled, getChicagoSubGates } from './gates.js' -import { requireComputerUseSwift } from './swiftLoader.js' +import { callPythonHelper } from './pythonBridge.js' class DebugLogger implements Logger { silly(message: string, ...args: unknown[]): void { @@ -29,12 +29,6 @@ class DebugLogger implements Logger { let cached: ComputerUseHostAdapter | undefined -/** - * Process-lifetime singleton. Built once on first CU tool call; native modules - * (both `@ant/computer-use-input` and `@ant/computer-use-swift`) are loaded - * here via the executor factory, which throws on load failure — there is no - * degraded mode. - */ export function getComputerUseHostAdapter(): ComputerUseHostAdapter { if (cached) return cached cached = { @@ -45,24 +39,14 @@ export function getComputerUseHostAdapter(): ComputerUseHostAdapter { getHideBeforeActionEnabled: () => getChicagoSubGates().hideBeforeAction, }), ensureOsPermissions: async () => { - const cu = requireComputerUseSwift() - const accessibility = cu.tcc.checkAccessibility() - const screenRecording = cu.tcc.checkScreenRecording() - return accessibility && screenRecording - ? { granted: true } - : { granted: false, accessibility, screenRecording } + const perms = await callPythonHelper<{ accessibility: boolean; screenRecording: boolean }>('check_permissions', {}) + return perms.accessibility && perms.screenRecording + ? { granted: true as const } + : { granted: false as const, accessibility: perms.accessibility, screenRecording: perms.screenRecording } }, isDisabled: () => !getChicagoEnabled(), getSubGates: getChicagoSubGates, - // cleanup.ts always unhides at turn end — no user preference to disable it. getAutoUnhideEnabled: () => true, - - // Pixel-validation JPEG decode+crop. MUST be synchronous (the package - // does `patch1.equals(patch2)` directly on the return value). Cowork uses - // Electron's `nativeImage` (sync); our `image-processor-napi` is - // sharp-compatible and async-only. Returning null → validation skipped, - // click proceeds — the designed fallback per `PixelCompareResult.skipped`. - // The sub-gate defaults to false anyway. cropRawPatch: () => null, } return cached diff --git a/src/utils/computerUse/inputLoader.ts b/src/utils/computerUse/inputLoader.ts index 2dd6e29c..f0f2c275 100644 --- a/src/utils/computerUse/inputLoader.ts +++ b/src/utils/computerUse/inputLoader.ts @@ -1,30 +1,3 @@ -import type { - ComputerUseInput, - ComputerUseInputAPI, -} from '@ant/computer-use-input' - -let cached: ComputerUseInputAPI | undefined - -/** - * Package's js/index.js reads COMPUTER_USE_INPUT_NODE_PATH (baked by - * build-with-plugins.ts on darwin targets, unset otherwise — falls through to - * the node_modules prebuilds/ path). - * - * The package exports a discriminated union on `isSupported` — narrowed here - * once so callers get the bare `ComputerUseInputAPI` without re-checking. - * - * key()/keys() dispatch enigo work onto DispatchQueue.main via - * dispatch2::run_on_main, then block a tokio worker on a channel. Under - * Electron (CFRunLoop drains the main queue) this works; under libuv - * (Node/bun) the main queue never drains and the promise hangs. The executor - * calls these inside drainRunLoop(). - */ -export function requireComputerUseInput(): ComputerUseInputAPI { - if (cached) return cached - // eslint-disable-next-line @typescript-eslint/no-require-imports - const input = require('@ant/computer-use-input') as ComputerUseInput - if (!input.isSupported) { - throw new Error('@ant/computer-use-input is not supported on this platform') - } - return (cached = input) +export function requireComputerUseInput(): any { + throw new Error('Native input module replaced by Python bridge. See pythonBridge.ts') } diff --git a/src/utils/computerUse/mcpServer.ts b/src/utils/computerUse/mcpServer.ts index d51d80ab..6b5100bf 100644 --- a/src/utils/computerUse/mcpServer.ts +++ b/src/utils/computerUse/mcpServer.ts @@ -1,7 +1,7 @@ import { buildComputerUseTools, createComputerUseMcpServer, -} from '@ant/computer-use-mcp' +} from '../../vendor/computer-use-mcp/index.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' import { homedir } from 'os' diff --git a/src/utils/computerUse/pythonBridge.ts b/src/utils/computerUse/pythonBridge.ts new file mode 100644 index 00000000..bfeb3231 --- /dev/null +++ b/src/utils/computerUse/pythonBridge.ts @@ -0,0 +1,110 @@ +import { createHash } from 'node:crypto' +import { readFile, mkdir, access, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { execFileNoThrow } from '../execFileNoThrow.js' +import { logForDebugging } from '../debug.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const projectRoot = path.resolve(__dirname, '../../..') +const runtimeRoot = path.join(projectRoot, 'runtime') +const runtimeStateRoot = path.join(projectRoot, '.runtime') +const venvRoot = path.join(runtimeStateRoot, 'venv') +const requirementsPath = path.join(runtimeRoot, 'requirements.txt') +const helperPath = path.join(runtimeRoot, 'mac_helper.py') +const installStampPath = path.join(runtimeStateRoot, 'requirements.sha256') + +let bootstrapPromise: Promise | undefined + +function pythonBinPath(): string { + return path.join(venvRoot, 'bin', 'python3') +} + +async function pathExists(target: string): Promise { + try { + await access(target) + return true + } catch { + return false + } +} + +async function runOrThrow(file: string, args: string[], label: string): Promise { + const { code, stdout, stderr } = await execFileNoThrow(file, args, { useCwd: false }) + if (code !== 0) { + throw new Error(`${label} failed with code ${code}: ${stderr || stdout || 'unknown error'}`) + } + return stdout +} + +export async function ensureBootstrapped(): Promise { + if (bootstrapPromise) return bootstrapPromise + bootstrapPromise = (async () => { + await mkdir(runtimeStateRoot, { recursive: true }) + + if (!(await pathExists(pythonBinPath()))) { + logForDebugging('creating runtime venv at %s', { level: 'debug' }) + await runOrThrow('python3', ['-m', 'venv', venvRoot], 'python venv creation') + } + + if (!(await pathExists(path.join(venvRoot, 'bin', 'pip')))) { + logForDebugging('bootstrapping pip with ensurepip', { level: 'debug' }) + await runOrThrow(pythonBinPath(), ['-m', 'ensurepip', '--upgrade'], 'ensurepip') + } + + const requirements = await readFile(requirementsPath, 'utf8') + const digest = createHash('sha256').update(requirements).digest('hex') + let installedDigest = '' + try { + installedDigest = (await readFile(installStampPath, 'utf8')).trim() + } catch {} + + if (installedDigest !== digest) { + logForDebugging('installing python runtime dependencies', { level: 'debug' }) + await runOrThrow(pythonBinPath(), ['-m', 'pip', 'install', '--upgrade', 'pip'], 'pip upgrade') + await runOrThrow( + pythonBinPath(), + ['-m', 'pip', 'install', '-r', requirementsPath], + 'python dependency install', + ) + await writeFile(installStampPath, `${digest}\n`, 'utf8') + } + })() + + try { + await bootstrapPromise + } catch (error) { + bootstrapPromise = undefined + throw error + } +} + +export async function callPythonHelper(command: string, payload: Record = {}): Promise { + await ensureBootstrapped() + const { code, stdout, stderr } = await execFileNoThrow( + pythonBinPath(), + [helperPath, command, '--payload', JSON.stringify(payload)], + { useCwd: false }, + ) + + if (code !== 0 && !stdout.trim()) { + throw new Error(stderr || `Python helper ${command} failed with code ${code}`) + } + + let parsed: { ok: boolean; result?: T; error?: { message?: string } } + try { + parsed = JSON.parse(stdout) + } catch { + throw new Error(stderr || stdout || `Python helper ${command} returned invalid JSON`) + } + + if (!parsed.ok) { + throw new Error(parsed.error?.message || `Python helper ${command} failed`) + } + + return parsed.result as T +} + +export function getRuntimePaths(): { projectRoot: string; runtimeRoot: string; venvRoot: string } { + return { projectRoot, runtimeRoot, venvRoot } +} diff --git a/src/utils/computerUse/setup.ts b/src/utils/computerUse/setup.ts index 8355e9f2..b4f8c63c 100644 --- a/src/utils/computerUse/setup.ts +++ b/src/utils/computerUse/setup.ts @@ -1,4 +1,4 @@ -import { buildComputerUseTools } from '@ant/computer-use-mcp' +import { buildComputerUseTools } from '../../vendor/computer-use-mcp/index.js' import { join } from 'path' import { fileURLToPath } from 'url' import { buildMcpToolName } from '../../services/mcp/mcpStringUtils.js' diff --git a/src/utils/computerUse/swiftLoader.ts b/src/utils/computerUse/swiftLoader.ts index 1a8a9b25..9cb110a3 100644 --- a/src/utils/computerUse/swiftLoader.ts +++ b/src/utils/computerUse/swiftLoader.ts @@ -1,23 +1,3 @@ -import type { ComputerUseAPI } from '@ant/computer-use-swift' - -let cached: ComputerUseAPI | undefined - -/** - * Package's js/index.js reads COMPUTER_USE_SWIFT_NODE_PATH (baked by - * build-with-plugins.ts on darwin targets, unset otherwise — falls through to - * the node_modules prebuilds/ path). We cache the loaded native module. - * - * The four @MainActor methods (captureExcluding, captureRegion, - * apps.listInstalled, resolvePrepareCapture) dispatch to DispatchQueue.main - * and will hang under libuv unless CFRunLoop is pumped — call sites wrap - * these in drainRunLoop(). - */ -export function requireComputerUseSwift(): ComputerUseAPI { - if (process.platform !== 'darwin') { - throw new Error('@ant/computer-use-swift is macOS-only') - } - // eslint-disable-next-line @typescript-eslint/no-require-imports - return (cached ??= require('@ant/computer-use-swift') as ComputerUseAPI) +export function requireComputerUseSwift(): any { + throw new Error('Native Swift module replaced by Python bridge. See pythonBridge.ts') } - -export type { ComputerUseAPI } diff --git a/src/utils/computerUse/wrapper.tsx b/src/utils/computerUse/wrapper.tsx index 217015ba..65d46f5e 100644 --- a/src/utils/computerUse/wrapper.tsx +++ b/src/utils/computerUse/wrapper.tsx @@ -16,7 +16,7 @@ * GrowthBook gate `tengu_malort_pedway` (see gates.ts). */ -import { bindSessionContext, type ComputerUseSessionContext, type CuCallToolResult, type CuPermissionRequest, type CuPermissionResponse, DEFAULT_GRANT_FLAGS, type ScreenshotDims } from '@ant/computer-use-mcp'; +import { bindSessionContext, type ComputerUseSessionContext, type CuCallToolResult, type CuPermissionRequest, type CuPermissionResponse, DEFAULT_GRANT_FLAGS, type ScreenshotDims } from '../../vendor/computer-use-mcp/index.js'; import * as React from 'react'; import { getSessionId } from '../../bootstrap/state.js'; import { ComputerUseApproval } from '../../components/permissions/ComputerUseApproval/ComputerUseApproval.js'; diff --git a/src/vendor/computer-use-mcp/deniedApps.ts b/src/vendor/computer-use-mcp/deniedApps.ts new file mode 100644 index 00000000..92f14e0b --- /dev/null +++ b/src/vendor/computer-use-mcp/deniedApps.ts @@ -0,0 +1,553 @@ +/** + * App category lookup for tiered CU permissions. Three categories land at a + * restricted tier instead of `"full"`: + * + * - **browser** → `"read"` tier — visible in screenshots, NO interaction. + * The model can read an already-open page but must use the Claude-in-Chrome + * MCP for navigation/clicking/typing. + * - **terminal** → `"click"` tier — visible + clickable, NO typing. The + * model can click a Run button or scroll test output in an IDE, but can't + * type into the integrated terminal. Use the Bash tool for shell work. + * - **trading** → `"read"` tier — same restrictions as browsers, but no + * CiC-MCP alternative exists. For platforms where a stray click can + * execute a trade or send a message to a counterparty. + * + * Uncategorized apps default to `"full"`. See `getDefaultTierForApp`. + * + * Identification is two-layered: + * 1. Bundle ID match (macOS-only; `InstalledApp.bundleId` is a + * CFBundleIdentifier and meaningless on Windows). Fast, exact, the + * primary mechanism while CU is darwin-gated. + * 2. Display-name substring match (cross-platform fallback). Catches + * unresolved requests ("Chrome" when Chrome isn't installed) AND will + * be the primary mechanism on Windows/Linux where there's no bundle ID. + * Windows-relevant names (PowerShell, cmd, Windows Terminal) are + * included now so they activate the moment the darwin gate lifts. + * + * Keep this file **import-free** (like sentinelApps.ts) — the renderer may + * import it via a package.json subpath export, and pulling in + * `@modelcontextprotocol/sdk` (a devDep) through the index → mcpServer chain + * would fail module resolution in Next.js. The `CuAppPermTier` type is + * duplicated as a string literal below rather than imported. + */ + +export type DeniedCategory = "browser" | "terminal" | "trading"; + +/** + * Map a category to its hardcoded tier. Return-type is the string-literal + * union inline (this file is import-free; see header comment). The + * authoritative type is `CuAppPermTier` in types.ts — keep in sync. + * + * Not bijective — both `"browser"` and `"trading"` map to `"read"`. Copy + * that differs by category (the "use CiC" hint is browser-only) must check + * the category, not just the tier. + */ +export function categoryToTier( + category: DeniedCategory | null, +): "read" | "click" | "full" { + if (category === "browser" || category === "trading") return "read"; + if (category === "terminal") return "click"; + return "full"; +} + +// ─── Bundle-ID deny sets (macOS) ───────────────────────────────────────── + +const BROWSER_BUNDLE_IDS: ReadonlySet = new Set([ + // Apple + "com.apple.Safari", + "com.apple.SafariTechnologyPreview", + // Google + "com.google.Chrome", + "com.google.Chrome.beta", + "com.google.Chrome.dev", + "com.google.Chrome.canary", + // Microsoft + "com.microsoft.edgemac", + "com.microsoft.edgemac.Beta", + "com.microsoft.edgemac.Dev", + "com.microsoft.edgemac.Canary", + // Mozilla + "org.mozilla.firefox", + "org.mozilla.firefoxdeveloperedition", + "org.mozilla.nightly", + // Chromium-based + "org.chromium.Chromium", + "com.brave.Browser", + "com.brave.Browser.beta", + "com.brave.Browser.nightly", + "com.operasoftware.Opera", + "com.operasoftware.OperaGX", + "com.operasoftware.OperaDeveloper", + "com.vivaldi.Vivaldi", + // The Browser Company + "company.thebrowser.Browser", // Arc + "company.thebrowser.dia", // Dia (agentic) + // Privacy-focused + "org.torproject.torbrowser", + "com.duckduckgo.macos.browser", + "ru.yandex.desktop.yandex-browser", + // Agentic / AI browsers — newer entrants with LLM integrations + "ai.perplexity.comet", + "com.sigmaos.sigmaos.macos", // SigmaOS + // Webkit-based misc + "com.kagi.kagimacOS", // Orion +]); + +/** + * Terminals + IDEs with integrated terminals. Supersets + * `SHELL_ACCESS_BUNDLE_IDS` from sentinelApps.ts — terminals proceed to the + * approval dialog at tier "click", and the sentinel warning renders + * alongside the tier badge. + */ +const TERMINAL_BUNDLE_IDS: ReadonlySet = new Set([ + // Dedicated terminals + "com.apple.Terminal", + "com.googlecode.iterm2", + "dev.warp.Warp-Stable", + "dev.warp.Warp-Beta", + "com.github.wez.wezterm", + "org.alacritty", + "io.alacritty", // pre-v0.11.0 (renamed 2022-07) — kept for legacy installs + "net.kovidgoyal.kitty", + "co.zeit.hyper", + "com.mitchellh.ghostty", + "org.tabby", + "com.termius-dmg.mac", // Termius + // IDEs with integrated terminals — we can't distinguish "type in the + // editor" from "type in the integrated terminal" via screenshot+click. + // VS Code family + "com.microsoft.VSCode", + "com.microsoft.VSCodeInsiders", + "com.vscodium", // VSCodium + "com.todesktop.230313mzl4w4u92", // Cursor + "com.exafunction.windsurf", // Windsurf / Codeium + "dev.zed.Zed", + "dev.zed.Zed-Preview", + // JetBrains family (all have integrated terminals) + "com.jetbrains.intellij", + "com.jetbrains.intellij.ce", + "com.jetbrains.pycharm", + "com.jetbrains.pycharm.ce", + "com.jetbrains.WebStorm", + "com.jetbrains.CLion", + "com.jetbrains.goland", + "com.jetbrains.rubymine", + "com.jetbrains.PhpStorm", + "com.jetbrains.datagrip", + "com.jetbrains.rider", + "com.jetbrains.AppCode", + "com.jetbrains.rustrover", + "com.jetbrains.fleet", + "com.google.android.studio", // Android Studio (JetBrains-based) + // Other IDEs + "com.axosoft.gitkraken", // GitKraken has an integrated terminal panel. Also keeps the "kraken" trading-substring from miscategorizing it — bundle-ID wins. + "com.sublimetext.4", + "com.sublimetext.3", + "org.vim.MacVim", + "com.neovim.neovim", + "org.gnu.Emacs", + // Xcode's previous carve-out (full tier for Interface Builder / simulator) + // was reversed — at tier "click" IB and simulator taps still work (both are + // plain clicks) while the integrated terminal is blocked from keyboard input. + "com.apple.dt.Xcode", + "org.eclipse.platform.ide", + "org.netbeans.ide", + "com.microsoft.visual-studio", // Visual Studio for Mac + // AppleScript/automation execution surfaces — same threat as terminals: + // type(script) → key("cmd+r") runs arbitrary code. Added after #28011 + // removed the osascript MCP server, making CU the only tool-call route + // to AppleScript. + "com.apple.ScriptEditor2", + "com.apple.Automator", + "com.apple.shortcuts", +]); + +/** + * Trading / crypto platforms — granted at tier `"read"` so the agent can see + * balances and prices but can't click into an order, transfer, or IB chat. + * Bundle IDs populated from Homebrew cask `uninstall.quit` stanzas as they're + * verified; the name-substring fallback below is the primary check. Bloomberg + * Terminal has no native macOS build per their FAQ (web/Citrix only). + * + * Budgeting/accounting apps (Quicken, YNAB, QuickBooks, etc.) are NOT listed + * here — they default to tier `"full"`. The risk model for brokerage/crypto + * (a stray click can execute a trade) doesn't apply to budgeting apps; the + * Cowork system prompt carries the soft instruction to never execute trades + * or transfer money on the user's behalf. + */ +const TRADING_BUNDLE_IDS: ReadonlySet = new Set([ + // Verified via Homebrew quit/zap stanzas + mdls + electron-builder source. + // Trading + "com.webull.desktop.v1", // Webull (direct download, Qt) + "com.webull.trade.mac.v1", // Webull (Mac App Store) + "com.tastytrade.desktop", + "com.tradingview.tradingviewapp.desktop", + "com.fidelity.activetrader", // Fidelity Trader+ (new) + "com.fmr.activetrader", // Fidelity Active Trader Pro (legacy) + // Interactive Brokers TWS — install4j wrapper; Homebrew quit stanza is + // authoritative for this exact value but install4j IDs can drift across + // major versions — name-substring "trader workstation" is the fallback. + "com.install4j.5889-6375-8446-2021", + // Crypto + "com.binance.BinanceDesktop", + "com.electron.exodus", + // Electrum uses PyInstaller with bundle_identifier=None → defaults to + // org.pythonmac.unspecified.. Confirmed in spesmilo/electrum + // source + Homebrew zap. IntuneBrew's "org.electrum.electrum" is a fork. + "org.pythonmac.unspecified.Electrum", + "com.ledger.live", + "io.trezor.TrezorSuite", + // No native macOS app (name-substring only): Schwab, E*TRADE, TradeStation, + // Robinhood, NinjaTrader, Coinbase, Kraken, Bloomberg. thinkorswim + // install4j ID drifts per-install — substring safer. +]); + +// ─── Policy-deny (not a tier — cannot be granted at all) ───────────────── +// +// Streaming / ebook / music apps and a handful of publisher apps. These +// are auto-denied before the approval dialog — no tier can be granted. +// Rationale is copyright / content-control (the agent has no legitimate +// need to screenshot Netflix or click Play on Spotify). +// +// Sourced from the ACP CU-apps blocklist xlsx ("Full block" tab). See +// /tmp/extract_cu_blocklist.py for the extraction script. + +const POLICY_DENIED_BUNDLE_IDS: ReadonlySet = new Set([ + // Verified via Homebrew quit/zap + mdls /System/Applications + IntuneBrew. + // Apple built-ins + "com.apple.TV", + "com.apple.Music", + "com.apple.iBooksX", + "com.apple.podcasts", + // Music + "com.spotify.client", + "com.amazon.music", + "com.tidal.desktop", + "com.deezer.deezer-desktop", + "com.pandora.desktop", + "com.electron.pocket-casts", // direct-download Electron wrapper + "au.com.shiftyjelly.PocketCasts", // Mac App Store + // Video + "tv.plex.desktop", + "tv.plex.htpc", + "tv.plex.plexamp", + "com.amazon.aiv.AIVApp", // Prime Video (iOS-on-Apple-Silicon) + // Ebooks + "net.kovidgoyal.calibre", + "com.amazon.Kindle", // legacy desktop, discontinued + "com.amazon.Lassen", // current Mac App Store (iOS-on-Mac) + "com.kobo.desktop.Kobo", + // No native macOS app (name-substring only): Netflix, Disney+, Hulu, + // HBO Max, Peacock, Paramount+, YouTube, Crunchyroll, Tubi, Vudu, + // Audible, Reddit, NYTimes. Their iOS apps don't opt into iPad-on-Mac. +]); + +const POLICY_DENIED_NAME_SUBSTRINGS: readonly string[] = [ + // Video streaming + "netflix", + "disney+", + "hulu", + "prime video", + "apple tv", + "peacock", + "paramount+", + // "plex" is too generic — would match "Perplexity". Covered by + // tv.plex.* bundle IDs on macOS. + "tubi", + "crunchyroll", + "vudu", + // E-readers / audiobooks + "kindle", + "apple books", + "kobo", + "play books", + "calibre", + "libby", + "readium", + "audible", + "libro.fm", + "speechify", + // Music + "spotify", + "apple music", + "amazon music", + "youtube music", + "tidal", + "deezer", + "pandora", + "pocket casts", + // Publisher / social apps (from the same blocklist tab) + "naver", + "reddit", + "sony music", + "vegas pro", + "pitchfork", + "economist", + "nytimes", + // Skipped (too generic for substring matching — need bundle ID): + // HBO Max / Max, YouTube (non-Music), Nook, Sony Catalyst, Wired +]; + +/** + * Policy-level auto-deny. Unlike `userDeniedBundleIds` (per-user Settings + * page), this is baked into the build. `buildAccessRequest` strips these + * before the approval dialog with "blocked by policy" guidance; the agent + * is told to not retry. + */ +export function isPolicyDenied( + bundleId: string | undefined, + displayName: string, +): boolean { + if (bundleId && POLICY_DENIED_BUNDLE_IDS.has(bundleId)) return true; + const lower = displayName.toLowerCase(); + for (const sub of POLICY_DENIED_NAME_SUBSTRINGS) { + if (lower.includes(sub)) return true; + } + return false; +} + +export function getDeniedCategory(bundleId: string): DeniedCategory | null { + if (BROWSER_BUNDLE_IDS.has(bundleId)) return "browser"; + if (TERMINAL_BUNDLE_IDS.has(bundleId)) return "terminal"; + if (TRADING_BUNDLE_IDS.has(bundleId)) return "trading"; + return null; +} + +// ─── Display-name fallback (cross-platform) ────────────────────────────── + +/** + * Lowercase substrings checked against the requested display name. Catches: + * - Unresolved requests (app not installed, Spotlight miss) + * - Future Windows/Linux support where bundleId is meaningless + * + * Matched via `.includes()` on `name.toLowerCase()`. Entries are ordered + * by specificity (more-specific first is irrelevant since we return on + * first match, but groupings are by category for readability). + */ +const BROWSER_NAME_SUBSTRINGS: readonly string[] = [ + "safari", + "chrome", + "firefox", + "microsoft edge", + "brave", + "opera", + "vivaldi", + "chromium", + // Arc/Dia: the canonical display name is just "Arc"/"Dia" — too short for + // substring matching (false-positives: "Arcade", "Diagram"). Covered by + // bundle ID on macOS. The "... browser" entries below catch natural-language + // phrasings ("the arc browser") but NOT the canonical short name. + "arc browser", + "tor browser", + "duckduckgo", + "yandex", + "orion browser", + // Agentic / AI browsers + "comet", // Perplexity's browser — "Comet" substring risks false positives + // but leaving for now; "comet" in an app name is rare + "sigmaos", + "dia browser", +]; + +const TERMINAL_NAME_SUBSTRINGS: readonly string[] = [ + // macOS / cross-platform terminals + "terminal", // catches Terminal, Windows Terminal (NOT iTerm — separate entry) + "iterm", + "wezterm", + "alacritty", + "kitty", + "ghostty", + "tabby", + "termius", + // AppleScript runners — see bundle-ID comment above. "shortcuts" is too + // generic for substring matching (many apps have "shortcuts" in the name); + // covered by bundle ID only, like warp/hyper. + "script editor", + "automator", + // NOTE: "warp" and "hyper" are too generic for substring matching — + // they'd false-positive on "Warpaint" or "Hyperion". Covered by bundle ID + // (dev.warp.Warp-Stable, co.zeit.hyper) for macOS; Windows exe-name + // matching can be added when Windows CU ships. + // Windows shells (activate when the darwin gate lifts) + "powershell", + "cmd.exe", + "command prompt", + "git bash", + "conemu", + "cmder", + // IDEs (VS Code family) + "visual studio code", + "visual studio", // catches VS for Mac + Windows + "vscode", + "vs code", + "vscodium", + "cursor", // Cursor IDE — "cursor" is generic but IDE is the only common app + "windsurf", + // Zed: display name is just "Zed" — too short for substring matching + // (false-positives). Covered by bundle ID (dev.zed.Zed) on macOS. + // IDEs (JetBrains family) + "intellij", + "pycharm", + "webstorm", + "clion", + "goland", + "rubymine", + "phpstorm", + "datagrip", + "rider", + "appcode", + "rustrover", + "fleet", + "android studio", + // Other IDEs + "sublime text", + "macvim", + "neovim", + "emacs", + "xcode", + "eclipse", + "netbeans", +]; + +const TRADING_NAME_SUBSTRINGS: readonly string[] = [ + // Trading — brokerage apps. Sourced from the ACP CU-apps blocklist xlsx + // ("Read Only" tab). Name-substring safe for proper nouns below; generic + // names (IG, Delta, HTX) are skipped and need bundle-ID matching once + // verified. + "bloomberg", + "ameritrade", + "thinkorswim", + "schwab", + "fidelity", + "e*trade", + "interactive brokers", + "trader workstation", // Interactive Brokers TWS + "tradestation", + "webull", + "robinhood", + "tastytrade", + "ninjatrader", + "tradingview", + "moomoo", + "tradezero", + "prorealtime", + "plus500", + "saxotrader", + "oanda", + "metatrader", + "forex.com", + "avaoptions", + "ctrader", + "jforex", + "iq option", + "olymp trade", + "binomo", + "pocket option", + "raceoption", + "expertoption", + "quotex", + "naga", + "morgan stanley", + "ubs neo", + "eikon", // Thomson Reuters / LSEG Workspace + // Crypto — exchanges, wallets, portfolio trackers + "coinbase", + "kraken", + "binance", + "okx", + "bybit", + // "gate.io" is too generic — the ".io" TLD suffix is common in app names + // (e.g., "Draw.io"). Needs bundle-ID matching once verified. + "phemex", + "stormgain", + "crypto.com", + // "exodus" is too generic — it's a common noun and would match unrelated + // apps/games. Needs bundle-ID matching once verified. + "electrum", + "ledger live", + "trezor", + "guarda", + "atomic wallet", + "bitpay", + "bisq", + "koinly", + "cointracker", + "blockfi", + "stripe cli", + // Crypto games / metaverse (same trade-execution risk model) + "decentraland", + "axie infinity", + "gods unchained", +]; + +/** + * Display-name substring match. Called when bundle-ID resolution returned + * nothing (`resolved === undefined`) or when no bundle-ID deny-list entry + * matched. Returns the category for the first matching substring, or null. + * + * Case-insensitive, substring — so `"Google Chrome"`, `"chrome"`, and + * `"Chrome Canary"` all match the `"chrome"` entry. + */ +export function getDeniedCategoryByDisplayName( + name: string, +): DeniedCategory | null { + const lower = name.toLowerCase(); + // Trading first — proper-noun-only set, most specific. "Bloomberg Terminal" + // contains "terminal" and would miscategorize if TERMINAL_NAME_SUBSTRINGS + // ran first. + for (const sub of TRADING_NAME_SUBSTRINGS) { + if (lower.includes(sub)) return "trading"; + } + for (const sub of BROWSER_NAME_SUBSTRINGS) { + if (lower.includes(sub)) return "browser"; + } + for (const sub of TERMINAL_NAME_SUBSTRINGS) { + if (lower.includes(sub)) return "terminal"; + } + return null; +} + +/** + * Combined check — bundle ID first (exact, fast), then display-name + * fallback. This is the function tool-call handlers should use. + * + * `bundleId` may be undefined (unresolved request — model asked for an app + * that isn't installed or Spotlight didn't find). In that case only the + * display-name check runs. + */ +export function getDeniedCategoryForApp( + bundleId: string | undefined, + displayName: string, +): DeniedCategory | null { + if (bundleId) { + const byId = getDeniedCategory(bundleId); + if (byId) return byId; + } + return getDeniedCategoryByDisplayName(displayName); +} + +/** + * Default tier for an app at grant time. Wraps `getDeniedCategoryForApp` + + * `categoryToTier`. Browsers → `"read"`, terminals/IDEs → `"click"`, + * everything else → `"full"`. + * + * Called by `buildAccessRequest` to populate `ResolvedAppRequest.proposedTier` + * before the approval dialog shows. + */ +export function getDefaultTierForApp( + bundleId: string | undefined, + displayName: string, +): "read" | "click" | "full" { + return categoryToTier(getDeniedCategoryForApp(bundleId, displayName)); +} + +export const _test = { + BROWSER_BUNDLE_IDS, + TERMINAL_BUNDLE_IDS, + TRADING_BUNDLE_IDS, + POLICY_DENIED_BUNDLE_IDS, + BROWSER_NAME_SUBSTRINGS, + TERMINAL_NAME_SUBSTRINGS, + TRADING_NAME_SUBSTRINGS, + POLICY_DENIED_NAME_SUBSTRINGS, +}; diff --git a/src/vendor/computer-use-mcp/executor.ts b/src/vendor/computer-use-mcp/executor.ts new file mode 100644 index 00000000..9d7f5f8c --- /dev/null +++ b/src/vendor/computer-use-mcp/executor.ts @@ -0,0 +1,100 @@ +export interface DisplayGeometry { + id?: number + displayId?: number + width: number + height: number + scaleFactor: number + originX: number + originY: number + isPrimary?: boolean + name?: string + label?: string +} + +export interface ScreenshotResult { + base64: string + width: number + height: number + displayWidth: number + displayHeight: number + displayId?: number + originX: number + originY: number +} + +export interface FrontmostApp { + bundleId: string + displayName: string +} + +export interface RunningApp { + bundleId: string + displayName: string +} + +export interface InstalledApp { + bundleId: string + displayName: string + path: string + iconDataUrl?: string +} + +export interface ResolvePrepareCaptureResult extends ScreenshotResult { + hidden: string[] + display: DisplayGeometry + resolvedDisplayId?: number + captureError?: string +} + +export interface ComputerExecutor { + capabilities: { + screenshotFiltering: 'native' | 'none' + platform: 'darwin' | 'win32' + hostBundleId: string + teachMode?: boolean + } + prepareForAction(allowlistBundleIds: string[], displayId?: number): Promise + previewHideSet(allowlistBundleIds: string[], displayId?: number): Promise> + getDisplaySize(displayId?: number): Promise + listDisplays(): Promise + findWindowDisplays(bundleIds: string[]): Promise> + resolvePrepareCapture(opts: { + allowedBundleIds: string[] + preferredDisplayId?: number + autoResolve: boolean + doHide?: boolean + }): Promise + screenshot(opts: { allowedBundleIds: string[]; displayId?: number }): Promise + zoom( + regionLogical: { x: number; y: number; w: number; h: number }, + allowedBundleIds: string[], + displayId?: number, + ): Promise<{ base64: string; width: number; height: number }> + key(keySequence: string, repeat?: number): Promise + holdKey(keyNames: string[], durationMs: number): Promise + type(text: string, opts: { viaClipboard: boolean }): Promise + click( + x: number, + y: number, + button: 'left' | 'right' | 'middle', + count: 1 | 2 | 3, + modifiers?: string[], + ): Promise + drag( + from: { x: number; y: number } | undefined, + to: { x: number; y: number }, + ): Promise + moveMouse(x: number, y: number): Promise + scroll(x: number, y: number, deltaX: number, deltaY: number): Promise + mouseDown(): Promise + mouseUp(): Promise + getCursorPosition(): Promise<{ x: number; y: number }> + getFrontmostApp(): Promise + appUnderPoint(x: number, y: number): Promise<{ bundleId: string; displayName: string } | null> + listInstalledApps(): Promise + getAppIcon?(path: string): Promise + listRunningApps(): Promise + openApp(bundleId: string): Promise + readClipboard(): Promise + writeClipboard(text: string): Promise +} diff --git a/src/vendor/computer-use-mcp/imageResize.ts b/src/vendor/computer-use-mcp/imageResize.ts new file mode 100644 index 00000000..fc529714 --- /dev/null +++ b/src/vendor/computer-use-mcp/imageResize.ts @@ -0,0 +1,108 @@ +/** + * Port of the API's image transcoder target-size algorithm. Pre-sizing + * screenshots to this function's output means the API's early-return fires + * (tokens ≤ max) and the image is NOT resized server-side — so the model + * sees exactly the dimensions in `ScreenshotResult.width/height` and + * `scaleCoord` stays coherent. + * + * Rust reference: api/api/image_transcoder/rust_transcoder/src/utils/resize.rs + * Sibling TS port: apps/claude-browser-use/src/utils/imageResize.ts (identical + * algorithm, lives in the Chrome extension tree — not a shared package). + * + * See COORDINATES.md for why this matters for click accuracy. + */ + +export interface ResizeParams { + pxPerToken: number; + maxTargetPx: number; + maxTargetTokens: number; +} + +/** + * Production defaults — match `resize.rs:160-164` and Chrome's + * `CDPService.ts:638-642`. Vision encoder uses 28px tiles; 1568 is both + * the long-edge cap (56 tiles) AND the token budget. + */ +export const API_RESIZE_PARAMS: ResizeParams = { + pxPerToken: 28, + maxTargetPx: 1568, + maxTargetTokens: 1568, +}; + +/** ceil(px / pxPerToken). Matches resize.rs:74-76 (which uses integer ceil-div). */ +export function nTokensForPx(px: number, pxPerToken: number): number { + return Math.floor((px - 1) / pxPerToken) + 1; +} + +function nTokensForImg( + width: number, + height: number, + pxPerToken: number, +): number { + return nTokensForPx(width, pxPerToken) * nTokensForPx(height, pxPerToken); +} + +/** + * Binary-search along the width dimension for the largest image that: + * - preserves the input aspect ratio + * - has long edge ≤ maxTargetPx + * - has ceil(w/pxPerToken) × ceil(h/pxPerToken) ≤ maxTargetTokens + * + * Returns [width, height]. No-op if input already satisfies all three. + * + * The long-edge constraint alone (what we used to use) is insufficient on + * squarer-than-16:9 displays: 1568×1014 (MBP 16" AR) is 56×37 = 2072 tokens, + * over budget, and gets server-resized to 1372×887 — model then clicks in + * 1372-space but scaleCoord assumed 1568-space → ~14% coord error. + * + * Matches resize.rs:91-155 exactly (verified against its test vectors). + */ +export function targetImageSize( + width: number, + height: number, + params: ResizeParams, +): [number, number] { + const { pxPerToken, maxTargetPx, maxTargetTokens } = params; + + if ( + width <= maxTargetPx && + height <= maxTargetPx && + nTokensForImg(width, height, pxPerToken) <= maxTargetTokens + ) { + return [width, height]; + } + + // Normalize to landscape for the search; transpose result back. + if (height > width) { + const [w, h] = targetImageSize(height, width, params); + return [h, w]; + } + + const aspectRatio = width / height; + + // Loop invariant: lowerBoundWidth is always valid, upperBoundWidth is + // always invalid. ~12 iterations for a 4000px image. + let upperBoundWidth = width; + let lowerBoundWidth = 1; + + for (;;) { + if (lowerBoundWidth + 1 === upperBoundWidth) { + return [ + lowerBoundWidth, + Math.max(Math.round(lowerBoundWidth / aspectRatio), 1), + ]; + } + + const middleWidth = Math.floor((lowerBoundWidth + upperBoundWidth) / 2); + const middleHeight = Math.max(Math.round(middleWidth / aspectRatio), 1); + + if ( + middleWidth <= maxTargetPx && + nTokensForImg(middleWidth, middleHeight, pxPerToken) <= maxTargetTokens + ) { + lowerBoundWidth = middleWidth; + } else { + upperBoundWidth = middleWidth; + } + } +} diff --git a/src/vendor/computer-use-mcp/index.ts b/src/vendor/computer-use-mcp/index.ts new file mode 100644 index 00000000..1e012cb2 --- /dev/null +++ b/src/vendor/computer-use-mcp/index.ts @@ -0,0 +1,69 @@ +export type { + ComputerExecutor, + DisplayGeometry, + FrontmostApp, + InstalledApp, + ResolvePrepareCaptureResult, + RunningApp, + ScreenshotResult, +} from "./executor.js"; + +export type { + AppGrant, + CuAppPermTier, + ComputerUseHostAdapter, + ComputerUseOverrides, + ComputerUseSessionContext, + CoordinateMode, + CuGrantFlags, + CuPermissionRequest, + CuPermissionResponse, + CuSubGates, + CuTeachPermissionRequest, + Logger, + ResolvedAppRequest, + ScreenshotDims, + TeachStepRequest, + TeachStepResult, +} from "./types.js"; + +export { DEFAULT_GRANT_FLAGS } from "./types.js"; + +export { + SENTINEL_BUNDLE_IDS, + getSentinelCategory, +} from "./sentinelApps.js"; +export type { SentinelCategory } from "./sentinelApps.js"; + +export { + categoryToTier, + getDefaultTierForApp, + getDeniedCategory, + getDeniedCategoryByDisplayName, + getDeniedCategoryForApp, + isPolicyDenied, +} from "./deniedApps.js"; +export type { DeniedCategory } from "./deniedApps.js"; + +export { isSystemKeyCombo, normalizeKeySequence } from "./keyBlocklist.js"; + +export { ALL_SUB_GATES_OFF, ALL_SUB_GATES_ON } from "./subGates.js"; + +export { API_RESIZE_PARAMS, targetImageSize } from "./imageResize.js"; +export type { ResizeParams } from "./imageResize.js"; + +export { defersLockAcquire, handleToolCall } from "./toolCalls.js"; +export type { + CuCallTelemetry, + CuCallToolResult, + CuErrorKind, +} from "./toolCalls.js"; + +export { bindSessionContext, createComputerUseMcpServer } from "./mcpServer.js"; +export { buildComputerUseTools } from "./tools.js"; + +export { + comparePixelAtLocation, + validateClickTarget, +} from "./pixelCompare.js"; +export type { CropRawPatchFn, PixelCompareResult } from "./pixelCompare.js"; diff --git a/src/vendor/computer-use-mcp/keyBlocklist.ts b/src/vendor/computer-use-mcp/keyBlocklist.ts new file mode 100644 index 00000000..1373e150 --- /dev/null +++ b/src/vendor/computer-use-mcp/keyBlocklist.ts @@ -0,0 +1,153 @@ +/** + * Key combos that cross app boundaries or terminate processes. Gated behind + * the `systemKeyCombos` grant flag. When that flag is off, the `key` tool + * rejects these and returns a tool error telling the model to request the + * flag; all other combos work normally. + * + * Matching is canonicalized: every modifier alias the Rust executor accepts + * collapses to one canonical name. Without this, `command+q` / `meta+q` / + * `cmd+alt+escape` bypass the gate — see keyBlocklist.test.ts for the three + * bypass forms and the Rust parity check that catches future alias drift. + */ + +/** + * Every modifier alias enigo_wrap.rs accepts (two copies: :351-359, :564-572), + * mapped to one canonical per Key:: variant. Left/right variants collapse — + * the blocklist doesn't distinguish which Ctrl. + * + * Canonical names are Rust's own variant names lowercased. Blocklist entries + * below use ONLY these. "meta" reads odd for Cmd+Q but it's honest: Rust + * sends Key::Meta, which is Cmd on darwin and Win on win32. + */ +const CANONICAL_MODIFIER: Readonly> = { + // Key::Meta — "meta"|"super"|"command"|"cmd"|"windows"|"win" + meta: "meta", + super: "meta", + command: "meta", + cmd: "meta", + windows: "meta", + win: "meta", + // Key::Control + LControl + RControl + ctrl: "ctrl", + control: "ctrl", + lctrl: "ctrl", + lcontrol: "ctrl", + rctrl: "ctrl", + rcontrol: "ctrl", + // Key::Shift + LShift + RShift + shift: "shift", + lshift: "shift", + rshift: "shift", + // Key::Alt and Key::Option — distinct Rust variants but same keycode on + // darwin (kVK_Option). Collapse: cmd+alt+escape and cmd+option+escape + // both Force Quit. + alt: "alt", + option: "alt", +}; + +/** Sort order for canonicals. ctrl < alt < shift < meta. */ +const MODIFIER_ORDER = ["ctrl", "alt", "shift", "meta"]; + +/** + * Canonical-form entries only. Every modifier must be a CANONICAL_MODIFIER + * *value* (not key), modifiers must be in MODIFIER_ORDER, non-modifier last. + * The self-consistency test enforces this. + */ +const BLOCKED_DARWIN = new Set([ + "meta+q", // Cmd+Q — quit frontmost app + "shift+meta+q", // Cmd+Shift+Q — log out + "alt+meta+escape", // Cmd+Option+Esc — Force Quit dialog + "meta+tab", // Cmd+Tab — app switcher + "meta+space", // Cmd+Space — Spotlight + "ctrl+meta+q", // Ctrl+Cmd+Q — lock screen +]); + +const BLOCKED_WIN32 = new Set([ + "ctrl+alt+delete", // Secure Attention Sequence + "alt+f4", // close window + "alt+tab", // window switcher + "meta+l", // Win+L — lock + "meta+d", // Win+D — show desktop +]); + +/** + * Partition into sorted-canonical modifiers and non-modifier keys. + * Shared by normalizeKeySequence (join for display) and isSystemKeyCombo + * (check mods+each-key to catch the cmd+q+a suffix bypass). + */ +function partitionKeys(seq: string): { mods: string[]; keys: string[] } { + const parts = seq + .toLowerCase() + .split("+") + .map((p) => p.trim()) + .filter(Boolean); + const mods: string[] = []; + const keys: string[] = []; + for (const p of parts) { + const canonical = CANONICAL_MODIFIER[p]; + if (canonical !== undefined) { + mods.push(canonical); + } else { + keys.push(p); + } + } + // Dedupe: "cmd+command+q" → "meta+q", not "meta+meta+q". + const uniqueMods = [...new Set(mods)]; + uniqueMods.sort( + (a, b) => MODIFIER_ORDER.indexOf(a) - MODIFIER_ORDER.indexOf(b), + ); + return { mods: uniqueMods, keys }; +} + +/** + * Normalize "Cmd + Shift + Q" → "shift+meta+q": lowercase, trim, alias → + * canonical, dedupe, sort modifiers, non-modifiers last. + */ +export function normalizeKeySequence(seq: string): string { + const { mods, keys } = partitionKeys(seq); + return [...mods, ...keys].join("+"); +} + +/** + * True if the sequence would fire a blocked OS shortcut. + * + * Checks mods + EACH non-modifier key individually, not just the full + * joined string. `cmd+q+a` → Rust presses Cmd, then Q (Cmd+Q fires here), + * then A. Exact-match against "meta+q+a" misses; checking "meta+q" and + * "meta+a" separately catches the Q. + * + * Modifiers-only sequences ("cmd+shift") are checked as-is — no key to + * pair with, and no blocklist entry is modifier-only, so this is a no-op + * that falls through to false. Covers the click-modifier case where + * `left_click(text="cmd")` is legitimate. + */ +export function isSystemKeyCombo( + seq: string, + platform: "darwin" | "win32", +): boolean { + const blocklist = platform === "darwin" ? BLOCKED_DARWIN : BLOCKED_WIN32; + const { mods, keys } = partitionKeys(seq); + const prefix = mods.length > 0 ? mods.join("+") + "+" : ""; + + // No non-modifier keys (e.g. "cmd+shift" as click-modifiers) — check the + // whole thing. Never matches (no blocklist entry is modifier-only) but + // keeps the contract simple: every call reaches a .has(). + if (keys.length === 0) { + return blocklist.has(mods.join("+")); + } + + // mods + each key. Any hit blocks the whole sequence. + for (const key of keys) { + if (blocklist.has(prefix + key)) { + return true; + } + } + return false; +} + +export const _test = { + CANONICAL_MODIFIER, + BLOCKED_DARWIN, + BLOCKED_WIN32, + MODIFIER_ORDER, +}; diff --git a/src/vendor/computer-use-mcp/mcpServer.ts b/src/vendor/computer-use-mcp/mcpServer.ts new file mode 100644 index 00000000..4b1f0ca2 --- /dev/null +++ b/src/vendor/computer-use-mcp/mcpServer.ts @@ -0,0 +1,313 @@ +/** + * MCP server factory + session-context binder. + * + * Two entry points: + * + * `bindSessionContext` — the wrapper closure. Takes a `ComputerUseSessionContext` + * (getters + callbacks backed by host session state), returns a dispatcher. + * Reusable by both the MCP CallTool handler here AND Cowork's + * `InternalServerDefinition.handleToolCall` (which doesn't go through MCP). + * This replaces the duplicated wrapper closures in apps/desktop/…/serverDef.ts + * and the Claude Code CLI's CU host wrapper — both did the same thing: build `ComputerUseOverrides` + * fresh from getters, call `handleToolCall`, stash screenshot, merge permissions. + * + * `createComputerUseMcpServer` — the Server object. When `context` is provided, + * the CallTool handler is real (uses `bindSessionContext`). When not, it's the + * legacy stub that returns a not-wired error. The tool-schema ListTools handler + * is the same either way. + */ + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; + +import type { ScreenshotResult } from "./executor.js"; +import type { CuCallToolResult } from "./toolCalls.js"; +import { + defersLockAcquire, + handleToolCall, + resetMouseButtonHeld, +} from "./toolCalls.js"; +import { buildComputerUseTools } from "./tools.js"; +import type { + AppGrant, + ComputerUseHostAdapter, + ComputerUseOverrides, + ComputerUseSessionContext, + CoordinateMode, + CuGrantFlags, + CuPermissionResponse, +} from "./types.js"; +import { DEFAULT_GRANT_FLAGS } from "./types.js"; + +const DEFAULT_LOCK_HELD_MESSAGE = + "Another Claude session is currently using the computer. Wait for that " + + "session to finish, or find a non-computer-use approach."; + +/** + * Dedupe `granted` into `existing` on bundleId, spread truthy-only flags over + * defaults+existing. Truthy-only: a subsequent `request_access` that doesn't + * request clipboard can't revoke an earlier clipboard grant — revocation lives + * in a Settings page, not here. + * + * Same merge both hosts implemented independently today. + */ +function mergePermissionResponse( + existing: readonly AppGrant[], + existingFlags: CuGrantFlags, + response: CuPermissionResponse, +): { apps: AppGrant[]; flags: CuGrantFlags } { + const seen = new Set(existing.map((a) => a.bundleId)); + const apps = [ + ...existing, + ...response.granted.filter((g) => !seen.has(g.bundleId)), + ]; + const truthyFlags = Object.fromEntries( + Object.entries(response.flags).filter(([, v]) => v === true), + ); + const flags: CuGrantFlags = { + ...DEFAULT_GRANT_FLAGS, + ...existingFlags, + ...truthyFlags, + }; + return { apps, flags }; +} + +/** + * Bind session state to a reusable dispatcher. The returned function is the + * wrapper closure: async lock gate → build overrides fresh → `handleToolCall` + * → stash screenshot → strip piggybacked fields. + * + * The last-screenshot blob is held in a closure cell here (not on `ctx`), so + * hosts don't need to guarantee `ctx` object identity across calls — they just + * need to hold onto the returned dispatcher. Cowork caches per + * `InternalServerContext` in a WeakMap; the CLI host constructs once at server creation. + */ +export function bindSessionContext( + adapter: ComputerUseHostAdapter, + coordinateMode: CoordinateMode, + ctx: ComputerUseSessionContext, +): (name: string, args: unknown) => Promise { + const { logger, serverName } = adapter; + + // Screenshot blob persists here across calls — NOT on `ctx`. Hosts hold + // onto the returned dispatcher; that's the identity that matters. + let lastScreenshot: ScreenshotResult | undefined; + + const wrapPermission = ctx.onPermissionRequest + ? async ( + req: Parameters>[0], + signal: AbortSignal, + ): Promise => { + const response = await ctx.onPermissionRequest!(req, signal); + const { apps, flags } = mergePermissionResponse( + ctx.getAllowedApps(), + ctx.getGrantFlags(), + response, + ); + logger.debug( + `[${serverName}] permission result: granted=${response.granted.length} denied=${response.denied.length}`, + ); + ctx.onAllowedAppsChanged?.(apps, flags); + return response; + } + : undefined; + + const wrapTeachPermission = ctx.onTeachPermissionRequest + ? async ( + req: Parameters>[0], + signal: AbortSignal, + ): Promise => { + const response = await ctx.onTeachPermissionRequest!(req, signal); + logger.debug( + `[${serverName}] teach permission result: granted=${response.granted.length} denied=${response.denied.length}`, + ); + // Teach doesn't request grant flags — preserve existing. + const { apps } = mergePermissionResponse( + ctx.getAllowedApps(), + ctx.getGrantFlags(), + response, + ); + ctx.onAllowedAppsChanged?.(apps, { + ...DEFAULT_GRANT_FLAGS, + ...ctx.getGrantFlags(), + }); + return response; + } + : undefined; + + return async (name, args) => { + // ─── Async lock gate ───────────────────────────────────────────────── + // Replaces the sync Gate-3 in `handleToolCall` — we pass + // `checkCuLock: undefined` below so it no-ops. Hosts with + // cross-process locks (O_EXCL file) await the real primitive here + // instead of pre-computing + feeding a fake sync result. + if (ctx.checkCuLock) { + const lock = await ctx.checkCuLock(); + if (lock.holder !== undefined && !lock.isSelf) { + const text = + ctx.formatLockHeldMessage?.(lock.holder) ?? DEFAULT_LOCK_HELD_MESSAGE; + return { + content: [{ type: "text", text }], + isError: true, + telemetry: { error_kind: "cu_lock_held" }, + }; + } + if (lock.holder === undefined && !defersLockAcquire(name)) { + await ctx.acquireCuLock?.(); + // Re-check: the awaits above yield the microtask queue, so another + // session's check+acquire can interleave with ours. Hosts where + // acquire is a no-op when already held (Cowork's CuLockManager) give + // no signal that we lost — verify we're now the holder before + // proceeding. The CLI's O_EXCL file lock would surface this as a throw from + // acquire instead; this re-check is a belt-and-suspenders for that + // path too. + const recheck = await ctx.checkCuLock(); + if (recheck.holder !== undefined && !recheck.isSelf) { + const text = + ctx.formatLockHeldMessage?.(recheck.holder) ?? + DEFAULT_LOCK_HELD_MESSAGE; + return { + content: [{ type: "text", text }], + isError: true, + telemetry: { error_kind: "cu_lock_held" }, + }; + } + // Fresh holder → any prior session's mouseButtonHeld is stale. + // Mirrors what Gate-3 does on the acquire branch. After the + // re-check so we only clear module state when we actually won. + resetMouseButtonHeld(); + } + } + + // ─── Build overrides fresh ─────────────────────────────────────────── + // Blob-first; dims-fallback with base64:"" when the closure cell is + // unset (cross-respawn). scaleCoord reads dims; pixelCompare sees "" → + // isEmpty → skip. + const dimsFallback = lastScreenshot + ? undefined + : ctx.getLastScreenshotDims?.(); + + // Per-call AbortController for dialog dismissal. Aborted in `finally` — + // if handleToolCall finishes (MCP timeout, throw) before the user + // answers, the host's dialog handler sees the abort and tears down. + const dialogAbort = new AbortController(); + + const overrides: ComputerUseOverrides = { + allowedApps: [...ctx.getAllowedApps()], + grantFlags: ctx.getGrantFlags(), + userDeniedBundleIds: ctx.getUserDeniedBundleIds(), + coordinateMode, + selectedDisplayId: ctx.getSelectedDisplayId(), + displayPinnedByModel: ctx.getDisplayPinnedByModel?.(), + displayResolvedForApps: ctx.getDisplayResolvedForApps?.(), + lastScreenshot: + lastScreenshot ?? + (dimsFallback ? { ...dimsFallback, base64: "" } : undefined), + onPermissionRequest: wrapPermission + ? (req) => wrapPermission(req, dialogAbort.signal) + : undefined, + onTeachPermissionRequest: wrapTeachPermission + ? (req) => wrapTeachPermission(req, dialogAbort.signal) + : undefined, + onAppsHidden: ctx.onAppsHidden, + getClipboardStash: ctx.getClipboardStash, + onClipboardStashChanged: ctx.onClipboardStashChanged, + onResolvedDisplayUpdated: ctx.onResolvedDisplayUpdated, + onDisplayPinned: ctx.onDisplayPinned, + onDisplayResolvedForApps: ctx.onDisplayResolvedForApps, + onTeachModeActivated: ctx.onTeachModeActivated, + onTeachStep: ctx.onTeachStep, + onTeachWorking: ctx.onTeachWorking, + getTeachModeActive: ctx.getTeachModeActive, + // Undefined → handleToolCall's sync Gate-3 no-ops. The async gate + // above already ran. + checkCuLock: undefined, + acquireCuLock: undefined, + isAborted: ctx.isAborted, + }; + + logger.debug( + `[${serverName}] tool=${name} allowedApps=${overrides.allowedApps.length} coordMode=${coordinateMode}`, + ); + + // ─── Dispatch ──────────────────────────────────────────────────────── + try { + const result = await handleToolCall(adapter, name, args, overrides); + + if (result.screenshot) { + lastScreenshot = result.screenshot; + const { base64: _blob, ...dims } = result.screenshot; + logger.debug(`[${serverName}] screenshot dims: ${JSON.stringify(dims)}`); + ctx.onScreenshotCaptured?.(dims); + } + + return result; + } finally { + dialogAbort.abort(); + } + }; +} + +export function createComputerUseMcpServer( + adapter: ComputerUseHostAdapter, + coordinateMode: CoordinateMode, + context?: ComputerUseSessionContext, +): Server { + const { serverName, logger } = adapter; + + const server = new Server( + { name: serverName, version: "0.1.3" }, + { capabilities: { tools: {}, logging: {} } }, + ); + + const tools = buildComputerUseTools( + adapter.executor.capabilities, + coordinateMode, + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => + adapter.isDisabled() ? { tools: [] } : { tools }, + ); + + if (context) { + const dispatch = bindSessionContext(adapter, coordinateMode, context); + server.setRequestHandler( + CallToolRequestSchema, + async (request): Promise => { + const { screenshot: _s, telemetry: _t, ...result } = await dispatch( + request.params.name, + request.params.arguments ?? {}, + ); + return result; + }, + ); + return server; + } + + // Legacy: no context → stub handler. Reached only if something calls the + // server over MCP transport WITHOUT going through a binder (a wiring + // regression). Clear error instead of silent failure. + server.setRequestHandler( + CallToolRequestSchema, + async (request): Promise => { + logger.warn( + `[${serverName}] tool call "${request.params.name}" reached the stub handler — no session context bound. Per-session state unavailable.`, + ); + return { + content: [ + { + type: "text", + text: "This computer-use server instance is not wired to a session. Per-session app permissions are not available on this code path.", + }, + ], + isError: true, + }; + }, + ); + + return server; +} diff --git a/src/vendor/computer-use-mcp/pixelCompare.ts b/src/vendor/computer-use-mcp/pixelCompare.ts new file mode 100644 index 00000000..05153f60 --- /dev/null +++ b/src/vendor/computer-use-mcp/pixelCompare.ts @@ -0,0 +1,171 @@ +/** + * Staleness guard ported from the Vercept acquisition. + * + * Compares the model's last-seen screenshot against a fresh-right-now + * screenshot at the click target, so the model never clicks pixels it hasn't + * seen. If the 9×9 patch around the target differs, the click is aborted and + * the model is told to re-screenshot. This is NOT a popup detector. + * + * Semantics preserved exactly: + * - Skip on no `lastScreenshot` (cold start) — click proceeds. + * - Skip on any internal error (crop throws, screenshot fails, etc.) — + * click proceeds. Validation failure must never block the action. + * - 9×9 exact byte equality on raw pixel bytes. No fuzzing, no tolerance. + * - Compare in percentage coords so Retina scale doesn't matter. + * + * JPEG decode + crop is INJECTED via `ComputerUseHostAdapter.cropRawPatch`. + * The original used `sharp` (LGPL, native `.node` addon); we inject Electron's + * `nativeImage` (Chromium decoders, BSD, nothing to bundle) from the host, so + * this package never imports it — the crop is a function parameter. + */ + +import type { ScreenshotResult } from "./executor.js"; +import type { Logger } from "./types.js"; + +/** Injected by the host. See `ComputerUseHostAdapter.cropRawPatch`. */ +export type CropRawPatchFn = ( + jpegBase64: string, + rect: { x: number; y: number; width: number; height: number }, +) => Buffer | null; + +/** 9×9 is empirically the sweet spot — large enough to catch a tooltip + * appearing, small enough to not false-positive on surrounding animation. + **/ +const DEFAULT_GRID_SIZE = 9; + +export interface PixelCompareResult { + /** true → click may proceed. false → patch changed, abort the click. */ + valid: boolean; + /** true → validation did not run (cold start, sub-gate off, or internal + * error). The caller MUST treat this identically to `valid: true`. */ + skipped: boolean; + /** Populated when valid === false. Returned to the model verbatim. */ + warning?: string; +} + +/** + * Compute the crop rect for a patch centered on (xPercent, yPercent). + * + * Dimensions come from ScreenshotResult.width/height (physical pixels). Both + * screenshots have the same dimensions (same display, consecutive captures), + * so the rect is the same for both. + */ +function computeCropRect( + imgW: number, + imgH: number, + xPercent: number, + yPercent: number, + gridSize: number, +): { x: number; y: number; width: number; height: number } | null { + if (!imgW || !imgH) return null; + + const clampedX = Math.max(0, Math.min(100, xPercent)); + const clampedY = Math.max(0, Math.min(100, yPercent)); + + const centerX = Math.round((clampedX / 100.0) * imgW); + const centerY = Math.round((clampedY / 100.0) * imgH); + + const halfGrid = Math.floor(gridSize / 2); + const cropX = Math.max(0, centerX - halfGrid); + const cropY = Math.max(0, centerY - halfGrid); + const cropW = Math.min(gridSize, imgW - cropX); + const cropH = Math.min(gridSize, imgH - cropY); + if (cropW <= 0 || cropH <= 0) return null; + + return { x: cropX, y: cropY, width: cropW, height: cropH }; +} + +/** + * Compare the same patch location between two screenshots. + * + * @returns true when the raw pixel bytes are identical. false on any + * difference, or on any internal error (the caller treats an error here as + * `skipped`, so the false is harmless). + */ +export function comparePixelAtLocation( + crop: CropRawPatchFn, + lastScreenshot: ScreenshotResult, + freshScreenshot: ScreenshotResult, + xPercent: number, + yPercent: number, + gridSize: number = DEFAULT_GRID_SIZE, +): boolean { + // Both screenshots are of the same display — use the fresh one's + // dimensions (less likely to be stale than last's). + const rect = computeCropRect( + freshScreenshot.width, + freshScreenshot.height, + xPercent, + yPercent, + gridSize, + ); + if (!rect) return false; + + const patch1 = crop(lastScreenshot.base64, rect); + const patch2 = crop(freshScreenshot.base64, rect); + if (!patch1 || !patch2) return false; + + // Direct buffer equality. Note: nativeImage.toBitmap() gives BGRA, sharp's + // .raw() gave RGB. + // Doesn't matter — we're comparing two same-format buffers for equality. + return patch1.equals(patch2); +} + +/** + * Battle-tested click-target validation ported from the Vercept acquisition, + * with the fresh-screenshot capture delegated to the caller (we don't have + * a global `SystemActions.takeScreenshot()` — the executor is injected). + * + * Skip conditions (any of these → `{ valid: true, skipped: true }`): + * - `lastScreenshot` is undefined (cold start). + * - `takeFreshScreenshot()` throws or returns null. + * - Injected crop function returns null (decode failure). + * - Any other exception. + * + * The caller decides whether to invoke this at all (sub-gate check lives + * in toolCalls.ts, not here). + */ +export async function validateClickTarget( + crop: CropRawPatchFn, + lastScreenshot: ScreenshotResult | undefined, + xPercent: number, + yPercent: number, + takeFreshScreenshot: () => Promise, + logger: Logger, + gridSize: number = DEFAULT_GRID_SIZE, +): Promise { + if (!lastScreenshot) { + return { valid: true, skipped: true }; + } + + try { + const fresh = await takeFreshScreenshot(); + if (!fresh) { + return { valid: true, skipped: true }; + } + + const pixelsMatch = comparePixelAtLocation( + crop, + lastScreenshot, + fresh, + xPercent, + yPercent, + gridSize, + ); + + if (pixelsMatch) { + return { valid: true, skipped: false }; + } + return { + valid: false, + skipped: false, + warning: + "Screen content at the target location changed since the last screenshot. Take a new screenshot before clicking.", + }; + } catch (err) { + // Skip validation on technical errors, execute action anyway. + // Battle-tested: validation failure must never block the click. + logger.debug("[pixelCompare] validation error, skipping", err); + return { valid: true, skipped: true }; + } +} diff --git a/src/vendor/computer-use-mcp/sentinelApps.ts b/src/vendor/computer-use-mcp/sentinelApps.ts new file mode 100644 index 00000000..0d26de60 --- /dev/null +++ b/src/vendor/computer-use-mcp/sentinelApps.ts @@ -0,0 +1,43 @@ +/** + * Bundle IDs that are escalations-in-disguise. The approval UI shows a warning + * badge for these; they are NOT blocked. Power users may legitimately want the + * model controlling a terminal. + * + * Imported by the renderer via the `./sentinelApps` subpath (package.json + * `exports`), which keeps Next.js from reaching index.ts → mcpServer.ts → + * @modelcontextprotocol/sdk (devDep, would fail module resolution). Keep + * this file import-free so the subpath stays clean. + */ + +/** These apps can execute arbitrary shell commands. */ +const SHELL_ACCESS_BUNDLE_IDS = new Set([ + "com.apple.Terminal", + "com.googlecode.iterm2", + "com.microsoft.VSCode", + "dev.warp.Warp-Stable", + "com.github.wez.wezterm", + "io.alacritty", + "net.kovidgoyal.kitty", + "com.jetbrains.intellij", + "com.jetbrains.pycharm", +]); + +/** Finder in the allowlist ≈ browse + open-any-file. */ +const FILESYSTEM_ACCESS_BUNDLE_IDS = new Set(["com.apple.finder"]); + +const SYSTEM_SETTINGS_BUNDLE_IDS = new Set(["com.apple.systempreferences"]); + +export const SENTINEL_BUNDLE_IDS: ReadonlySet = new Set([ + ...SHELL_ACCESS_BUNDLE_IDS, + ...FILESYSTEM_ACCESS_BUNDLE_IDS, + ...SYSTEM_SETTINGS_BUNDLE_IDS, +]); + +export type SentinelCategory = "shell" | "filesystem" | "system_settings"; + +export function getSentinelCategory(bundleId: string): SentinelCategory | null { + if (SHELL_ACCESS_BUNDLE_IDS.has(bundleId)) return "shell"; + if (FILESYSTEM_ACCESS_BUNDLE_IDS.has(bundleId)) return "filesystem"; + if (SYSTEM_SETTINGS_BUNDLE_IDS.has(bundleId)) return "system_settings"; + return null; +} diff --git a/src/vendor/computer-use-mcp/subGates.ts b/src/vendor/computer-use-mcp/subGates.ts new file mode 100644 index 00000000..c2cacdef --- /dev/null +++ b/src/vendor/computer-use-mcp/subGates.ts @@ -0,0 +1,19 @@ +import type { CuSubGates } from './types.js' + +export const ALL_SUB_GATES_ON: CuSubGates = { + pixelValidation: false, + clipboardPasteMultiline: true, + mouseAnimation: true, + hideBeforeAction: true, + autoTargetDisplay: true, + clipboardGuard: true, +} + +export const ALL_SUB_GATES_OFF: CuSubGates = { + pixelValidation: false, + clipboardPasteMultiline: false, + mouseAnimation: false, + hideBeforeAction: false, + autoTargetDisplay: false, + clipboardGuard: false, +} diff --git a/src/vendor/computer-use-mcp/toolCalls.ts b/src/vendor/computer-use-mcp/toolCalls.ts new file mode 100644 index 00000000..1c982e00 --- /dev/null +++ b/src/vendor/computer-use-mcp/toolCalls.ts @@ -0,0 +1,3652 @@ +/** + * Tool dispatch. Every security decision from plan §2 is enforced HERE, + * before any executor method is called. + * + * Enforcement order, every call: + * 1. Kill switch (`adapter.isDisabled()`). + * 2. TCC gate (`adapter.ensureOsPermissions()`). `request_access` is + * exempted — it threads the ungranted state to the renderer so the + * user can grant TCC perms from inside the approval dialog. + * 3. Tool-specific gates (see dispatch table) — ANY exception in a gate + * returns a tool error, executor never called. + * 4. Executor call. + * + * For input actions (click/type/key/scroll/drag/move_mouse) the tool-specific + * gates are, in order: + * a. `prepareForAction` — hide every non-allowlisted app, then defocus us + * (battle-tested pre-action sequence from the Vercept acquisition). + * Sub-gated via `hideBeforeAction`. After this runs the screenshot is + * TRUE (what the + * model sees IS what's at each pixel) and we are not keyboard-focused. + * b. Frontmost gate — branched by actionKind: + * mouse: frontmost ∈ allowlist ∪ {hostBundleId, Finder} → pass. + * hostBundleId passes because the executor's + * `withClickThrough` bracket makes us click-through. + * keyboard: frontmost ∈ allowlist ∪ {Finder} → pass. + * hostBundleId → ERROR (safety net — defocus should have + * moved us off; if it didn't, typing would go into our + * own chat box). + * After step (a) this gate fires RARELY — only when something popped + * up between prepare and action, or the 5-try hide loop gave up. + * Checked FRESH on every call, not cached across calls. + * + * For click variants only, AFTER the above gates but BEFORE the executor call: + * c. Pixel-validation staleness check (sub-gated). + */ + +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { randomUUID } from "node:crypto"; + +import { getDefaultTierForApp, getDeniedCategoryForApp, isPolicyDenied } from "./deniedApps.js"; +import type { + ComputerExecutor, + DisplayGeometry, + InstalledApp, + ScreenshotResult, +} from "./executor.js"; +import { isSystemKeyCombo } from "./keyBlocklist.js"; +import { validateClickTarget } from "./pixelCompare.js"; +import { SENTINEL_BUNDLE_IDS } from "./sentinelApps.js"; +import type { + AppGrant, + ComputerUseHostAdapter, + ComputerUseOverrides, + CoordinateMode, + CuAppPermTier, + CuGrantFlags, + CuPermissionRequest, + CuSubGates, + CuTeachPermissionRequest, + Logger, + ResolvedAppRequest, + TeachStepRequest, +} from "./types.js"; + +/** + * Finder is never hidden by the hide loop (hiding Finder kills the Desktop), + * so it's always a valid frontmost. + */ +const FINDER_BUNDLE_ID = "com.apple.finder"; + +/** + * Categorical error classes for the cu_tool_call telemetry event. Never + * free text — error messages may contain file paths / app content (PII). + */ +export type CuErrorKind = + | "allowlist_empty" + | "tcc_not_granted" + | "cu_lock_held" + | "teach_mode_conflict" + | "teach_mode_not_active" + | "executor_threw" + | "capture_failed" + | "app_denied" // no longer emitted (tiered model replaced hard-deny); kept for schema compat + | "bad_args" // malformed tool args (type/shape/range/unknown value) + | "app_not_granted" // target app not in session allowlist (distinct from allowlist_empty) + | "tier_insufficient" // app in allowlist but at a tier too low for the action + | "feature_unavailable" // tool callable but session not wired for it + | "state_conflict" // wrong state for action (call sequence, mouse already held) + | "grant_flag_required" // action needs a grant flag (systemKeyCombos, clipboard*) from request_access + | "display_error" // display enumeration failed (platform) + | "other"; + +/** + * Telemetry payload piggybacked on the result — populated by handlers, + * consumed and stripped by the host wrapper (serverDef.ts) before the + * result goes to the SDK. Same pattern as `screenshot`. + */ +export interface CuCallTelemetry { + /** request_access / request_teach_access: apps NEWLY granted in THIS call + * (does NOT include idempotent re-grants of already-allowed apps). */ + granted_count?: number; + /** request_access / request_teach_access: apps denied in THIS call */ + denied_count?: number; + /** request_access / request_teach_access: apps safety-denied (browser) this call */ + denied_browser_count?: number; + /** request_access / request_teach_access: apps safety-denied (terminal) this call */ + denied_terminal_count?: number; + /** Categorical error class (only set when isError) */ + error_kind?: CuErrorKind; +} + +/** + * `CallToolResult` augmented with the screenshot payload. `bindSessionContext` + * reads `result.screenshot` after a `screenshot` tool call and stashes it in a + * closure cell for the next pixel-validation. MCP clients never see this + * field — the host wrapper strips it before returning to the SDK. + */ +export type CuCallToolResult = CallToolResult & { + screenshot?: ScreenshotResult; + /** Piggybacked telemetry — stripped by the host wrapper before SDK return. */ + telemetry?: CuCallTelemetry; +}; + +// --------------------------------------------------------------------------- +// Small result helpers (mirror of chrome-mcp's inline `{content, isError}`) +// --------------------------------------------------------------------------- + +function errorResult(text: string, errorKind?: CuErrorKind): CuCallToolResult { + return { + content: [{ type: "text", text }], + isError: true, + telemetry: errorKind ? { error_kind: errorKind } : undefined, + }; +} + +function okText(text: string): CuCallToolResult { + return { content: [{ type: "text", text }] }; +} + +function okJson(obj: unknown, telemetry?: CuCallTelemetry): CuCallToolResult { + return { + content: [{ type: "text", text: JSON.stringify(obj) }], + telemetry, + }; +} + +// --------------------------------------------------------------------------- +// Arg validation — lightweight, no zod (mirrors chrome-mcp's cast-and-check) +// --------------------------------------------------------------------------- + +function asRecord(args: unknown): Record { + if (typeof args === "object" && args !== null) { + return args as Record; + } + return {}; +} + +function requireNumber( + args: Record, + key: string, +): number | Error { + const v = args[key]; + if (typeof v !== "number" || !Number.isFinite(v)) { + return new Error(`"${key}" must be a finite number.`); + } + return v; +} + +function requireString( + args: Record, + key: string, +): string | Error { + const v = args[key]; + if (typeof v !== "string") { + return new Error(`"${key}" must be a string.`); + } + return v; +} + +/** + * Extract (x, y) from `coordinate: [x, y]` tuple. + * array of length 2, both non-negative numbers. + */ +function extractCoordinate( + args: Record, + paramName: string = "coordinate", +): [number, number] | Error { + const coord = args[paramName]; + if (coord === undefined) { + return new Error(`${paramName} is required`); + } + if (!Array.isArray(coord) || coord.length !== 2) { + return new Error(`${paramName} must be an array of length 2`); + } + const [x, y] = coord; + if (typeof x !== "number" || typeof y !== "number" || x < 0 || y < 0) { + return new Error(`${paramName} must be a tuple of non-negative numbers`); + } + return [x, y]; +} + +// --------------------------------------------------------------------------- +// Coordinate scaling +// --------------------------------------------------------------------------- + +/** + * Convert model-space coordinates to the logical points that enigo expects. + * + * - `normalized_0_100`: (x / 100) * display.width. `display` is fetched + * fresh per tool call — never cached across calls — + * so a mid-session display-settings change doesn't leave us stale. + * - `pixels`: the model sent image-space pixel coords (it read them off the + * last screenshot). With the 1568-px long-edge downsample, the + * screenshot-px → logical-pt ratio is `displayWidth / screenshotWidth`, + * NOT `1/scaleFactor`. Uses the display geometry stashed at CAPTURE time + * (`lastScreenshot.displayWidth`), not fresh — so the transform matches + * what the model actually saw even if the user changed display settings + * since. (Chrome's ScreenshotContext pattern — CDPService.ts:1486-1493.) + */ +function scaleCoord( + rawX: number, + rawY: number, + mode: CoordinateMode, + display: DisplayGeometry, + lastScreenshot: ScreenshotResult | undefined, + logger: Logger, +): { x: number; y: number } { + if (mode === "normalized_0_100") { + // Origin offset targets the selected display in virtual-screen space. + return { + x: Math.round((rawX / 100) * display.width) + display.originX, + y: Math.round((rawY / 100) * display.height) + display.originY, + }; + } + + // mode === "pixels": model sent image-space pixel coords. + if (lastScreenshot) { + // The transform. Chrome coordinateScaling.ts:22-34 + claude-in-a-box + // ComputerTool.swift:70-80 — two independent convergent impls. + // Uses the display geometry stashed AT CAPTURE TIME, not fresh. + // Origin from the same snapshot keeps clicks coherent with the captured display. + return { + x: + Math.round( + rawX * (lastScreenshot.displayWidth / lastScreenshot.width), + ) + lastScreenshot.originX, + y: + Math.round( + rawY * (lastScreenshot.displayHeight / lastScreenshot.height), + ) + lastScreenshot.originY, + }; + } + + // Cold start: model sent pixel coords without having taken a screenshot. + // Degenerate — fall back to the old /sf behavior and warn. + logger.warn( + "[computer-use] pixels-mode coordinate received with no prior screenshot; " + + "falling back to /scaleFactor. Click may be off if downsample is active.", + ); + return { + x: Math.round(rawX / display.scaleFactor) + display.originX, + y: Math.round(rawY / display.scaleFactor) + display.originY, + }; +} + +/** + * Convert model-space coordinates to the 0–100 percentage that + * pixelCompare.ts works in. The staleness check operates in screenshot-image + * space; comparing by percentage lets us crop both last and fresh screenshots + * at the same relative location without caring about their absolute dims. + * + * With the 1568-px downsample, `screenshot.width != display.width * sf`, so + * the old `rawX / (display.width * sf)` formula is wrong. The correct + * denominator is just `lastScreenshot.width` — the model's raw pixel coord is + * already in that image's coordinate space. `DisplayGeometry` is no longer + * consumed at all. + */ +function coordToPercentageForPixelCompare( + rawX: number, + rawY: number, + mode: CoordinateMode, + lastScreenshot: ScreenshotResult | undefined, +): { xPct: number; yPct: number } { + if (mode === "normalized_0_100") { + // Unchanged — already a percentage. + return { xPct: rawX, yPct: rawY }; + } + + // mode === "pixels" + if (!lastScreenshot) { + // validateClickTarget at pixelCompare.ts:141-143 already skips when + // lastScreenshot is undefined, so this return value never reaches a crop. + return { xPct: 0, yPct: 0 }; + } + return { + xPct: (rawX / lastScreenshot.width) * 100, + yPct: (rawY / lastScreenshot.height) * 100, + }; +} + +// --------------------------------------------------------------------------- +// Shared input-action gates +// --------------------------------------------------------------------------- + +/** + * Tier needed to perform a given action class. `undefined` → `"full"`. + * + * - `"mouse_position"` — mouse_move only. Passes at any tier including + * `"read"`. Pure cursor positioning, no app interaction. Still runs + * prepareForAction (hide non-allowed apps). + * - `"mouse"` — plain left click, double/triple, scroll, drag-from. + * Requires tier `"click"` or `"full"`. + * - `"mouse_full"` — right/middle click, any click with modifiers, + * drag-drop (the `to` endpoint of left_click_drag). Requires tier + * `"full"`. Right-click → context menu Paste, modifier chords → + * keystrokes before click, drag-drop → text insertion at the drop + * point. All escalate a click-tier grant to keyboard-equivalent input. + * Blunt: also rejects same-app drags (scrollbar, panel resize) onto + * click-tier apps; `scroll` is the tier-"click" way to scroll. + * - `"keyboard"` — type, key, hold_key. Requires tier `"full"`. + */ +type CuActionKind = "mouse_position" | "mouse" | "mouse_full" | "keyboard"; + +function tierSatisfies( + grantTier: CuAppPermTier | undefined, + actionKind: CuActionKind, +): boolean { + const tier = grantTier ?? "full"; + if (actionKind === "mouse_position") return true; + if (actionKind === "keyboard" || actionKind === "mouse_full") { + return tier === "full"; + } + // mouse + return tier === "click" || tier === "full"; +} + +// Appended to every tier_insufficient error. The model may try to route +// around the gate (osascript, System Events, cliclick via Bash) — this +// closes that door explicitly. Leading space so it concatenates cleanly. +const TIER_ANTI_SUBVERSION = + " Do not attempt to work around this restriction — never use AppleScript, " + + "System Events, shell commands, or any other method to send clicks or " + + "keystrokes to this app."; + +// --------------------------------------------------------------------------- +// Clipboard guard — stash+clear while a click-tier app is frontmost +// --------------------------------------------------------------------------- +// +// Threat: tier "click" blocks type/key/right-click-Paste, but a click-tier +// terminal/IDE may have a UI Paste button that's plain-left-clickable. If the +// clipboard holds `rm -rf /` — from the user, from a prior full-tier paste, +// OR from the agent's own write_clipboard call (which doesn't route through +// runInputActionGates) — a left_click on that button injects it. +// +// Mitigation: stash the user's clipboard on first entry to click-tier, then +// RE-CLEAR before every input action while click-tier stays frontmost. The +// re-clear is the load-bearing part — a stash-on-transition-only design +// leaves a gap between an agent write_clipboard and the next left_click. +// When frontmost becomes anything else, restore. Turn-end restore is inlined +// in the host's result-handler + leavingRunning (same dual-location as +// cuHiddenDuringTurn unhide) — reads `session.cuClipboardStash` directly and +// writes via Electron's `clipboard.writeText`, so no nest-only import. +// +// State lives on the session (via `overrides.getClipboardStash` / +// `onClipboardStashChanged`), not module-level. The CU lock still guarantees +// one session at a time, but session-scoped state means the host's turn-end +// restore doesn't need to reach back into this package. + +async function syncClipboardStash( + adapter: ComputerUseHostAdapter, + overrides: ComputerUseOverrides, + frontmostIsClickTier: boolean, +): Promise { + const current = overrides.getClipboardStash?.(); + if (!frontmostIsClickTier) { + // Restore + clear. Idempotent — if nothing is stashed, no-op. + if (current === undefined) return; + try { + await adapter.executor.writeClipboard(current); + // Clear only after a successful write — a transient pasteboard + // failure must not irrecoverably drop the stash. + overrides.onClipboardStashChanged?.(undefined); + } catch { + // Best effort — stash held, next non-click action retries. + } + return; + } + // Stash the user's clipboard on FIRST entry to click-tier only. + if (current === undefined) { + try { + const read = await adapter.executor.readClipboard(); + overrides.onClipboardStashChanged?.(read); + } catch { + // readClipboard failed — use empty sentinel so we don't retry the stash + // on the next action; restore becomes a harmless writeClipboard(""). + overrides.onClipboardStashChanged?.(""); + } + } + // Re-clear on EVERY click-tier action, not just the first. Defeats the + // bypass where the agent calls write_clipboard (which doesn't route + // through runInputActionGates) between stash and a left_click on a UI + // Paste button — the next action's clear clobbers the agent's write + // before the click lands. + try { + await adapter.executor.writeClipboard(""); + } catch { + // Transient pasteboard failure. The tier-"click" right-click/modifier + // block still holds; this is a net, not a promise. + } +} + +/** Every click/type/key/scroll/drag/move_mouse runs through this before + * touching the executor. Returns null on pass, error-result on block. + * Any throw inside → caught by handleToolCall's outer try → tool error. */ +async function runInputActionGates( + adapter: ComputerUseHostAdapter, + overrides: ComputerUseOverrides, + subGates: CuSubGates, + actionKind: CuActionKind, +): Promise { + // Step A+B — hide non-allowlisted apps + defocus us. Sub-gated. After this + // runs, the frontmost gate below becomes a rare edge-case detector (something + // popped up between prepare and action) rather than a normal-path blocker. + // ALL grant tiers stay visible — visibility is the baseline (tier "read"). + if (subGates.hideBeforeAction) { + const hidden = await adapter.executor.prepareForAction( + overrides.allowedApps.map((a) => a.bundleId), + overrides.selectedDisplayId, + ); + // Empty-check so we don't spam the callback on every action when nothing + // was hidden (the common case after the first action of a turn). + if (hidden.length > 0) { + overrides.onAppsHidden?.(hidden); + } + } + + // Frontmost gate. Check FRESH on every call. + const frontmost = await adapter.executor.getFrontmostApp(); + + const tierByBundleId = new Map( + overrides.allowedApps.map((a) => [a.bundleId, a.tier] as const), + ); + + // After handleToolCall's tier backfill, every grant has a concrete tier — + // .get() returning undefined means the app is not in the allowlist at all. + const frontmostTier = frontmost + ? tierByBundleId.get(frontmost.bundleId) + : undefined; + + // Clipboard guard. Per-action, not per-tool-call — runs for every sub-action + // inside computer_batch and teach_step/teach_batch, so clicking into a + // click-tier app mid-batch stashes+clears before the next click lands. + // Lives here (not in handleToolCall) so deferAcquire tools (request_access, + // list_granted_applications), `wait`, and the teach_step blocking-dialog + // phase don't trigger a sync — only input actions do. + if (subGates.clipboardGuard) { + await syncClipboardStash(adapter, overrides, frontmostTier === "click"); + } + + if (!frontmost) { + // No frontmost app (rare — login window?). Let it through; the click + // will land somewhere and PixelCompare catches staleness. + return null; + } + + const { hostBundleId } = adapter.executor.capabilities; + + if (frontmostTier !== undefined) { + if (tierSatisfies(frontmostTier, actionKind)) return null; + // In the allowlist but tier doesn't cover this action. Tailor the + // guidance to the actual tier — at "read", suggesting left_click or Bash + // is wrong (nothing is allowed; use Chrome MCP). At "click", the + // mouse_full/keyboard-specific messages apply. + if (frontmostTier === "read") { + // tier "read" is not category-unique (browser AND trading map to it) — + // re-look-up so the CiC hint only shows for actual browsers. + const isBrowser = + getDeniedCategoryForApp(frontmost.bundleId, frontmost.displayName) === + "browser"; + return errorResult( + `"${frontmost.displayName}" is granted at tier "read" — ` + + `visible in screenshots only, no clicks or typing.` + + (isBrowser + ? " Use the Claude-in-Chrome MCP for browser interaction (tools " + + "named `mcp__Claude_in_Chrome__*`; load via ToolSearch if " + + "deferred)." + : " No interaction is permitted; ask the user to take any " + + "actions in this app themselves.") + + TIER_ANTI_SUBVERSION, + "tier_insufficient", + ); + } + // frontmostTier === "click" (tier === "full" would have passed tierSatisfies) + if (actionKind === "keyboard") { + return errorResult( + `"${frontmost.displayName}" is granted at tier "click" — ` + + `typing, key presses, and paste require tier "full". The keys ` + + `would go to this app's text fields or integrated terminal. To ` + + `type into a different app, click it first to bring it forward. ` + + `For shell commands, use the Bash tool.` + TIER_ANTI_SUBVERSION, + "tier_insufficient", + ); + } + // actionKind === "mouse_full" ("mouse" and "mouse_position" pass at "click") + return errorResult( + `"${frontmost.displayName}" is granted at tier "click" — ` + + `right-click, middle-click, and clicks with modifier keys require ` + + `tier "full". Right-click opens a context menu with Paste/Cut, and ` + + `modifier chords fire as keystrokes before the click. Plain ` + + `left_click is allowed here.` + TIER_ANTI_SUBVERSION, + "tier_insufficient", + ); + } + // Finder is never-hide, always allowed. + if (frontmost.bundleId === FINDER_BUNDLE_ID) return null; + + if (frontmost.bundleId === hostBundleId) { + if (actionKind !== "keyboard") { + // mouse and mouse_full are both click events — click-through works. + // We're click-through (executor's withClickThrough). Pass. + return null; + } + // Keyboard safety net — defocus (prepareForAction step B) should have + // moved us off. If we're still here, typing would go to our chat box. + return errorResult( + "Claude's own window still has keyboard focus. This should not happen " + + "after the pre-action defocus. Click on the target application first.", + "state_conflict", + ); + } + + // Non-allowlisted, non-us, non-Finder. RARE after the hide loop — means + // something popped up between prepare and action, or the 5-try loop gave up. + return errorResult( + `"${frontmost.displayName}" is not in the allowed applications and is ` + + `currently in front. Take a new screenshot — it may have appeared ` + + `since your last one.`, + "app_not_granted", + ); +} + +/** + * Hit-test gate: reject a mouse action if the window under (x, y) belongs + * to an app whose tier doesn't cover mouse input. Closes the gap where a + * tier-"full" app is frontmost but the click lands on a tier-"read" window + * overlapping it — `runInputActionGates` passes (frontmost is fine), but the + * click actually goes to the read-tier app. + * + * Runs AFTER `scaleCoord` (needs global coords) and BEFORE the executor call. + * Returns null on pass (target is tier-"click"/"full", or desktop/Finder/us), + * error-result on block. + * + * When `appUnderPoint` returns null (desktop, or platform without hit-test), + * falls through — the frontmost check in `runInputActionGates` already ran. + */ +async function runHitTestGate( + adapter: ComputerUseHostAdapter, + overrides: ComputerUseOverrides, + subGates: CuSubGates, + x: number, + y: number, + actionKind: CuActionKind, +): Promise { + const target = await adapter.executor.appUnderPoint(x, y); + if (!target) return null; // desktop / nothing under point / platform no-op + + // Finder (desktop, file dialogs) is always clickable — same exemption as + // runInputActionGates. Our own overlay is filtered by Swift (pid != self). + if (target.bundleId === FINDER_BUNDLE_ID) return null; + + const tierByBundleId = new Map( + overrides.allowedApps.map((a) => [a.bundleId, a.tier] as const), + ); + + if (!tierByBundleId.has(target.bundleId)) { + // Not in the allowlist at all. The frontmost check would catch this if + // the target were frontmost, but here a different app is in front. This + // is the "something popped up" edge case — a new window appeared between + // screenshot and click, or a background app's window overlaps the target. + return errorResult( + `Click at these coordinates would land on "${target.displayName}", ` + + `which is not in the allowed applications. Take a fresh screenshot ` + + `to see the current window layout.`, + "app_not_granted", + ); + } + + const targetTier = tierByBundleId.get(target.bundleId); + + // Frontmost-based sync (runInputActionGates) misses the case where + // the click lands on a NON-FRONTMOST click-tier window. Re-sync by + // the hit-test target's tier — if target is click-tier, stash+clear + // before the click lands, regardless of what's frontmost. + if (subGates.clipboardGuard && targetTier === "click") { + await syncClipboardStash(adapter, overrides, true); + } + + if (tierSatisfies(targetTier, actionKind)) return null; + + // Target is in the allowlist but tier doesn't cover this action. + // runHitTestGate is only called with mouse/mouse_full (keyboard routes to + // frontmost, not window-under-cursor). The branch above catches + // mouse_full ∧ click; the only remaining fall-through is tier "read". + if (actionKind === "mouse_full" && targetTier === "click") { + return errorResult( + `Click at these coordinates would land on "${target.displayName}", ` + + `which is granted at tier "click" — right-click, middle-click, and ` + + `clicks with modifier keys require tier "full" (they can Paste via ` + + `the context menu or fire modifier-chord keystrokes). Plain ` + + `left_click is allowed here.` + TIER_ANTI_SUBVERSION, + "tier_insufficient", + ); + } + const isBrowser = + getDeniedCategoryForApp(target.bundleId, target.displayName) === "browser"; + return errorResult( + `Click at these coordinates would land on "${target.displayName}", ` + + `which is granted at tier "read" (screenshots only, no interaction). ` + + (isBrowser + ? "Use the Claude-in-Chrome MCP for browser interaction." + : "Ask the user to take any actions in this app themselves.") + + TIER_ANTI_SUBVERSION, + "tier_insufficient", + ); +} + +// --------------------------------------------------------------------------- +// Screenshot helpers +// --------------------------------------------------------------------------- + +/** + * §6 item 9 — screenshot retry on implausibly-small buffer. Battle-tested + * threshold (1024 bytes). We retry exactly once. + */ +const MIN_SCREENSHOT_BYTES = 1024; + +function decodedByteLength(base64: string): number { + // 3 bytes per 4 chars, minus padding. Good enough for a threshold check. + const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; + return Math.floor((base64.length * 3) / 4) - padding; +} + +async function takeScreenshotWithRetry( + executor: ComputerExecutor, + allowedBundleIds: string[], + logger: ComputerUseHostAdapter["logger"], + displayId?: number, +): Promise { + let shot = await executor.screenshot({ allowedBundleIds, displayId }); + if (decodedByteLength(shot.base64) < MIN_SCREENSHOT_BYTES) { + logger.warn( + `[computer-use] screenshot implausibly small (${decodedByteLength(shot.base64)} bytes decoded), retrying once`, + ); + shot = await executor.screenshot({ allowedBundleIds, displayId }); + } + return shot; +} + +// --------------------------------------------------------------------------- +// Grapheme iteration — §6 item 7, ported from the Vercept acquisition +// --------------------------------------------------------------------------- + +const INTER_GRAPHEME_SLEEP_MS = 8; // §6 item 4 — 125 Hz USB polling + +function segmentGraphemes(text: string): string[] { + try { + // Node 18+ has Intl.Segmenter; the try is defence against a stripped- + // -down runtime (falls back to code points). + const Segmenter = ( + Intl as typeof Intl & { + Segmenter?: new ( + locale?: string, + options?: { granularity: "grapheme" | "word" | "sentence" }, + ) => { segment: (s: string) => Iterable<{ segment: string }> }; + } + ).Segmenter; + if (typeof Segmenter === "function") { + const seg = new Segmenter(undefined, { granularity: "grapheme" }); + return Array.from(seg.segment(text), (s) => s.segment); + } + } catch { + // fall through + } + // Code-point iteration. Keeps surrogate pairs together but splits ZWJ. + return Array.from(text); +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +/** + * Split a chord string like "ctrl+shift" into individual key names. + * Same parsing as `key` tool / executor.key / keyBlocklist.normalizeKeySequence. + */ +function parseKeyChord(text: string): string[] { + return text + .split("+") + .map((s) => s.trim()) + .filter(Boolean); +} + +// --------------------------------------------------------------------------- +// left_mouse_down / left_mouse_up held-state tracking +// --------------------------------------------------------------------------- + +/** + * Errors on double-down but not on up-without-down. Module-level, but + * reset on every lock acquire (handleToolCall → acquireCuLock branch) so + * a session interrupted mid-drag (overlay stop during left_mouse_down) + * doesn't leave the flag true for the next lock holder. + * + * Still scoped wrong within a single lock cycle if sessions could interleave + * tool calls, but the lock enforces at-most-one-session-uses-CU so they + * can't. The per-turn reset is the correctness boundary. + */ +let mouseButtonHeld = false; +/** Whether mouse_move occurred between left_mouse_down and left_mouse_up. + * When false at mouseUp, the decomposed sequence is a click-release (not a + * drop) — hit-test at "mouse", not "mouse_full". */ +let mouseMoved = false; + +/** Clears the cross-call drag flags. Called from Gate-3 on lock-acquire and + * from `bindSessionContext` in mcpServer.ts — a fresh lock holder must not + * inherit a prior session's mid-drag state. */ +export function resetMouseButtonHeld(): void { + mouseButtonHeld = false; + mouseMoved = false; +} + +/** If a left_mouse_down set the OS button without a matching left_mouse_up + * ever getting its turn, release it now. Same release-before-return as + * handleClick. No-op when not held — callers don't need to check. */ +async function releaseHeldMouse( + adapter: ComputerUseHostAdapter, +): Promise { + if (!mouseButtonHeld) return; + await adapter.executor.mouseUp(); + mouseButtonHeld = false; + mouseMoved = false; +} + +/** + * Tools that check the lock but don't acquire it. `request_access` and + * `list_granted_applications` hit the CHECK (so a blocked session doesn't + * show an approval dialog for access it can't use) but defer ACQUIRE — the + * enter-CU notification/overlay only fires on the first action tool. + * + * `request_teach_access` is NOT here: approving teach mode hides the main + * window, and the lock must be held before that. See Gate-3 block in + * `handleToolCall` for the full explanation. + * + * Exported for `bindSessionContext` in mcpServer.ts so the async lock gate + * uses the same set as the sync one. + */ +export function defersLockAcquire(toolName: string): boolean { + return ( + toolName === "request_access" || + toolName === "list_granted_applications" + ); +} + +// --------------------------------------------------------------------------- +// request_access helpers +// --------------------------------------------------------------------------- + +/** Reverse-DNS-ish: contains at least one dot, no spaces, no slashes. Lets + * raw bundle IDs pass through resolution. */ +const REVERSE_DNS_RE = /^[A-Za-z0-9][\w.-]*\.[A-Za-z0-9][\w.-]*$/; + +function looksLikeBundleId(s: string): boolean { + return REVERSE_DNS_RE.test(s) && !s.includes(" "); +} + +function resolveRequestedApps( + requestedNames: string[], + installed: InstalledApp[], + alreadyGrantedBundleIds: ReadonlySet, +): ResolvedAppRequest[] { + const byLowerDisplayName = new Map(); + const byBundleId = new Map(); + for (const app of installed) { + byBundleId.set(app.bundleId, app); + // Last write wins on collisions. Ambiguous-name handling (multiple + // candidates in the dialog) is plan-documented but deferred — the + // InstalledApps enumerator dedupes by bundle ID, so true display-name + // collisions are rare. TODO(chicago, post-P1): surface all candidates. + byLowerDisplayName.set(app.displayName.toLowerCase(), app); + } + + return requestedNames.map((requested): ResolvedAppRequest => { + let resolved: InstalledApp | undefined; + if (looksLikeBundleId(requested)) { + resolved = byBundleId.get(requested); + } + if (!resolved) { + resolved = byLowerDisplayName.get(requested.toLowerCase()); + } + const bundleId = resolved?.bundleId; + // When unresolved AND the requested string looks like a bundle ID, use it + // directly for tier lookup (e.g. "company.thebrowser.Browser" with Arc not + // installed — the reverse-DNS string won't match any display-name substring). + const bundleIdCandidate = + bundleId ?? (looksLikeBundleId(requested) ? requested : undefined); + return { + requestedName: requested, + resolved, + isSentinel: bundleId ? SENTINEL_BUNDLE_IDS.has(bundleId) : false, + alreadyGranted: bundleId ? alreadyGrantedBundleIds.has(bundleId) : false, + proposedTier: getDefaultTierForApp( + bundleIdCandidate, + resolved?.displayName ?? requested, + ), + }; + }); +} + +// --------------------------------------------------------------------------- +// Individual tool handlers +// --------------------------------------------------------------------------- + +async function handleRequestAccess( + adapter: ComputerUseHostAdapter, + args: Record, + overrides: ComputerUseOverrides, + tccState: { accessibility: boolean; screenRecording: boolean } | undefined, +): Promise { + if (!overrides.onPermissionRequest) { + return errorResult( + "This session was not wired with a permission handler. Computer control is not available here.", + "feature_unavailable", + ); + } + + // Teach mode hides the main window; permission dialogs render in that + // window. Without this, handleToolPermission blocks on an invisible + // prompt and the overlay spins forever. Tell the model to exit teach + // mode, request access, then re-enter. + if (overrides.getTeachModeActive?.()) { + return errorResult( + "Cannot request additional permissions during teach mode — the permission dialog would be hidden. End teach mode (finish the tour or let the turn complete), then call request_access, then start a new tour.", + "teach_mode_conflict", + ); + } + + const reason = requireString(args, "reason"); + if (reason instanceof Error) return errorResult(reason.message, "bad_args"); + + // TCC-ungranted branch. The renderer shows a toggle panel INSTEAD OF the + // app list when `tccState` is present on the request, so we skip app + // resolution entirely (listInstalledApps() may fail without Screen + // Recording anyway). The user grants the OS perms from inside the dialog, + // then clicks "Ask again" — both buttons resolve with deny by design + // (ComputerUseApproval.tsx) so the model re-calls request_access and + // gets the app list on the next call. + if (tccState) { + const req: CuPermissionRequest = { + requestId: randomUUID(), + reason, + apps: [], + requestedFlags: {}, + screenshotFiltering: adapter.executor.capabilities.screenshotFiltering, + tccState, + }; + await overrides.onPermissionRequest(req); + + // Re-check: the user may have granted in System Settings while the + // dialog was up. The `tccState` arg is a pre-dialog snapshot — reading + // it here would tell the model "not yet granted" even after the user + // granted, and the model waits for confirmation instead of retrying. + // The renderer's TCC panel already live-polls (computerUseTccStore); + // this is the same re-check on the tool-result side. + const recheck = await adapter.ensureOsPermissions(); + if (recheck.granted) { + return errorResult( + "macOS Accessibility and Screen Recording are now both granted. " + + "Call request_access again immediately — the next call will show " + + "the app selection list.", + ); + } + + const missing: string[] = []; + if (!recheck.accessibility) missing.push("Accessibility"); + if (!recheck.screenRecording) missing.push("Screen Recording"); + return errorResult( + `macOS ${missing.join(" and ")} permission(s) not yet granted. ` + + `The permission panel has been shown. Once the user grants the ` + + `missing permission(s), call request_access again.`, + "tcc_not_granted", + ); + } + + const rawApps = args.apps; + if (!Array.isArray(rawApps) || !rawApps.every((a) => typeof a === "string")) { + return errorResult('"apps" must be an array of strings.', "bad_args"); + } + const apps = rawApps as string[]; + + const requestedFlags: Partial = {}; + if (typeof args.clipboardRead === "boolean") { + requestedFlags.clipboardRead = args.clipboardRead; + } + if (typeof args.clipboardWrite === "boolean") { + requestedFlags.clipboardWrite = args.clipboardWrite; + } + if (typeof args.systemKeyCombos === "boolean") { + requestedFlags.systemKeyCombos = args.systemKeyCombos; + } + + const { + needDialog, + skipDialogGrants, + willHide, + tieredApps, + userDenied, + policyDenied, + } = await buildAccessRequest( + adapter, + apps, + overrides.allowedApps, + new Set(overrides.userDeniedBundleIds), + overrides.selectedDisplayId, + ); + + let dialogGranted: AppGrant[] = []; + let dialogDenied: Array<{ + bundleId: string; + reason: "user_denied" | "not_installed"; + }> = []; + let dialogFlags: CuGrantFlags = overrides.grantFlags; + + if (needDialog.length > 0 || Object.keys(requestedFlags).length > 0) { + const req: CuPermissionRequest = { + requestId: randomUUID(), + reason, + apps: needDialog, + requestedFlags, + screenshotFiltering: adapter.executor.capabilities.screenshotFiltering, + // Undefined when empty so the renderer skips the section cleanly. + ...(willHide.length > 0 && { + willHide, + autoUnhideEnabled: adapter.getAutoUnhideEnabled(), + }), + }; + const response = await overrides.onPermissionRequest(req); + dialogGranted = response.granted; + dialogDenied = response.denied; + dialogFlags = response.flags; + } + + // Do NOT return display geometry or coordinateMode. See COORDINATES.md + // ("Never give the model a number that invites rescaling"). scaleCoord + // already transforms server-side; the coordinate convention is baked into + // the tool param descriptions at server-construction time. + const allGranted = [...skipDialogGrants, ...dialogGranted]; + // Filter tieredApps to what was actually granted — if the user unchecked + // Chrome in the dialog, don't explain Chrome's tier. + const grantedBundleIds = new Set(allGranted.map((g) => g.bundleId)); + const grantedTieredApps = tieredApps.filter((t) => + grantedBundleIds.has(t.bundleId), + ); + // Best-effort — grants are already persisted by wrappedPermissionHandler; + // a listDisplays/findWindowDisplays failure (monitor hot-unplug, NAPI + // error) must not tank the grant response. Same discipline as + // buildMonitorNote's listDisplays try/catch. + let windowLocations: Awaited> = []; + try { + windowLocations = await buildWindowLocations(adapter, allGranted); + } catch (e) { + adapter.logger.warn( + `[computer-use] buildWindowLocations failed: ${String(e)}`, + ); + } + return okJson( + { + granted: allGranted, + denied: dialogDenied, + // Policy blocklist — precedes userDenied in precedence and response + // order. No escape hatch; the agent is told to find another approach. + ...(policyDenied.length > 0 && { + policyDenied: { + apps: policyDenied, + guidance: buildPolicyDeniedGuidance(policyDenied), + }, + }), + // User-configured auto-deny — stripped before the dialog; this is the + // agent's only signal that these apps exist but are user-blocked. + ...(userDenied.length > 0 && { + userDenied: { + apps: userDenied, + guidance: buildUserDeniedGuidance(userDenied), + }, + }), + // Upfront guidance so the model knows what each tier allows BEFORE + // hitting the gate. Only included when something was tier-restricted. + ...(grantedTieredApps.length > 0 && { + tierGuidance: buildTierGuidanceMessage(grantedTieredApps), + }), + screenshotFiltering: adapter.executor.capabilities.screenshotFiltering, + // Where each granted app currently has open windows, across monitors. + // Omitted when the app isn't running or has no normal windows. + ...(windowLocations.length > 0 ? { windowLocations } : {}), + }, + { + // dialogGranted only — skipDialogGrants are idempotent re-grants of + // apps already in the allowlist (no user action, dialog skips them). + // Matching denied_count's this-call-only semantics. + granted_count: dialogGranted.length, + denied_count: dialogDenied.length, + ...tierAssignmentTelemetry(grantedTieredApps), + }, + ); +} + +/** + * For each granted app with open windows, which displays those windows are + * on. Single-monitor setups return an empty array (no multi-monitor signal + * to give). Apps not running, or running with no normal windows, are omitted. + */ +async function buildWindowLocations( + adapter: ComputerUseHostAdapter, + granted: AppGrant[], +): Promise< + Array<{ + bundleId: string; + displayName: string; + displays: Array<{ id: number; label?: string; isPrimary?: boolean }>; + }> +> { + if (granted.length === 0) return []; + + const displays = await adapter.executor.listDisplays(); + if (displays.length <= 1) return []; + + const grantedBundleIds = granted.map((g) => g.bundleId); + const windowLocs = await adapter.executor.findWindowDisplays(grantedBundleIds); + const displayById = new Map(displays.map((d) => [d.displayId, d])); + const idsByBundle = new Map(windowLocs.map((w) => [w.bundleId, w.displayIds])); + + const out = []; + for (const g of granted) { + const displayIds = idsByBundle.get(g.bundleId); + if (!displayIds || displayIds.length === 0) continue; + out.push({ + bundleId: g.bundleId, + displayName: g.displayName, + displays: displayIds.map((id) => { + const d = displayById.get(id); + return { id, label: d?.label, isPrimary: d?.isPrimary }; + }), + }); + } + return out; +} + +/** + * Shared app-resolution + partition + hide-preview pipeline. Extracted from + * `handleRequestAccess` so `handleRequestTeachAccess` can call the same path. + * + * Does the full app-name→InstalledApp resolution, assigns each a tier + * (browser→"read", terminal/IDE→"click", else "full" — see deniedApps.ts), + * splits into already-granted (skip the dialog, preserve grantedAt+tier) vs + * need-dialog, and computes the willHide preview. Unlike the previous + * hard-deny model, ALL apps proceed to the dialog; the tier just constrains + * what actions are allowed once granted. + */ +/** An app assigned a restricted tier (not `"full"`). Used to build the + * guidance message telling the model what it can/can't do. */ +interface TieredApp { + bundleId: string; + displayName: string; + /** Never `"full"` — only restricted tiers are collected. */ + tier: "read" | "click"; +} + +interface AccessRequestParts { + needDialog: ResolvedAppRequest[]; + skipDialogGrants: AppGrant[]; + willHide: Array<{ bundleId: string; displayName: string }>; + /** Resolved apps with `proposedTier !== "full"` — for the guidance text. + * Unresolved apps are omitted (they go to `denied` with `not_installed`). */ + tieredApps: TieredApp[]; + /** Apps stripped by the user's Settings auto-deny list. Surfaced in the + * response with guidance; never reach the dialog. */ + userDenied: Array<{ requestedName: string; displayName: string }>; + /** Apps stripped by the baked-in policy blocklist (streaming/music/ebooks, + * etc. — `deniedApps.isPolicyDenied`). Precedence over userDenied. */ + policyDenied: Array<{ requestedName: string; displayName: string }>; +} + +async function buildAccessRequest( + adapter: ComputerUseHostAdapter, + apps: string[], + allowedApps: AppGrant[], + userDeniedBundleIds: ReadonlySet, + selectedDisplayId?: number, +): Promise { + const alreadyGranted = new Set(allowedApps.map((g) => g.bundleId)); + const installed = await adapter.executor.listInstalledApps(); + const resolved = resolveRequestedApps(apps, installed, alreadyGranted); + + // Policy-level auto-deny (baked-in, not user-configurable). Stripped + // before userDenied — checks bundle ID AND display name (covers + // unresolved requests). Precedence: policy > user setting > tier. + const policyDenied: Array<{ requestedName: string; displayName: string }> = + []; + const afterPolicy: typeof resolved = []; + for (const r of resolved) { + const displayName = r.resolved?.displayName ?? r.requestedName; + if (isPolicyDenied(r.resolved?.bundleId, displayName)) { + policyDenied.push({ requestedName: r.requestedName, displayName }); + } else { + afterPolicy.push(r); + } + } + + // User-configured auto-deny (Settings → Desktop app → Computer Use). + // Stripped BEFORE + // tier assignment — these never reach the dialog regardless of category. + // Bundle-ID match only (the Settings UI picks from installed apps, which + // always have a bundle ID). Unresolved requests pass through to the tier + // system; the user can't preemptively deny an app that isn't installed. + const userDenied: Array<{ requestedName: string; displayName: string }> = []; + const surviving: typeof afterPolicy = []; + for (const r of afterPolicy) { + if (r.resolved && userDeniedBundleIds.has(r.resolved.bundleId)) { + userDenied.push({ + requestedName: r.requestedName, + displayName: r.resolved.displayName, + }); + } else { + surviving.push(r); + } + } + + // Collect resolved apps with a restricted tier for the guidance message. + // Unresolved apps with a restricted tier (e.g. model asks for "Chrome" but + // it's not installed) are omitted — they'll end up in the `denied` list + // with reason "not_installed" and the model will see that instead. + const tieredApps: TieredApp[] = []; + for (const r of surviving) { + if (r.proposedTier === "full" || !r.resolved) continue; + tieredApps.push({ + bundleId: r.resolved.bundleId, + displayName: r.resolved.displayName, + tier: r.proposedTier, + }); + } + + // Idempotence: apps that are already granted skip the dialog and are + // merged into the `granted` response. Existing grants keep their tier + // (which may differ from the current proposedTier if policy changed). + const skipDialog = surviving.filter((r) => r.alreadyGranted); + const needDialog = surviving.filter((r) => !r.alreadyGranted); + + // Populate icons only for what the dialog will actually show. Sequential + // awaits are fine — the Swift module is cached (listInstalledApps above + // loaded it), each N-API call is synchronous, and the darwin executor + // memoizes by path. Failures leave iconDataUrl undefined; renderer falls + // back to a grey box. + for (const r of needDialog) { + if (!r.resolved) continue; + try { + r.resolved.iconDataUrl = await adapter.executor.getAppIcon( + r.resolved.path, + ); + } catch { + // leave undefined + } + } + + const now = Date.now(); + const skipDialogGrants: AppGrant[] = skipDialog + .filter((r) => r.resolved) + .map((r) => { + // Reuse the existing grant (preserving grantedAt + tier) rather than + // synthesizing a new one — keeps Settings-page "Granted 3m ago" honest. + const existing = allowedApps.find( + (g) => g.bundleId === r.resolved!.bundleId, + ); + return ( + existing ?? { + bundleId: r.resolved!.bundleId, + displayName: r.resolved!.displayName, + grantedAt: now, + tier: r.proposedTier, + } + ); + }); + + // Preview what will be hidden if the user approves exactly the requested + // set plus what they already have. All tiers are visible, so everything + // resolved goes in the exempt set. + const exemptForPreview = [ + ...allowedApps.map((a) => a.bundleId), + ...surviving.filter((r) => r.resolved).map((r) => r.resolved!.bundleId), + ]; + const willHide = await adapter.executor.previewHideSet( + exemptForPreview, + selectedDisplayId, + ); + + return { + needDialog, + skipDialogGrants, + willHide, + tieredApps, + userDenied, + policyDenied, + }; +} + +/** + * Build guidance text for apps granted at a restricted tier. Returned + * inline in the okJson response so the model knows upfront what it can + * do with each app, instead of learning by hitting the tier gate. + */ +function buildTierGuidanceMessage(tiered: TieredApp[]): string { + // tier "read" is not category-unique — split so browsers get the CiC hint + // and trading platforms get "ask the user" instead. + const readBrowsers = tiered.filter( + (t) => + t.tier === "read" && + getDeniedCategoryForApp(t.bundleId, t.displayName) === "browser", + ); + const readOther = tiered.filter( + (t) => + t.tier === "read" && + getDeniedCategoryForApp(t.bundleId, t.displayName) !== "browser", + ); + const clickTier = tiered.filter((t) => t.tier === "click"); + + const parts: string[] = []; + + if (readBrowsers.length > 0) { + const names = readBrowsers.map((b) => `"${b.displayName}"`).join(", "); + parts.push( + `${names} ${readBrowsers.length === 1 ? "is a browser" : "are browsers"} — ` + + `granted at tier "read" (visible in screenshots only; no clicks or ` + + `typing). You can read what's on screen but cannot navigate, click, ` + + `or type into ${readBrowsers.length === 1 ? "it" : "them"}. For browser ` + + `interaction, use the Claude-in-Chrome MCP (tools named ` + + `\`mcp__Claude_in_Chrome__*\`; load via ToolSearch if deferred).`, + ); + } + + if (readOther.length > 0) { + const names = readOther.map((t) => `"${t.displayName}"`).join(", "); + parts.push( + `${names} ${readOther.length === 1 ? "is" : "are"} granted at tier ` + + `"read" (visible in screenshots only; no clicks or typing). You can ` + + `read what's on screen but cannot interact. Ask the user to take any ` + + `actions in ${readOther.length === 1 ? "this app" : "these apps"} ` + + `themselves.`, + ); + } + + if (clickTier.length > 0) { + const names = clickTier.map((t) => `"${t.displayName}"`).join(", "); + parts.push( + `${names} ${clickTier.length === 1 ? "has" : "have"} terminal or IDE ` + + `capabilities — granted at tier "click" (visible + plain left-click ` + + `only; NO typing, key presses, right-click, modifier-clicks, or ` + + `drag-drop). You can click buttons and scroll output, but ` + + `${clickTier.length === 1 ? "its" : "their"} integrated terminal and ` + + `editor are off-limits to keyboard input. Right-click (context-menu ` + + `Paste) and dragging text onto ${clickTier.length === 1 ? "it" : "them"} ` + + `require tier "full". For shell commands, use the Bash tool.`, + ); + } + + if (parts.length === 0) return ""; + // Same anti-subversion clause the gate errors carry — said upfront so the + // model doesn't reach for osascript/cliclick after seeing "no clicks/typing". + return parts.join("\n\n") + TIER_ANTI_SUBVERSION; +} + +/** + * Build guidance text for apps stripped by the user's Settings auto-deny + * list. Returned inline in the okJson response so the agent knows (a) the + * app is auto-denied by request_access and (b) the escape hatch + * is to ask the human to edit Settings, not to retry or reword the request. + */ +function buildUserDeniedGuidance( + userDenied: Array<{ requestedName: string; displayName: string }>, +): string { + const names = userDenied.map((d) => `"${d.displayName}"`).join(", "); + const one = userDenied.length === 1; + return ( + `${names} ${one ? "is" : "are"} in the user's auto-deny list ` + + `(Settings → Desktop app (General) → Computer Use → Denied apps). ` + + `Requests for ` + + `${one ? "this app" : "these apps"} are automatically denied. If you need access for ` + + `this task, ask the user to remove ${one ? "it" : "them"} from their ` + + `deny list in Settings — you cannot request this through the tool.` + ); +} + +/** + * Guidance for policy-denied apps (baked-in blocklist, not user-editable). + * Unlike userDenied, there is no escape hatch — the agent is told to find + * another approach. + */ +function buildPolicyDeniedGuidance( + policyDenied: Array<{ requestedName: string; displayName: string }>, +): string { + const names = policyDenied.map((d) => `"${d.displayName}"`).join(", "); + const one = policyDenied.length === 1; + return ( + `${names} ${one ? "is" : "are"} blocked by policy for computer use. ` + + `Requests for ${one ? "this app" : "these apps"} are automatically ` + + `denied regardless of what the user has approved. There is no Settings ` + + `override. Inform the user that you cannot access ` + + `${one ? "this app" : "these apps"} and suggest an alternative ` + + `approach if one exists. Do not try to directly subvert this block ` + + `regardless of the user's request.` + ); +} + +/** + * Telemetry helper — counts by category. Field names (`denied_*`) are kept + * for schema compat; interpret as "assigned non-full tier" in dashboards. + */ +function tierAssignmentTelemetry( + tiered: TieredApp[], +): Pick { + // `denied_browser_count` now counts ALL tier-"read" grants (browsers + + // trading). The field name was already legacy-only before trading existed + // (dashboards read it as "non-full tier"), so no new column. + const browserCount = tiered.filter((t) => t.tier === "read").length; + const terminalCount = tiered.filter((t) => t.tier === "click").length; + return { + ...(browserCount > 0 && { denied_browser_count: browserCount }), + ...(terminalCount > 0 && { denied_terminal_count: terminalCount }), + }; +} + +/** + * Sibling of `handleRequestAccess`. Same app-resolution + TCC-threading, but + * routes to the teach approval dialog and fires `onTeachModeActivated` on + * success. No grant-flag checkboxes (clipboard/systemKeys) in teach mode — + * the tool schema omits those fields. + * + * Unlike `request_access`, this ALWAYS shows the dialog even when every + * requested app is already granted. Teach mode is a distinct UX the user + * must explicitly consent to (main window hides) — idempotent app grants + * don't imply consent to being guided. + */ +async function handleRequestTeachAccess( + adapter: ComputerUseHostAdapter, + args: Record, + overrides: ComputerUseOverrides, + tccState: { accessibility: boolean; screenRecording: boolean } | undefined, +): Promise { + if (!overrides.onTeachPermissionRequest) { + return errorResult( + "Teach mode is not available in this session.", + "feature_unavailable", + ); + } + + // Same as handleRequestAccess above — the dialog renders in the hidden + // main window. Model re-calling request_teach_access mid-tour (to add + // another app) is plausible since request_access docs say "call again + // mid-session to add more apps" and this uses the same grant model. + if (overrides.getTeachModeActive?.()) { + return errorResult( + "Teach mode is already active. To add more apps, end the current tour first, then call request_teach_access again with the full app list.", + "teach_mode_conflict", + ); + } + + const reason = requireString(args, "reason"); + if (reason instanceof Error) return errorResult(reason.message, "bad_args"); + + // TCC-ungranted branch — identical to handleRequestAccess's. The renderer + // shows the same TCC toggle panel regardless of which request tool got here. + if (tccState) { + const req: CuTeachPermissionRequest = { + requestId: randomUUID(), + reason, + apps: [], + screenshotFiltering: adapter.executor.capabilities.screenshotFiltering, + tccState, + }; + await overrides.onTeachPermissionRequest(req); + + // Same re-check as handleRequestAccess — user may have granted while the + // dialog was up, and the pre-dialog snapshot would mislead the model. + const recheck = await adapter.ensureOsPermissions(); + if (recheck.granted) { + return errorResult( + "macOS Accessibility and Screen Recording are now both granted. " + + "Call request_teach_access again immediately — the next call will " + + "show the app selection list.", + ); + } + + const missing: string[] = []; + if (!recheck.accessibility) missing.push("Accessibility"); + if (!recheck.screenRecording) missing.push("Screen Recording"); + return errorResult( + `macOS ${missing.join(" and ")} permission(s) not yet granted. ` + + `The permission panel has been shown. Once the user grants the ` + + `missing permission(s), call request_teach_access again.`, + "tcc_not_granted", + ); + } + + const rawApps = args.apps; + if (!Array.isArray(rawApps) || !rawApps.every((a) => typeof a === "string")) { + return errorResult('"apps" must be an array of strings.', "bad_args"); + } + const apps = rawApps as string[]; + + const { + needDialog, + skipDialogGrants, + willHide, + tieredApps, + userDenied, + policyDenied, + } = await buildAccessRequest( + adapter, + apps, + overrides.allowedApps, + new Set(overrides.userDeniedBundleIds), + overrides.selectedDisplayId, + ); + + // All requested apps were user-denied (or unresolvable) and none pre-granted + // — skip the dialog entirely. Without this, onTeachPermissionRequest fires + // with apps:[] and the user sees an empty approval dialog where Allow and + // Deny produce the same result (granted=[] → teachModeActive stays false). + // handleRequestAccess has the equivalent guard at the needDialog.length + // check; teach didn't need one before user-deny because needDialog=[] + // previously implied skipDialogGrants.length > 0 (all-already-granted). + if (needDialog.length === 0 && skipDialogGrants.length === 0) { + return okJson( + { + granted: [], + denied: [], + ...(policyDenied.length > 0 && { + policyDenied: { + apps: policyDenied, + guidance: buildPolicyDeniedGuidance(policyDenied), + }, + }), + ...(userDenied.length > 0 && { + userDenied: { + apps: userDenied, + guidance: buildUserDeniedGuidance(userDenied), + }, + }), + teachModeActive: false, + screenshotFiltering: adapter.executor.capabilities.screenshotFiltering, + }, + { granted_count: 0, denied_count: 0 }, + ); + } + + const req: CuTeachPermissionRequest = { + requestId: randomUUID(), + reason, + apps: needDialog, + screenshotFiltering: adapter.executor.capabilities.screenshotFiltering, + ...(willHide.length > 0 && { + willHide, + autoUnhideEnabled: adapter.getAutoUnhideEnabled(), + }), + }; + const response = await overrides.onTeachPermissionRequest(req); + + const granted = [...skipDialogGrants, ...response.granted]; + // Gate on explicit dialog consent, NOT on merged grant length. + // skipDialogGrants are pre-existing idempotent app grants — they don't + // imply the user said yes to THIS dialog. Without the userConsented + // check, Deny would still activate teach mode whenever any requested + // app was previously granted (worst case: needDialog=[] → Allow and + // Deny payloads are structurally identical). + const teachModeActive = response.userConsented === true && granted.length > 0; + if (teachModeActive) { + overrides.onTeachModeActivated?.(); + } + + const grantedBundleIds = new Set(granted.map((g) => g.bundleId)); + const grantedTieredApps = tieredApps.filter((t) => + grantedBundleIds.has(t.bundleId), + ); + + return okJson( + { + granted, + denied: response.denied, + ...(policyDenied.length > 0 && { + policyDenied: { + apps: policyDenied, + guidance: buildPolicyDeniedGuidance(policyDenied), + }, + }), + ...(userDenied.length > 0 && { + userDenied: { + apps: userDenied, + guidance: buildUserDeniedGuidance(userDenied), + }, + }), + ...(grantedTieredApps.length > 0 && { + tierGuidance: buildTierGuidanceMessage(grantedTieredApps), + }), + teachModeActive, + screenshotFiltering: adapter.executor.capabilities.screenshotFiltering, + }, + { + // response.granted only — skipDialogGrants are idempotent re-grants. + // See handleRequestAccess's parallel comment. + granted_count: response.granted.length, + denied_count: response.denied.length, + ...tierAssignmentTelemetry(grantedTieredApps), + }, + ); +} + +// --------------------------------------------------------------------------- +// teach_step + teach_batch — shared step primitives +// --------------------------------------------------------------------------- + +/** A fully-validated teach step, anchor already scaled to logical points. */ +interface ValidatedTeachStep { + explanation: string; + nextPreview: string; + anchorLogical: TeachStepRequest["anchorLogical"]; + actions: Array>; +} + +/** + * Validate one raw step record and scale its anchor. `label` is prefixed to + * error messages so teach_batch can say `steps[2].actions[0]` instead of + * just `actions[0]`. + * + * The anchor transform is the whole coordinate story: model sends image-pixel + * coords (same space as click coords, per COORDINATES.md), `scaleCoord` turns + * them into logical points against `overrides.lastScreenshot`. For + * teach_batch, lastScreenshot stays at its pre-call value for the entire + * batch — same invariant as computer_batch's "coordinates refer to the + * PRE-BATCH screenshot". Anchors for step 2+ must therefore target elements + * the model can predict will be at those coordinates after step 1's actions. + */ +async function validateTeachStepArgs( + raw: Record, + adapter: ComputerUseHostAdapter, + overrides: ComputerUseOverrides, + label: string, +): Promise { + const explanation = requireString(raw, "explanation"); + if (explanation instanceof Error) { + return new Error(`${label}: ${explanation.message}`); + } + const nextPreview = requireString(raw, "next_preview"); + if (nextPreview instanceof Error) { + return new Error(`${label}: ${nextPreview.message}`); + } + + const actions = raw.actions; + if (!Array.isArray(actions)) { + return new Error( + `${label}: "actions" must be an array (empty is allowed).`, + ); + } + for (const [i, act] of actions.entries()) { + if (typeof act !== "object" || act === null) { + return new Error(`${label}: actions[${i}] must be an object`); + } + const action = (act as Record).action; + if (typeof action !== "string") { + return new Error(`${label}: actions[${i}].action must be a string`); + } + if (!BATCHABLE_ACTIONS.has(action)) { + return new Error( + `${label}: actions[${i}].action="${action}" is not allowed. ` + + `Allowed: ${[...BATCHABLE_ACTIONS].join(", ")}.`, + ); + } + } + + let anchorLogical: TeachStepRequest["anchorLogical"]; + if (raw.anchor !== undefined) { + const anchor = raw.anchor; + if ( + !Array.isArray(anchor) || + anchor.length !== 2 || + typeof anchor[0] !== "number" || + typeof anchor[1] !== "number" || + !Number.isFinite(anchor[0]) || + !Number.isFinite(anchor[1]) + ) { + return new Error( + `${label}: "anchor" must be a [x, y] number tuple or omitted.`, + ); + } + const display = await adapter.executor.getDisplaySize( + overrides.selectedDisplayId, + ); + anchorLogical = scaleCoord( + anchor[0], + anchor[1], + overrides.coordinateMode, + display, + overrides.lastScreenshot, + adapter.logger, + ); + } + + return { + explanation, + nextPreview, + anchorLogical, + actions: actions as Array>, + }; +} + +/** Outcome of showing one tooltip + running its actions. */ +type TeachStepOutcome = + | { kind: "exit" } + | { kind: "ok"; results: BatchActionResult[] } + | { + kind: "action_error"; + executed: number; + failed: BatchActionResult; + remaining: number; + /** The inner action's telemetry (error_kind), forwarded so the + * caller can pass it to okJson and keep cu_tool_call accurate + * when the failure happened inside a batch. */ + telemetry: CuCallTelemetry | undefined; + }; + +/** + * Show the tooltip, block for Next/Exit, run actions on Next. + * + * Action execution is a straight lift from `handleComputerBatch`: + * prepareForAction ONCE per step (the user clicked Next — they consented to + * that step's sequence), pixelValidation OFF (committed sequence), frontmost + * gate still per-action, stop-on-first-error with partial results. + * + * Empty `actions` is valid — "read this, click Next to continue" steps. + * Assumes `overrides.onTeachStep` is set (caller guards). + */ +async function executeTeachStep( + step: ValidatedTeachStep, + adapter: ComputerUseHostAdapter, + overrides: ComputerUseOverrides, + subGates: CuSubGates, +): Promise { + // Block until Next or Exit. Same pending-promise pattern as + // onPermissionRequest — host stores the resolver, overlay IPC fires it. + // `!` is safe: both callers guard on overrides.onTeachStep before reaching here. + const stepResult = await overrides.onTeachStep!({ + explanation: step.explanation, + nextPreview: step.nextPreview, + anchorLogical: step.anchorLogical, + }); + + if (stepResult.action === "exit") { + // The host's Exit handler also calls stopSession, so the turn is + // already unwinding. Caller decides what to return for the transcript. + // A PREVIOUS step's left_mouse_down may have left the OS button held. + await releaseHeldMouse(adapter); + return { kind: "exit" }; + } + + // Next clicked. Flip overlay to spinner before we start driving. + overrides.onTeachWorking?.(); + + if (step.actions.length === 0) { + return { kind: "ok", results: [] }; + } + + if (subGates.hideBeforeAction) { + const hidden = await adapter.executor.prepareForAction( + overrides.allowedApps.map((a) => a.bundleId), + overrides.selectedDisplayId, + ); + if (hidden.length > 0) { + overrides.onAppsHidden?.(hidden); + } + } + + const stepSubGates: CuSubGates = { + ...subGates, + hideBeforeAction: false, + pixelValidation: false, + // Anchors are pre-computed against the display at batch start. + // A mid-batch resolver switch would break tooltip positioning. + autoTargetDisplay: false, + }; + + const results: BatchActionResult[] = []; + for (const [i, act] of step.actions.entries()) { + // Same abort check as handleComputerBatch — Exit calls stopSession so + // this IS the exit path, just caught mid-dispatch instead of at the + // onTeachStep await above. Callers already handle { kind: "exit" }. + if (overrides.isAborted?.()) { + await releaseHeldMouse(adapter); + return { kind: "exit" }; + } + // Same inter-step settle as handleComputerBatch. + if (i > 0) await sleep(10); + const action = act.action as string; + + // Drop mid-step screenshot piggyback — same invariant as computer_batch. + // Click coords stay anchored to the screenshot the model took BEFORE + // calling teach_step/teach_batch. + const { screenshot: _dropped, ...inner } = await dispatchAction( + action, + act, + adapter, + overrides, + stepSubGates, + ); + + const text = firstTextContent(inner); + const result = { action, ok: !inner.isError, output: text }; + results.push(result); + + if (inner.isError) { + await releaseHeldMouse(adapter); + return { + kind: "action_error", + executed: results.length - 1, + failed: result, + remaining: step.actions.length - results.length, + telemetry: inner.telemetry, + }; + } + } + + return { kind: "ok", results }; +} + +/** + * Fold a fresh screenshot into the result. Eliminates the separate + * screenshot tool call the model would otherwise make before the next + * teach_step (one fewer API round trip per step). handleScreenshot + * runs its own prepareForAction — that's correct: actions may have + * opened something outside the allowlist. The .screenshot piggyback + * flows through to serverDef.ts's stash → lastScreenshot updates → + * the next teach_step.anchor scales against THIS image, which is what + * the model is now looking at. + */ +async function appendTeachScreenshot( + resultJson: unknown, + adapter: ComputerUseHostAdapter, + overrides: ComputerUseOverrides, + subGates: CuSubGates, +): Promise { + const shotResult = await handleScreenshot(adapter, overrides, subGates); + if (shotResult.isError) { + // Hide+screenshot failed (rare — e.g. SCContentFilter error). Don't + // tank the step; just omit the image. Model will call screenshot + // itself and see the real error. + return okJson(resultJson); + } + return { + content: [ + { type: "text", text: JSON.stringify(resultJson) }, + // handleScreenshot's content is [maybeMonitorNote, maybeHiddenNote, + // image]. Spread all — both notes are useful context and the model + // expects them alongside screenshots. + ...shotResult.content, + ], + // For serverDef.ts to stash. Next teach_step.anchor scales against this. + screenshot: shotResult.screenshot, + }; +} + +/** + * Show one guided-tour tooltip and block until the user clicks Next or Exit. + * On Next, execute `actions[]` with `computer_batch` semantics. + */ +async function handleTeachStep( + adapter: ComputerUseHostAdapter, + args: Record, + overrides: ComputerUseOverrides, + subGates: CuSubGates, +): Promise { + if (!overrides.onTeachStep) { + return errorResult( + "Teach mode is not active. Call request_teach_access first.", + "teach_mode_not_active", + ); + } + + const step = await validateTeachStepArgs( + args, + adapter, + overrides, + "teach_step", + ); + if (step instanceof Error) return errorResult(step.message, "bad_args"); + + const outcome = await executeTeachStep(step, adapter, overrides, subGates); + + if (outcome.kind === "exit") { + return okJson({ exited: true }); + } + if (outcome.kind === "action_error") { + return okJson( + { + executed: outcome.executed, + failed: outcome.failed, + remaining: outcome.remaining, + }, + outcome.telemetry, + ); + } + + // ok. No screenshot for empty actions — screen didn't change, model's + // existing screenshot is still accurate. + if (step.actions.length === 0) { + return okJson({ executed: 0, results: [] }); + } + return appendTeachScreenshot( + { executed: outcome.results.length, results: outcome.results }, + adapter, + overrides, + subGates, + ); +} + +/** + * Queue a whole guided tour in one tool call. Parallels `computer_batch`: N + * steps → one model→API round trip instead of N. Each step still blocks for + * its own Next click (the user paces the tour), but the model doesn't wait + * for a round trip between steps. + * + * Validates ALL steps upfront so a typo in step 5 doesn't surface after the + * user has already clicked through steps 1–4. + * + * Anchors for every step scale against the pre-call `lastScreenshot` — same + * PRE-BATCH invariant as computer_batch. Steps 2+ should either omit anchor + * (centered tooltip) or target elements the model predicts won't have moved. + * + * Result shape: + * {exited: true, stepsCompleted: N} — user clicked Exit + * {stepsCompleted, stepFailed, executed, failed, …} — action error at step N + * {stepsCompleted, results: [...]} + screenshot — all steps ran + */ +async function handleTeachBatch( + adapter: ComputerUseHostAdapter, + args: Record, + overrides: ComputerUseOverrides, + subGates: CuSubGates, +): Promise { + if (!overrides.onTeachStep) { + return errorResult( + "Teach mode is not active. Call request_teach_access first.", + "teach_mode_not_active", + ); + } + + const rawSteps = args.steps; + if (!Array.isArray(rawSteps) || rawSteps.length < 1) { + return errorResult('"steps" must be a non-empty array.', "bad_args"); + } + + // Validate upfront — fail fast before showing any tooltip. + const steps: ValidatedTeachStep[] = []; + for (const [i, raw] of rawSteps.entries()) { + if (typeof raw !== "object" || raw === null) { + return errorResult(`steps[${i}] must be an object`, "bad_args"); + } + const v = await validateTeachStepArgs( + raw as Record, + adapter, + overrides, + `steps[${i}]`, + ); + if (v instanceof Error) return errorResult(v.message, "bad_args"); + steps.push(v); + } + + const allResults: BatchActionResult[][] = []; + for (const [i, step] of steps.entries()) { + const outcome = await executeTeachStep(step, adapter, overrides, subGates); + + if (outcome.kind === "exit") { + return okJson({ exited: true, stepsCompleted: i }); + } + if (outcome.kind === "action_error") { + return okJson( + { + stepsCompleted: i, + stepFailed: i, + executed: outcome.executed, + failed: outcome.failed, + remaining: outcome.remaining, + results: allResults, + }, + outcome.telemetry, + ); + } + allResults.push(outcome.results); + } + + // Final screenshot only if any step ran actions (screen changed). + const screenChanged = steps.some((s) => s.actions.length > 0); + const resultJson = { stepsCompleted: steps.length, results: allResults }; + if (!screenChanged) { + return okJson(resultJson); + } + return appendTeachScreenshot(resultJson, adapter, overrides, subGates); +} + +/** + * Build the hidden-apps note that accompanies a screenshot. Tells the model + * which apps got hidden (not in allowlist) and how to add them. Returns + * undefined when nothing was hidden since the last screenshot. + */ +async function buildHiddenNote( + adapter: ComputerUseHostAdapter, + hiddenSinceLastSeen: string[], +): Promise { + if (hiddenSinceLastSeen.length === 0) return undefined; + const running = await adapter.executor.listRunningApps(); + const nameOf = new Map(running.map((a) => [a.bundleId, a.displayName])); + const names = hiddenSinceLastSeen.map((id) => nameOf.get(id) ?? id); + const list = names.map((n) => `"${n}"`).join(", "); + const one = names.length === 1; + return ( + `${list} ${one ? "was" : "were"} open and got hidden before this screenshot ` + + `(not in the session allowlist). If a previous action was meant to open ` + + `${one ? "it" : "one of them"}, that's why you don't see it — call ` + + `request_access to add ${one ? "it" : "them"} to the allowlist.` + ); +} + +/** + * Assign a human-readable label to each display. Falls back to `display N` + * when NSScreen.localizedName is undefined; disambiguates identical labels + * (matched-pair external monitors) with a `(2)` suffix. Used by both + * buildMonitorNote and handleSwitchDisplay so the name the model sees in a + * screenshot note is the same name it can pass back to switch_display. + */ +function uniqueDisplayLabels( + displays: readonly DisplayGeometry[], +): Map { + // Sort by displayId so the (N) suffix is stable regardless of + // NSScreen.screens iteration order — same label always maps to same + // physical display across buildMonitorNote → switch_display round-trip, + // even if display configuration reorders between the two calls. + const sorted = [...displays].sort((a, b) => a.displayId - b.displayId); + const counts = new Map(); + const out = new Map(); + for (const d of sorted) { + const base = d.label ?? `display ${d.displayId}`; + const n = (counts.get(base) ?? 0) + 1; + counts.set(base, n); + out.set(d.displayId, n === 1 ? base : `${base} (${n})`); + } + return out; +} + +/** + * Build the monitor-context text that accompanies a screenshot. Tells the + * model which monitor it's looking at (by human name), lists other attached + * monitors, and flags when the monitor changed vs. the previous screenshot. + * + * Only emitted when there are 2+ displays AND (first screenshot OR the + * display changed). Single-monitor setups and steady-state same-monitor + * screenshots get no text — avoids noise. + */ +async function buildMonitorNote( + adapter: ComputerUseHostAdapter, + shotDisplayId: number, + lastDisplayId: number | undefined, + canSwitchDisplay: boolean, +): Promise { + // listDisplays failure (e.g. Swift returns zero screens during monitor + // hot-unplug) must not tank the screenshot — this note is optional context. + let displays; + try { + displays = await adapter.executor.listDisplays(); + } catch (e) { + adapter.logger.warn(`[computer-use] listDisplays failed: ${String(e)}`); + return undefined; + } + if (displays.length < 2) return undefined; + + const labels = uniqueDisplayLabels(displays); + const nameOf = (id: number): string => labels.get(id) ?? `display ${id}`; + + const current = nameOf(shotDisplayId); + const others = displays + .filter((d) => d.displayId !== shotDisplayId) + .map((d) => nameOf(d.displayId)); + const switchHint = canSwitchDisplay + ? " Use switch_display to capture a different monitor." + : ""; + const othersList = + others.length > 0 + ? ` Other attached monitors: ${others.map((n) => `"${n}"`).join(", ")}.` + + switchHint + : ""; + + // 0 is kCGNullDirectDisplay (sentinel from old sessions persisted + // pre-multimon) — treat same as undefined. + if (lastDisplayId === undefined || lastDisplayId === 0) { + return `This screenshot was taken on monitor "${current}".` + othersList; + } + if (lastDisplayId !== shotDisplayId) { + const prev = nameOf(lastDisplayId); + return ( + `This screenshot was taken on monitor "${current}", which is different ` + + `from your previous screenshot (taken on "${prev}").` + + othersList + ); + } + return undefined; +} + +async function handleScreenshot( + adapter: ComputerUseHostAdapter, + overrides: ComputerUseOverrides, + subGates: CuSubGates, +): Promise { + // §2 — empty allowlist → tool error, no screenshot. + if (overrides.allowedApps.length === 0) { + return errorResult( + "No applications are granted for this session. Call request_access first.", + "allowlist_empty", + ); + } + + // Atomic resolve→prepare→capture (one Swift call, no scheduler gap). + // Off → fall through to separate-calls path below. + if (subGates.autoTargetDisplay) { + // Model's explicit switch_display pin overrides everything — Swift's + // straight cuDisplayInfo(forDisplayID:) passthrough, no chase chain. + // Otherwise sticky display: only auto-resolve when the allowed-app + // set has changed since the display was last resolved. Prevents the + // resolver yanking the display on every screenshot. + const allowedBundleIds = overrides.allowedApps.map((a) => a.bundleId); + const currentAppSetKey = allowedBundleIds.slice().sort().join(","); + const appSetChanged = currentAppSetKey !== overrides.displayResolvedForApps; + const autoResolve = !overrides.displayPinnedByModel && appSetChanged; + + const result = await adapter.executor.resolvePrepareCapture({ + allowedBundleIds, + preferredDisplayId: overrides.selectedDisplayId, + autoResolve, + // Keep the hideBeforeAction sub-gate independently rollable — + // atomic path honors the same toggle the non-atomic path checks + // at the prepareForAction call site. + doHide: subGates.hideBeforeAction, + }); + + // Non-atomic path's takeScreenshotWithRetry has a MIN_SCREENSHOT_BYTES + // check + retry. The atomic call is expensive (resolve+prepare+capture), + // so no retry here — just a warning when the result is implausibly + // small (transient display state like sleep wake). Skip when + // captureError is set (base64 is intentionally empty then). + if ( + result.captureError === undefined && + decodedByteLength(result.base64) < MIN_SCREENSHOT_BYTES + ) { + adapter.logger.warn( + `[computer-use] resolvePrepareCapture result implausibly small (${decodedByteLength(result.base64)} bytes decoded) — possible transient display state`, + ); + } + + // Resolver picked a different display than the session had selected + // (host window moved, or allowed app on a different display). Write + // the pick back to session so teach overlay positioning and subsequent + // non-resolver calls track the same display. Fire-and-forget. + if (result.displayId !== overrides.selectedDisplayId) { + adapter.logger.debug( + `[computer-use] resolver: preferred=${overrides.selectedDisplayId} resolved=${result.displayId}`, + ); + overrides.onResolvedDisplayUpdated?.(result.displayId); + } + // Record the app set this display was resolved for, so the next + // screenshot skips auto-resolve until the set changes again. Gated on + // autoResolve (not just appSetChanged) — when pinned, we didn't + // actually resolve, so don't update the key. + if (autoResolve) { + overrides.onDisplayResolvedForApps?.(currentAppSetKey); + } + + // Report hidden apps only when the model has already seen the screen. + let hiddenSinceLastSeen: string[] = []; + if (overrides.lastScreenshot !== undefined) { + hiddenSinceLastSeen = result.hidden; + } + if (result.hidden.length > 0) { + overrides.onAppsHidden?.(result.hidden); + } + + // Partial-success case: hide succeeded, capture failed (SCK perm + // revoked mid-session). onAppsHidden fired above so auto-unhide will + // restore hidden apps at turn end. Now surface the error to the model. + if (result.captureError !== undefined) { + return errorResult(result.captureError, "capture_failed"); + } + + const hiddenNote = await buildHiddenNote(adapter, hiddenSinceLastSeen); + + // Cherry-pick — don't spread `result` (would leak resolver fields into lastScreenshot). + const shot: ScreenshotResult = { + base64: result.base64, + width: result.width, + height: result.height, + displayWidth: result.displayWidth, + displayHeight: result.displayHeight, + displayId: result.displayId, + originX: result.originX, + originY: result.originY, + }; + + const monitorNote = await buildMonitorNote( + adapter, + shot.displayId, + overrides.lastScreenshot?.displayId, + overrides.onDisplayPinned !== undefined, + ); + + return { + content: [ + ...(monitorNote ? [{ type: "text" as const, text: monitorNote }] : []), + ...(hiddenNote ? [{ type: "text" as const, text: hiddenNote }] : []), + { + type: "image", + data: shot.base64, + mimeType: "image/jpeg", + }, + ], + screenshot: shot, + }; + } + + // Same hide+defocus sequence as input actions. Screenshot needs hide too + // — if a non-allowlisted app is on top, SCContentFilter would composite it + // out, but the pixels BELOW it are what the model would see, and those are + // NOT what's actually there. Hiding first makes the screenshot TRUE. + let hiddenSinceLastSeen: string[] = []; + if (subGates.hideBeforeAction) { + const hidden = await adapter.executor.prepareForAction( + overrides.allowedApps.map((a) => a.bundleId), + overrides.selectedDisplayId, + ); + // "Something appeared since the model last looked." Report whenever: + // (a) prepare hid something AND + // (b) the model has ALREADY SEEN the screen (lastScreenshot is set). + // + // (b) is the discriminator that silences the first screenshot's + // expected-noise hide. NOT a delta against a cumulative set — that was + // the earlier bug: cuHiddenDuringTurn only grows, so once Preview is in + // it (from the first screenshot's hide), subsequent re-hides of Preview + // delta to zero. The double-click → Preview opens → re-hide → silent + // loop never breaks. + // + // With this check: every re-hide fires. If the model loops "click → file + // opens in Preview → screenshot → Preview hidden", it gets told EVERY + // time. Eventually it'll request_access for Preview (or give up). + // + // False positive: user alt-tabs mid-turn → Safari re-hidden → reported. + // Rare, and "Safari appeared" is at worst mild noise — far better than + // the false-negative of never explaining why the file vanished. + if (overrides.lastScreenshot !== undefined) { + hiddenSinceLastSeen = hidden; + } + if (hidden.length > 0) { + overrides.onAppsHidden?.(hidden); + } + } + + const allowedBundleIds = overrides.allowedApps.map((g) => g.bundleId); + const shot = await takeScreenshotWithRetry( + adapter.executor, + allowedBundleIds, + adapter.logger, + overrides.selectedDisplayId, + ); + + const hiddenNote = await buildHiddenNote(adapter, hiddenSinceLastSeen); + + const monitorNote = await buildMonitorNote( + adapter, + shot.displayId, + overrides.lastScreenshot?.displayId, + overrides.onDisplayPinned !== undefined, + ); + + return { + content: [ + ...(monitorNote ? [{ type: "text" as const, text: monitorNote }] : []), + ...(hiddenNote ? [{ type: "text" as const, text: hiddenNote }] : []), + { + type: "image", + data: shot.base64, + mimeType: "image/jpeg", + }, + ], + // Piggybacked for serverDef.ts to stash on InternalServerContext. + screenshot: shot, + }; +} + +/** + * Region-crop upscaled screenshot. Coord invariant (computer_use_v2.py:1092): + * click coords ALWAYS refer to the full-screen screenshot, never the zoom. + * Enforced structurally: this handler's return has NO `.screenshot` field, + * so serverDef.ts's `if (result.screenshot)` branch cannot fire and + * `cuLastScreenshot` is never touched. `executor.zoom()`'s return type also + * lacks displayWidth/displayHeight, so it's not assignable to + * `ScreenshotResult` even by accident. + */ +async function handleZoom( + adapter: ComputerUseHostAdapter, + args: Record, + overrides: ComputerUseOverrides, +): Promise { + // region: [x0, y0, x1, y1] in IMAGE-PX of lastScreenshot — same space the + // model reads click coords from. + const region = args.region; + if (!Array.isArray(region) || region.length !== 4) { + return errorResult( + "region must be an array of length 4: [x0, y0, x1, y1]", + "bad_args", + ); + } + const [x0, y0, x1, y1] = region; + if (![x0, y0, x1, y1].every((v) => typeof v === "number" && v >= 0)) { + return errorResult( + "region values must be non-negative numbers", + "bad_args", + ); + } + if (x1 <= x0) + return errorResult("region x1 must be greater than x0", "bad_args"); + if (y1 <= y0) + return errorResult("region y1 must be greater than y0", "bad_args"); + + const last = overrides.lastScreenshot; + if (!last) { + return errorResult( + "take a screenshot before zooming (region coords are relative to it)", + "state_conflict", + ); + } + if (x1 > last.width || y1 > last.height) { + return errorResult( + `region exceeds screenshot bounds (${last.width}×${last.height})`, + "bad_args", + ); + } + + // image-px → logical-pt. Same ratio as scaleCoord (:198-199) — + // displayWidth / width, not 1/scaleFactor. The ratio is folded. + const ratioX = last.displayWidth / last.width; + const ratioY = last.displayHeight / last.height; + const regionLogical = { + x: x0 * ratioX, + y: y0 * ratioY, + w: (x1 - x0) * ratioX, + h: (y1 - y0) * ratioY, + }; + + const allowedIds = overrides.allowedApps.map((g) => g.bundleId); + // Crop from the same display as lastScreenshot so the zoom region + // matches the image the model is reading coords from. + const zoomed = await adapter.executor.zoom( + regionLogical, + allowedIds, + last.displayId, + ); + + // Return the image. NO `.screenshot` piggyback — this is the invariant. + return { + content: [{ type: "image", data: zoomed.base64, mimeType: "image/jpeg" }], + }; +} + +/** Shared handler for all five click variants. */ +async function handleClickVariant( + adapter: ComputerUseHostAdapter, + args: Record, + overrides: ComputerUseOverrides, + subGates: CuSubGates, + button: "left" | "right" | "middle", + count: 1 | 2 | 3, +): Promise { + // A prior left_mouse_down may have set mouseButtonHeld without a matching + // left_mouse_up (e.g. drag rejected by a tier gate, model falls back to + // left_click). executor.click() does its own mouseDown+mouseUp, releasing + // the OS button — but without this, the JS flag stays true and all + // subsequent mouse_move calls take the held-button path ("mouse"/ + // "mouse_full" actionKind + hit-test), causing spurious rejections on + // click-tier and read-tier windows. Release first so click() gets a clean + // slate. + if (mouseButtonHeld) { + await adapter.executor.mouseUp(); + mouseButtonHeld = false; + mouseMoved = false; + } + + const coord = extractCoordinate(args); + if (coord instanceof Error) return errorResult(coord.message, "bad_args"); + const [rawX, rawY] = coord; + + // left_click(coordinate=[x,y], text="shift") — hold modifiers + // during the click. Same chord parsing as the key tool. + let modifiers: string[] | undefined; + if (args.text !== undefined) { + if (typeof args.text !== "string") { + return errorResult("text must be a string", "bad_args"); + } + // Same gate as handleKey/handleHoldKey. withModifiers presses each name + // via native.key(m, "press") — a non-modifier like "q" in text="cmd+q" + // gets pressed while Cmd is held → Cmd+Q fires before the click. + if ( + isSystemKeyCombo(args.text, adapter.executor.capabilities.platform) && + !overrides.grantFlags.systemKeyCombos + ) { + return errorResult( + `The modifier chord "${args.text}" would fire a system shortcut. ` + + "Request the systemKeyCombos grant flag via request_access, or use " + + "only modifier keys (shift, ctrl, alt, cmd) in the text parameter.", + "grant_flag_required", + ); + } + modifiers = parseKeyChord(args.text); + } + + // Right/middle-click and any click with a modifier chord escalate to + // keyboard-equivalent input at tier "click" (context-menu Paste, chord + // keystrokes). Compute once, pass to both gates. + const clickActionKind: CuActionKind = + button !== "left" || (modifiers !== undefined && modifiers.length > 0) + ? "mouse_full" + : "mouse"; + + const gate = await runInputActionGates( + adapter, + overrides, + subGates, + clickActionKind, + ); + if (gate) return gate; + + const display = await adapter.executor.getDisplaySize( + overrides.selectedDisplayId, + ); + + // §6 item P — pixel-validation staleness check. Sub-gated. + // Runs AFTER the gates (no point validating if we're about to refuse + // anyway) but BEFORE the executor call. + if (subGates.pixelValidation) { + const { xPct, yPct } = coordToPercentageForPixelCompare( + rawX, + rawY, + overrides.coordinateMode, + overrides.lastScreenshot, + ); + const validation = await validateClickTarget( + adapter.cropRawPatch, + overrides.lastScreenshot, + xPct, + yPct, + async () => { + // The fresh screenshot for validation uses the SAME allow-set as + // the model's last screenshot did, so we compare like with like. + const allowedIds = overrides.allowedApps.map((g) => g.bundleId); + try { + // Fresh shot must match lastScreenshot's display, not the current + // selection — pixel-compare is against the model's last image. + return await adapter.executor.screenshot({ + allowedBundleIds: allowedIds, + displayId: overrides.lastScreenshot?.displayId, + }); + } catch { + return null; + } + }, + adapter.logger, + ); + if (!validation.valid && validation.warning) { + // Warning result — model told to re-screenshot. + return okText(validation.warning); + } + } + + const { x, y } = scaleCoord( + rawX, + rawY, + overrides.coordinateMode, + display, + overrides.lastScreenshot, + adapter.logger, + ); + + const hitGate = await runHitTestGate( + adapter, + overrides, + subGates, + x, + y, + clickActionKind, + ); + if (hitGate) return hitGate; + + await adapter.executor.click(x, y, button, count, modifiers); + return okText("Clicked."); +} + +async function handleType( + adapter: ComputerUseHostAdapter, + args: Record, + overrides: ComputerUseOverrides, + subGates: CuSubGates, +): Promise { + const text = requireString(args, "text"); + if (text instanceof Error) return errorResult(text.message, "bad_args"); + + const gate = await runInputActionGates( + adapter, + overrides, + subGates, + "keyboard", + ); + if (gate) return gate; + + // §6 item 3 — clipboard-paste fast path. On macOS we also prefer the + // clipboard path for ordinary text when clipboardWrite is granted, because + // IME/input-source state can corrupt keystroke-by-keystroke typing even for + // plain ASCII. The save/restore + read-back-verify lives in the EXECUTOR, + // not here. Here we just route. + const viaClipboard = + (text.includes("\n") || + (adapter.executor.capabilities.platform === "darwin" && text.length > 1)) && + overrides.grantFlags.clipboardWrite && + subGates.clipboardPasteMultiline; + + if (viaClipboard) { + await adapter.executor.type(text, { viaClipboard: true }); + return okText("Typed (via clipboard)."); + } + + // §6 item 7 — grapheme-cluster iteration. Prevents ZWJ emoji → �. + // §6 item 4 — 8ms between graphemes (125 Hz USB polling). Battle-tested: + // sleep BEFORE each keystroke, not after. + // + // \n, \r, \t MUST route through executor.key(), not type(). Two reasons: + // 1. enigo.text("\n") on macOS posts a stale CGEvent with virtualKey=0 + // after stripping the newline — virtualKey 0 is the 'a' key, so a + // ghost 'a' gets typed. Upstream bug in enigo 0.6.1 fast_text(). + // 2. Unicode text-insertion of '\n' is not a Return key press. URL bars + // and terminals ignore it; the model's intent (submit/execute) is lost. + // CRLF (\r\n) is one grapheme cluster (UAX #29 GB3), so check for it too. + const graphemes = segmentGraphemes(text); + for (const [i, g] of graphemes.entries()) { + // Same abort check as handleComputerBatch. At 8ms/grapheme a 50-char + // type() runs ~400ms; this is where an in-flight batch actually + // spends its time. + if (overrides.isAborted?.()) { + return errorResult( + `Typing aborted after ${i} of ${graphemes.length} graphemes (user interrupt).`, + ); + } + await sleep(INTER_GRAPHEME_SLEEP_MS); + if (g === "\n" || g === "\r" || g === "\r\n") { + await adapter.executor.key("return"); + } else if (g === "\t") { + await adapter.executor.key("tab"); + } else { + await adapter.executor.type(g, { viaClipboard: false }); + } + } + return okText(`Typed ${graphemes.length} grapheme(s).`); +} + +async function handleKey( + adapter: ComputerUseHostAdapter, + args: Record, + overrides: ComputerUseOverrides, + subGates: CuSubGates, +): Promise { + const keySequence = requireString(args, "text"); + if (keySequence instanceof Error) + return errorResult("text is required", "bad_args"); + + // Cap 100, error strings match. + let repeat: number | undefined; + if (args.repeat !== undefined) { + if ( + typeof args.repeat !== "number" || + !Number.isInteger(args.repeat) || + args.repeat < 1 + ) { + return errorResult("repeat must be a positive integer", "bad_args"); + } + if (args.repeat > 100) { + return errorResult("repeat exceeds maximum of 100", "bad_args"); + } + repeat = args.repeat; + } + + // §2 — blocklist check BEFORE gates. A blocked combo with an ungranted + // app frontmost should return the blocklist error, not the frontmost + // error — the model's fix is to request the flag, not change focus. + if ( + isSystemKeyCombo(keySequence, adapter.executor.capabilities.platform) && + !overrides.grantFlags.systemKeyCombos + ) { + return errorResult( + `"${keySequence}" is a system-level shortcut. Request the \`systemKeyCombos\` grant via request_access to use it.`, + "grant_flag_required", + ); + } + + const gate = await runInputActionGates( + adapter, + overrides, + subGates, + "keyboard", + ); + if (gate) return gate; + + await adapter.executor.key(keySequence, repeat); + return okText("Key pressed."); +} + +async function handleScroll( + adapter: ComputerUseHostAdapter, + args: Record, + overrides: ComputerUseOverrides, + subGates: CuSubGates, +): Promise { + const coord = extractCoordinate(args); + if (coord instanceof Error) return errorResult(coord.message, "bad_args"); + const [rawX, rawY] = coord; + + // Uses scroll_direction + scroll_amount. + // Map to our dx/dy executor interface. + const dir = args.scroll_direction; + if (dir !== "up" && dir !== "down" && dir !== "left" && dir !== "right") { + return errorResult( + "scroll_direction must be 'up', 'down', 'left', or 'right'", + "bad_args", + ); + } + const amount = args.scroll_amount; + if (typeof amount !== "number" || !Number.isInteger(amount) || amount < 0) { + return errorResult("scroll_amount must be a non-negative int", "bad_args"); + } + if (amount > 100) { + return errorResult("scroll_amount exceeds maximum of 100", "bad_args"); + } + // up → dy = -amount; down → dy = +amount; left → dx = -amount; right → dx = +amount. + const dx = dir === "left" ? -amount : dir === "right" ? amount : 0; + const dy = dir === "up" ? -amount : dir === "down" ? amount : 0; + + const gate = await runInputActionGates(adapter, overrides, subGates, "mouse"); + if (gate) return gate; + + const display = await adapter.executor.getDisplaySize( + overrides.selectedDisplayId, + ); + const { x, y } = scaleCoord( + rawX, + rawY, + overrides.coordinateMode, + display, + overrides.lastScreenshot, + adapter.logger, + ); + + // When the button is held, executor.scroll's internal moveMouse generates + // a leftMouseDragged event (enigo reads NSEvent.pressedMouseButtons) — + // same mechanism as handleMoveMouse's held-button path. Upgrade the + // hit-test to "mouse_full" so scroll can't be used to drag-drop text onto + // a click-tier terminal, and mark mouseMoved so the subsequent + // left_mouse_up hit-tests as a drop not a click-release. + const hitGate = await runHitTestGate( + adapter, + overrides, + subGates, + x, + y, + mouseButtonHeld ? "mouse_full" : "mouse", + ); + if (hitGate) return hitGate; + if (mouseButtonHeld) mouseMoved = true; + + await adapter.executor.scroll(x, y, dx, dy); + return okText("Scrolled."); +} + +async function handleDrag( + adapter: ComputerUseHostAdapter, + args: Record, + overrides: ComputerUseOverrides, + subGates: CuSubGates, +): Promise { + // executor.drag() does its own press+release internally. Without this + // defensive clear, a prior left_mouse_down leaves mouseButtonHeld=true + // across the drag and desyncs the flag from OS state — same mechanism as + // the handleClickVariant clear above. Release first so drag() gets a + // clean slate. + if (mouseButtonHeld) { + await adapter.executor.mouseUp(); + mouseButtonHeld = false; + mouseMoved = false; + } + + // `coordinate` is the END point + // (required). `start_coordinate` is OPTIONAL — when omitted, drag from + // current cursor position. + const endCoord = extractCoordinate(args, "coordinate"); + if (endCoord instanceof Error) + return errorResult(endCoord.message, "bad_args"); + const rawTo = endCoord; + + let rawFrom: [number, number] | undefined; + if (args.start_coordinate !== undefined) { + const startCoord = extractCoordinate(args, "start_coordinate"); + if (startCoord instanceof Error) + return errorResult(startCoord.message, "bad_args"); + rawFrom = startCoord; + } + // else: rawFrom stays undefined → executor drags from current cursor. + + const gate = await runInputActionGates(adapter, overrides, subGates, "mouse"); + if (gate) return gate; + + const display = await adapter.executor.getDisplaySize( + overrides.selectedDisplayId, + ); + const from = + rawFrom === undefined + ? undefined + : scaleCoord( + rawFrom[0], + rawFrom[1], + overrides.coordinateMode, + display, + overrides.lastScreenshot, + adapter.logger, + ); + const to = scaleCoord( + rawTo[0], + rawTo[1], + overrides.coordinateMode, + display, + overrides.lastScreenshot, + adapter.logger, + ); + + // Check both drag endpoints. `from` is where the mouseDown happens (picks + // up), `to` is where mouseUp happens (drops). When start_coordinate is + // omitted the drag begins at the cursor — same bypass as mouse_move → + // left_mouse_down, so read the cursor and hit-test it (mirrors + // handleLeftMouseDown). + // + // The `to` endpoint uses "mouse_full" (not "mouse"): dropping text onto a + // terminal inserts it as if typed (macOS text drag-drop). Same threat as + // right-click→Paste. `from` stays "mouse" — picking up is a read. + const fromPoint = from ?? (await adapter.executor.getCursorPosition()); + const fromGate = await runHitTestGate( + adapter, + overrides, + subGates, + fromPoint.x, + fromPoint.y, + "mouse", + ); + if (fromGate) return fromGate; + const toGate = await runHitTestGate( + adapter, + overrides, + subGates, + to.x, + to.y, + "mouse_full", + ); + if (toGate) return toGate; + + await adapter.executor.drag(from, to); + return okText("Dragged."); +} + +async function handleMoveMouse( + adapter: ComputerUseHostAdapter, + args: Record, + overrides: ComputerUseOverrides, + subGates: CuSubGates, +): Promise { + const coord = extractCoordinate(args); + if (coord instanceof Error) return errorResult(coord.message, "bad_args"); + const [rawX, rawY] = coord; + + // When the button is held, moveMouse generates leftMouseDragged events on + // the window under the cursor — that's interaction, not positioning. + // Upgrade to "mouse" and hit-test the destination. When the button is NOT + // held: pure positioning, passes at any tier, no hit-test (mouseDown/Up + // hit-test the cursor to close the mouse_move→left_mouse_down decomposition). + const actionKind: CuActionKind = mouseButtonHeld ? "mouse" : "mouse_position"; + const gate = await runInputActionGates( + adapter, + overrides, + subGates, + actionKind, + ); + if (gate) return gate; + + const display = await adapter.executor.getDisplaySize( + overrides.selectedDisplayId, + ); + const { x, y } = scaleCoord( + rawX, + rawY, + overrides.coordinateMode, + display, + overrides.lastScreenshot, + adapter.logger, + ); + + if (mouseButtonHeld) { + // "mouse_full" — same as left_click_drag's to-endpoint. Dragging onto a + // click-tier terminal is text injection regardless of which primitive + // (atomic drag vs. decomposed down/move/up) delivers the events. + const hitGate = await runHitTestGate( + adapter, + overrides, + subGates, + x, + y, + "mouse_full", + ); + if (hitGate) return hitGate; + } + + await adapter.executor.moveMouse(x, y); + if (mouseButtonHeld) mouseMoved = true; + return okText("Moved."); +} + +async function handleOpenApplication( + adapter: ComputerUseHostAdapter, + args: Record, + overrides: ComputerUseOverrides, +): Promise { + const app = requireString(args, "app"); + if (app instanceof Error) return errorResult(app.message, "bad_args"); + + // Resolve display-name → bundle ID. Same logic as request_access. + const allowed = new Set(overrides.allowedApps.map((g) => g.bundleId)); + let targetBundleId: string | undefined; + + if (looksLikeBundleId(app) && allowed.has(app)) { + targetBundleId = app; + } else { + // Try display name → bundle ID, but ONLY against the allowlist itself. + // Avoids paying the listInstalledApps() cost on the hot path and is + // arguably more correct: if the user granted "Slack", the model asking + // to open "Slack" should match THAT grant. + const match = overrides.allowedApps.find( + (g) => g.displayName.toLowerCase() === app.toLowerCase(), + ); + targetBundleId = match?.bundleId; + } + + if (!targetBundleId || !allowed.has(targetBundleId)) { + return errorResult( + `"${app}" is not granted for this session. Call request_access first.`, + "app_not_granted", + ); + } + + // open_application works at any tier — bringing an app forward is exactly + // what tier "read" enables (you need it on screen to screenshot it). The + // tier gates on click/type catch any follow-up interaction. + + await adapter.executor.openApp(targetBundleId); + + // On multi-monitor setups, macOS may place the opened window on a monitor + // the resolver won't pick (e.g. Claude + another allowed app are co-located + // elsewhere). Nudge the model toward switch_display BEFORE it wastes steps + // clicking on dock icons. Single-monitor → no hint. listDisplays failure is + // non-fatal — the hint is advisory. + if (overrides.onDisplayPinned !== undefined) { + let displayCount = 1; + try { + displayCount = (await adapter.executor.listDisplays()).length; + } catch { + // hint skipped + } + if (displayCount >= 2) { + return okText( + `Opened "${app}". If it isn't visible in the next screenshot, it may ` + + `have opened on a different monitor — use switch_display to check.`, + ); + } + } + + return okText(`Opened "${app}".`); +} + +async function handleSwitchDisplay( + adapter: ComputerUseHostAdapter, + args: Record, + overrides: ComputerUseOverrides, +): Promise { + const display = requireString(args, "display"); + if (display instanceof Error) return errorResult(display.message, "bad_args"); + + if (!overrides.onDisplayPinned) { + return errorResult( + "Display switching is not available in this session.", + "feature_unavailable", + ); + } + + if (display.toLowerCase() === "auto") { + overrides.onDisplayPinned(undefined); + return okText( + "Returned to automatic monitor selection. Call screenshot to continue.", + ); + } + + // Resolve label → displayId fresh. Same source buildMonitorNote reads, + // so whatever name the model saw in a screenshot note resolves here. + let displays; + try { + displays = await adapter.executor.listDisplays(); + } catch (e) { + return errorResult( + `Failed to enumerate displays: ${String(e)}`, + "display_error", + ); + } + + if (displays.length < 2) { + return errorResult( + "Only one monitor is connected. There is nothing to switch to.", + "bad_args", + ); + } + + const labels = uniqueDisplayLabels(displays); + const wanted = display.toLowerCase(); + const target = displays.find( + (d) => labels.get(d.displayId)?.toLowerCase() === wanted, + ); + if (!target) { + const available = displays + .map((d) => `"${labels.get(d.displayId)}"`) + .join(", "); + return errorResult( + `No monitor named "${display}" is connected. Available monitors: ${available}.`, + "bad_args", + ); + } + + overrides.onDisplayPinned(target.displayId); + return okText( + `Switched to monitor "${labels.get(target.displayId)}". Call screenshot to see it.`, + ); +} + +function handleListGrantedApplications( + overrides: ComputerUseOverrides, +): CuCallToolResult { + return okJson({ + allowedApps: overrides.allowedApps, + grantFlags: overrides.grantFlags, + }); +} + +async function handleReadClipboard( + adapter: ComputerUseHostAdapter, + overrides: ComputerUseOverrides, + subGates: CuSubGates, +): Promise { + if (!overrides.grantFlags.clipboardRead) { + return errorResult( + "Clipboard read is not granted. Request `clipboardRead` via request_access.", + "grant_flag_required", + ); + } + + // read_clipboard doesn't route through runInputActionGates — sync here so + // reading after clicking into a click-tier app sees the cleared clipboard + // (same as what the app's own Paste would see). + if (subGates.clipboardGuard) { + const frontmost = await adapter.executor.getFrontmostApp(); + const tierByBundleId = new Map( + overrides.allowedApps.map((a) => [a.bundleId, a.tier] as const), + ); + const frontmostTier = frontmost + ? tierByBundleId.get(frontmost.bundleId) + : undefined; + await syncClipboardStash(adapter, overrides, frontmostTier === "click"); + } + + // clipboardGuard may have stashed+cleared — read the actual (possibly + // empty) clipboard. The agent sees what the app would see. + const text = await adapter.executor.readClipboard(); + return okJson({ text }); +} + +async function handleWriteClipboard( + adapter: ComputerUseHostAdapter, + args: Record, + overrides: ComputerUseOverrides, + subGates: CuSubGates, +): Promise { + if (!overrides.grantFlags.clipboardWrite) { + return errorResult( + "Clipboard write is not granted. Request `clipboardWrite` via request_access.", + "grant_flag_required", + ); + } + const text = requireString(args, "text"); + if (text instanceof Error) return errorResult(text.message, "bad_args"); + + if (subGates.clipboardGuard) { + const frontmost = await adapter.executor.getFrontmostApp(); + const tierByBundleId = new Map( + overrides.allowedApps.map((a) => [a.bundleId, a.tier] as const), + ); + const frontmostTier = frontmost + ? tierByBundleId.get(frontmost.bundleId) + : undefined; + + // Defense-in-depth for the clipboardGuard bypass: write_clipboard + + // left_click on a click-tier app's UI Paste button. The re-clear in + // syncClipboardStash already defeats it (the next action clobbers the + // write), but rejecting here gives the agent a clear signal instead of + // silently voiding its write. + if (frontmost && frontmostTier === "click") { + return errorResult( + `"${frontmost.displayName}" is a tier-"click" app and currently ` + + `frontmost. write_clipboard is blocked because the next action ` + + `would clear the clipboard anyway — a UI Paste button in this ` + + `app cannot be used to inject text. Bring a tier-"full" app ` + + `forward before writing to the clipboard.` + + TIER_ANTI_SUBVERSION, + "tier_insufficient", + ); + } + + // write_clipboard doesn't route through runInputActionGates — sync here + // so clicking away from a click-tier app then writing restores the user's + // stash before the agent's text lands. + await syncClipboardStash(adapter, overrides, frontmostTier === "click"); + } + + await adapter.executor.writeClipboard(text); + return okText("Clipboard written."); +} + +/** + * wait(duration=N). Sleeps N seconds, capped at 100. + * No frontmost gate — no input, nothing to protect. Kill-switch + TCC + * are checked in handleToolCall before dispatch reaches here. + */ +async function handleWait( + args: Record, +): Promise { + const duration = args.duration; + if (typeof duration !== "number" || !Number.isFinite(duration)) { + return errorResult("duration must be a number", "bad_args"); + } + if (duration < 0) { + return errorResult("duration must be non-negative", "bad_args"); + } + if (duration > 100) { + return errorResult( + "duration is too long. Duration is in seconds.", + "bad_args", + ); + } + await sleep(duration * 1000); + return okText(`Waited ${duration}s.`); +} + +/** + * Returns "X=...,Y=..." plain text. We return richer JSON with + * coordinateSpace annotation — the model handles both shapes. + * + * When lastScreenshot is present: inverse of scaleCoord — logical points → + * image-pixels via `imageX = logicalX × (screenshotWidth / displayWidth)`. + * Uses capture-time dims so the returned coords match what the model would + * read off that screenshot. + * + * No frontmost gate — read-only, no input. + */ +async function handleCursorPosition( + adapter: ComputerUseHostAdapter, + overrides: ComputerUseOverrides, +): Promise { + const logical = await adapter.executor.getCursorPosition(); + const shot = overrides.lastScreenshot; + if (shot) { + // Inverse of scaleCoord: subtract capture-time origin to go from + // virtual-screen to display-relative before the image-px transform. + const localX = logical.x - shot.originX; + const localY = logical.y - shot.originY; + // Cursor off the captured display (multi-monitor): local coords go + // negative or exceed display dims. Return logical_points + hint rather + // than garbage image-px. + if ( + localX < 0 || + localX > shot.displayWidth || + localY < 0 || + localY > shot.displayHeight + ) { + return okJson({ + x: logical.x, + y: logical.y, + coordinateSpace: "logical_points", + note: "cursor is on a different monitor than your last screenshot; take a fresh screenshot", + }); + } + const x = Math.round(localX * (shot.width / shot.displayWidth)); + const y = Math.round(localY * (shot.height / shot.displayHeight)); + return okJson({ x, y, coordinateSpace: "image_pixels" }); + } + return okJson({ + x: logical.x, + y: logical.y, + coordinateSpace: "logical_points", + note: "take a screenshot first for image-pixel coordinates", + }); +} + +/** + * Presses each key in the + * chord, sleeps duration seconds, releases in reverse. Same duration bounds + * as wait. Keyboard action → frontmost gate applies; same systemKeyCombos + * blocklist check as key. + */ +async function handleHoldKey( + adapter: ComputerUseHostAdapter, + args: Record, + overrides: ComputerUseOverrides, + subGates: CuSubGates, +): Promise { + const text = requireString(args, "text"); + if (text instanceof Error) return errorResult(text.message, "bad_args"); + + const duration = args.duration; + if (typeof duration !== "number" || !Number.isFinite(duration)) { + return errorResult("duration must be a number", "bad_args"); + } + if (duration < 0) { + return errorResult("duration must be non-negative", "bad_args"); + } + if (duration > 100) { + return errorResult( + "duration is too long. Duration is in seconds.", + "bad_args", + ); + } + + // Blocklist check BEFORE gates — same reasoning as handleKey. Holding + // cmd+q is just as dangerous as tapping it. + if ( + isSystemKeyCombo(text, adapter.executor.capabilities.platform) && + !overrides.grantFlags.systemKeyCombos + ) { + return errorResult( + `"${text}" is a system-level shortcut. Request the \`systemKeyCombos\` grant via request_access to use it.`, + "grant_flag_required", + ); + } + + const gate = await runInputActionGates( + adapter, + overrides, + subGates, + "keyboard", + ); + if (gate) return gate; + + const keyNames = parseKeyChord(text); + await adapter.executor.holdKey(keyNames, duration * 1000); + return okText("Key held."); +} + +/** + * Raw press at current cursor, no coordinate. + * Move first with mouse_move. Errors if already held. + */ +async function handleLeftMouseDown( + adapter: ComputerUseHostAdapter, + overrides: ComputerUseOverrides, + subGates: CuSubGates, +): Promise { + if (mouseButtonHeld) { + return errorResult( + "mouse button already held, call left_mouse_up first", + "state_conflict", + ); + } + + const gate = await runInputActionGates(adapter, overrides, subGates, "mouse"); + if (gate) return gate; + + // macOS routes mouseDown to the window under the cursor, not the frontmost + // app. Without this hit-test, mouse_move (positioning, passes at any tier) + // + left_mouse_down decomposes a click that lands on a tier-"read" window + // overlapping a tier-"full" frontmost app — bypassing runHitTestGate's + // whole purpose. All three are batchable, so the bypass is atomic. + const cursor = await adapter.executor.getCursorPosition(); + const hitGate = await runHitTestGate( + adapter, + overrides, + subGates, + cursor.x, + cursor.y, + "mouse", + ); + if (hitGate) return hitGate; + + await adapter.executor.mouseDown(); + mouseButtonHeld = true; + mouseMoved = false; + return okText("Mouse button pressed."); +} + +/** + * Raw release at current cursor. Does NOT error + * if not held (idempotent release). + */ +async function handleLeftMouseUp( + adapter: ComputerUseHostAdapter, + overrides: ComputerUseOverrides, + subGates: CuSubGates, +): Promise { + // Any gate rejection here must release the button FIRST — otherwise the + // OS button stays pressed and mouseButtonHeld stays true. Recovery + // attempts (mouse_move back to a safe app) would generate leftMouseDragged + // events into whatever window is under the cursor, including the very + // read-tier window the gate was protecting. A single mouseUp on a + // restricted window is one event; a stuck button is cascading damage. + // + // This includes the frontmost gate: focus can change between mouseDown and + // mouseUp (something else grabbed focus), in which case runInputActionGates + // rejects here even though it passed at mouseDown. + const releaseFirst = async ( + err: CuCallToolResult, + ): Promise => { + await adapter.executor.mouseUp(); + mouseButtonHeld = false; + mouseMoved = false; + return err; + }; + + const gate = await runInputActionGates(adapter, overrides, subGates, "mouse"); + if (gate) return releaseFirst(gate); + + // When the cursor moved since mouseDown, this is a drop (text-injection + // vector) — hit-test at "mouse_full" same as left_click_drag's `to`. When + // NO move happened, this is a click-release — same semantics as the atomic + // left_click, hit-test at "mouse". Without this distinction, a decomposed + // click on a click-tier app fails here while the atomic left_click works, + // and releaseFirst fires mouseUp anyway so the OS sees a complete click + // while the model gets a misleading error. + const cursor = await adapter.executor.getCursorPosition(); + const hitGate = await runHitTestGate( + adapter, + overrides, + subGates, + cursor.x, + cursor.y, + mouseMoved ? "mouse_full" : "mouse", + ); + if (hitGate) return releaseFirst(hitGate); + + await adapter.executor.mouseUp(); + mouseButtonHeld = false; + mouseMoved = false; + return okText("Mouse button released."); +} + +// --------------------------------------------------------------------------- +// Batch dispatch +// --------------------------------------------------------------------------- + +/** + * Actions allowed inside a computer_batch call. Excludes request_access, + * open_application, clipboard, list_granted (no latency benefit, complicates + * security model). + */ +const BATCHABLE_ACTIONS: ReadonlySet = new Set([ + "key", + "type", + "mouse_move", + "left_click", + "left_click_drag", + "right_click", + "middle_click", + "double_click", + "triple_click", + "scroll", + "hold_key", + "screenshot", + "cursor_position", + "left_mouse_down", + "left_mouse_up", + "wait", +]); + +interface BatchActionResult { + action: string; + ok: boolean; + output: string; +} + +/** + * Executes `actions: [{action, …}, …]` + * sequentially in ONE model→API round trip — the dominant latency cost + * (seconds, vs. ~50ms local overhead per action). + * + * Gate semantics (the security model): + * - Kill-switch + TCC: checked ONCE by handleToolCall before reaching here. + * - prepareForAction: run ONCE at the top. The user approved "do this + * sequence"; hiding apps per-action is wasted work and fast-pathed anyway. + * - Frontmost gate: checked PER ACTION. State can change mid-batch — a + * click might open a non-allowed app. This is the safety net: if action + * 3 of 5 opened Safari (not allowed), action 4's frontmost check fires + * and stops the batch there. + * - PixelCompare: SKIPPED inside batch. The model committed to the full + * sequence without intermediate screenshots; validating mid-batch clicks + * against a pre-batch screenshot would false-positive constantly. + * + * Both skips are implemented by passing `{...subGates, hideBeforeAction: + * false, pixelValidation: false}` to each inner dispatch — the handlers' + * existing gate logic does the right thing, no new code paths. + * + * Stop-on-first-error: accumulate results, on + * first `isError` stop executing, return everything so far + the error. The + * model sees exactly where the batch broke and what succeeded before it. + * + * Mid-batch screenshots are allowed (for inspection) but NEVER piggyback — + * their `.screenshot` field is dropped. Same invariant as zoom: click coords + * always refer to the PRE-BATCH `lastScreenshot`. If the model wants to click + * based on a new screenshot, it ends the batch and screenshots separately. + */ +async function handleComputerBatch( + adapter: ComputerUseHostAdapter, + args: Record, + overrides: ComputerUseOverrides, + subGates: CuSubGates, +): Promise { + const actions = args.actions; + if (!Array.isArray(actions) || actions.length === 0) { + return errorResult("actions must be a non-empty array", "bad_args"); + } + + for (const [i, act] of actions.entries()) { + if (typeof act !== "object" || act === null) { + return errorResult(`actions[${i}] must be an object`, "bad_args"); + } + const action = (act as Record).action; + if (typeof action !== "string") { + return errorResult(`actions[${i}].action must be a string`, "bad_args"); + } + if (!BATCHABLE_ACTIONS.has(action)) { + return errorResult( + `actions[${i}].action="${action}" is not allowed in a batch. ` + + `Allowed: ${[...BATCHABLE_ACTIONS].join(", ")}.`, + "bad_args", + ); + } + } + + // prepareForAction ONCE. After this, inner dispatches skip it via + // hideBeforeAction:false. + if (subGates.hideBeforeAction) { + const hidden = await adapter.executor.prepareForAction( + overrides.allowedApps.map((a) => a.bundleId), + overrides.selectedDisplayId, + ); + if (hidden.length > 0) { + overrides.onAppsHidden?.(hidden); + } + } + + // Inner actions: skip prepare (already ran), skip pixelCompare (stale by + // design). Frontmost still checked — runInputActionGates does it + // unconditionally. + const batchSubGates: CuSubGates = { + ...subGates, + hideBeforeAction: false, + pixelValidation: false, + // Batch already took its screenshot (appended at end); a mid-batch + // resolver switch would make that screenshot inconsistent with + // earlier clicks' lastScreenshot-based scaleCoord targeting. + autoTargetDisplay: false, + }; + + const results: BatchActionResult[] = []; + for (const [i, act] of actions.entries()) { + // Overlay Stop → host's stopSession → lifecycleState leaves "running" + // synchronously before query.interrupt(). The SDK abort tears down the + // host's await but not this loop — without this check the remaining + // actions fire into a dead session. + if (overrides.isAborted?.()) { + await releaseHeldMouse(adapter); + return errorResult( + `Batch aborted after ${results.length} of ${actions.length} actions (user interrupt).`, + ); + } + + // Small inter-step settle. Synthetic CGEvents post instantly; some apps + // need a tick to process step N's input before step N+1 lands (e.g. a + // click opening a menu before the next click targets a menu item). + if (i > 0) await sleep(10); + + const actionArgs = act as Record; + const action = actionArgs.action as string; + + // Drop mid-batch screenshot piggyback (strip .screenshot). Click coords + // stay anchored to the pre-batch lastScreenshot. + const { screenshot: _dropped, ...inner } = await dispatchAction( + action, + actionArgs, + adapter, + overrides, + batchSubGates, + ); + + const text = firstTextContent(inner); + const result = { action, ok: !inner.isError, output: text }; + results.push(result); + + if (inner.isError) { + // Stop-on-first-error. Return everything so far + the error. + // Forward the inner action's telemetry (error_kind) so cu_tool_call + // reflects the actual failure — without this, batch-internal errors + // emit error_kind: undefined despite the inner handler tagging it. + // Release held mouse: the error may be a mid-grapheme abort in + // handleType, or a frontmost gate, landing between mouse_down and + // mouse_up. + await releaseHeldMouse(adapter); + return okJson( + { + completed: results.slice(0, -1), + failed: result, + remaining: actions.length - results.length, + }, + inner.telemetry, + ); + } + } + + return okJson({ completed: results }); +} + +function firstTextContent(r: CuCallToolResult): string { + const first = r.content[0]; + return first && first.type === "text" ? first.text : ""; +} + +/** + * Action dispatch shared by handleToolCall and handleComputerBatch. Called + * AFTER kill-switch + TCC gates have passed. Never sees request_access — it's + * special-cased in handleToolCall for the tccState thread-through. + */ +async function dispatchAction( + name: string, + a: Record, + adapter: ComputerUseHostAdapter, + overrides: ComputerUseOverrides, + subGates: CuSubGates, +): Promise { + switch (name) { + case "screenshot": + return handleScreenshot(adapter, overrides, subGates); + + case "zoom": + return handleZoom(adapter, a, overrides); + + case "left_click": + return handleClickVariant(adapter, a, overrides, subGates, "left", 1); + case "double_click": + return handleClickVariant(adapter, a, overrides, subGates, "left", 2); + case "triple_click": + return handleClickVariant(adapter, a, overrides, subGates, "left", 3); + case "right_click": + return handleClickVariant(adapter, a, overrides, subGates, "right", 1); + case "middle_click": + return handleClickVariant(adapter, a, overrides, subGates, "middle", 1); + + case "type": + return handleType(adapter, a, overrides, subGates); + + case "key": + return handleKey(adapter, a, overrides, subGates); + + case "scroll": + return handleScroll(adapter, a, overrides, subGates); + + case "left_click_drag": + return handleDrag(adapter, a, overrides, subGates); + + case "mouse_move": + return handleMoveMouse(adapter, a, overrides, subGates); + + case "wait": + return handleWait(a); + + case "cursor_position": + return handleCursorPosition(adapter, overrides); + + case "hold_key": + return handleHoldKey(adapter, a, overrides, subGates); + + case "left_mouse_down": + return handleLeftMouseDown(adapter, overrides, subGates); + + case "left_mouse_up": + return handleLeftMouseUp(adapter, overrides, subGates); + + case "open_application": + return handleOpenApplication(adapter, a, overrides); + + case "switch_display": + return handleSwitchDisplay(adapter, a, overrides); + + case "list_granted_applications": + return handleListGrantedApplications(overrides); + + case "read_clipboard": + return handleReadClipboard(adapter, overrides, subGates); + + case "write_clipboard": + return handleWriteClipboard(adapter, a, overrides, subGates); + + case "computer_batch": + return handleComputerBatch(adapter, a, overrides, subGates); + + default: + return errorResult(`Unknown tool "${name}".`, "bad_args"); + } +} + +// --------------------------------------------------------------------------- +// Main dispatch +// --------------------------------------------------------------------------- + +export async function handleToolCall( + adapter: ComputerUseHostAdapter, + name: string, + args: unknown, + rawOverrides: ComputerUseOverrides, +): Promise { + const { logger, serverName } = adapter; + + // Normalize the allowlist before any gate runs: + // + // (a) Strip user-denied. A grant from a previous session (before the user + // added the app to Settings → Desktop app → Computer Use → Denied apps) + // must not survive. Without + // this, a stale grant bypasses the auto-deny. Stripped silently — the + // agent already saw the userDenied guidance at request_access time, and + // a live frontmost-gate rejection cites "not in allowed applications". + // + // (b) Strip policy-denied. Same story as (a) for a grant that predates a + // blocklist addition. buildAccessRequest denies these up front for new + // requests; this catches stale persisted grants. + // + // (c) Backfill tier. A grant persisted before the tier field existed has + // `tier: undefined`, which `tierSatisfies` treats as `"full"` — wrong + // for a legacy Chrome grant. Assign the hardcoded tier based on + // bundle-ID category. Modern grants already have a tier. + // + // `.some()` guard keeps the hot path (empty deny list, no legacy grants) + // zero-alloc. + const userDeniedSet = new Set(rawOverrides.userDeniedBundleIds); + const overrides: ComputerUseOverrides = rawOverrides.allowedApps.some( + (a) => + a.tier === undefined || + userDeniedSet.has(a.bundleId) || + isPolicyDenied(a.bundleId, a.displayName), + ) + ? { + ...rawOverrides, + allowedApps: rawOverrides.allowedApps + .filter((a) => !userDeniedSet.has(a.bundleId)) + .filter((a) => !isPolicyDenied(a.bundleId, a.displayName)) + .map((a) => + a.tier !== undefined + ? a + : { ...a, tier: getDefaultTierForApp(a.bundleId, a.displayName) }, + ), + } + : rawOverrides; + + // ─── Gate 1: kill switch ───────────────────────────────────────────── + if (adapter.isDisabled()) { + return errorResult( + "Computer control is disabled in Settings. Enable it and try again.", + "other", + ); + } + + // ─── Gate 2: TCC ───────────────────────────────────────────────────── + // Accessibility + Screen Recording on macOS. Pure check — no dialog, + // no relaunch. `request_access` is exempted: it threads the ungranted + // state through to the renderer, which shows a TCC toggle panel instead + // of the app list. Every other tool short-circuits here. + const osPerms = await adapter.ensureOsPermissions(); + let tccState: + | { accessibility: boolean; screenRecording: boolean } + | undefined; + if (!osPerms.granted) { + // Both request_* tools thread tccState through to the renderer's + // TCC toggle panel. Every other tool short-circuits. + if (name !== "request_access" && name !== "request_teach_access") { + return errorResult( + "Accessibility and Screen Recording permissions are required. " + + "Call request_access to show the permission panel.", + "tcc_not_granted", + ); + } + tccState = { + accessibility: osPerms.accessibility, + screenRecording: osPerms.screenRecording, + }; + } + + // ─── Gate 3: global CU lock ────────────────────────────────────────── + // At most one session uses CU at a time. Every tool including + // request_access hits the CHECK — even showing the approval dialog while + // another session holds the lock would be confusing ("why approve access + // that can't be used?"). + // + // But ACQUIRE is split: request_access and list_granted_applications + // check-without-acquire (the overlay + notifications are driven by + // cuLockChanged, and showing "Claude is using your computer" while the + // agent is only ASKING for access is premature). First action tool + // acquires and the overlay appears. If the user denies and no action + // follows, the overlay never shows. + // + // request_teach_access is NOT in this set — approving teach mode HIDES + // the main window (via onTeachModeActivated), and the lock must be held + // before that happens. Otherwise a concurrent session's request_access + // would render its dialog in an invisible main window during the gap + // between hide and the first teach_step (seconds of model inference). + // The old acquire-always-at-Gate-3 behavior was correct for teach; only + // the non-teach permission tools benefit from deferral. + // + // Host releases on idle/stop/archive; this package never releases. Both + // Cowork (LAM) and CCD (LSM) wire checkCuLock via the shared cuLock + // singleton. When undefined (tests/future hosts), no gate — absence of + // the mechanism ≠ locked out. + const deferAcquire = defersLockAcquire(name); + const lock = overrides.checkCuLock?.(); + if (lock) { + if (lock.holder !== undefined && !lock.isSelf) { + return errorResult( + "Another Claude session is currently using the computer. Wait for " + + "the user to acknowledge it is finished (stop button in the Claude " + + "window), or find a non-computer-use approach if one is readily " + + "apparent.", + "cu_lock_held", + ); + } + if (lock.holder === undefined && !deferAcquire) { + // Acquire. Emits cuLockChanged → overlay shows. Idempotent — if + // someone else acquired between check and here (won't happen on a + // single-threaded event loop, but defensive), this is a no-op. + overrides.acquireCuLock?.(); + // Fresh lock holder → any prior session's mouseButtonHeld is stale + // (e.g. overlay stop mid-drag). Clear it so this session doesn't get + // a spurious "already held" error. resetMouseButtonHeld is file-local; + // this is the one non-test callsite. + resetMouseButtonHeld(); + } + // lock.isSelf → already held by us, proceed. + // lock.holder === undefined && deferAcquire → + // checked but not acquired — proceed, first action will acquire. + } + + // Sub-gates read FRESH every call so a GrowthBook flip takes effect + // mid-session (plan §3). + const subGates = adapter.getSubGates(); + + // Clipboard guard runs per-action inside runInputActionGates + inline in + // handleReadClipboard/handleWriteClipboard. NOT here — per-tool-call sync + // would run once for computer_batch and miss sub-actions 2..N, and would + // fire during deferAcquire tools / `wait` / teach_step's blocking-dialog + // phase where no input is happening. + + const a = asRecord(args); + + logger.silly( + `[${serverName}] tool=${name} args=${JSON.stringify(a).slice(0, 200)}`, + ); + + // ─── Fail-closed dispatch ──────────────────────────────────────────── + // ANY exception below → tool error, executor never left in a half-called + // state. Explicit inversion of the prior `catch → return true` fail-open. + try { + // request_access / request_teach_access: need tccState thread-through; + // dispatchAction never sees them (not batchable). + // teach_step: blocking UI tool, also not batchable; needs subGates for + // its action-execution phase. + if (name === "request_access") { + return await handleRequestAccess(adapter, a, overrides, tccState); + } + if (name === "request_teach_access") { + return await handleRequestTeachAccess(adapter, a, overrides, tccState); + } + if (name === "teach_step") { + return await handleTeachStep(adapter, a, overrides, subGates); + } + if (name === "teach_batch") { + return await handleTeachBatch(adapter, a, overrides, subGates); + } + return await dispatchAction(name, a, adapter, overrides, subGates); + } catch (err) { + // Fail-closed. If the gate machinery itself throws (e.g. + // getFrontmostApp() rejects), the executor has NOT been called yet for + // the gated tools — the gates run before the executor in every handler. + // For ungated tools, the executor may have been mid-call; that's fine — + // the result is still a tool error, never an implicit success. + const msg = err instanceof Error ? err.message : String(err); + logger.error(`[${serverName}] tool=${name} threw: ${msg}`, err); + return errorResult(`Tool "${name}" failed: ${msg}`, "executor_threw"); + } +} + +export const _test = { + scaleCoord, + coordToPercentageForPixelCompare, + segmentGraphemes, + decodedByteLength, + resolveRequestedApps, + buildAccessRequest, + buildTierGuidanceMessage, + buildUserDeniedGuidance, + tierSatisfies, + looksLikeBundleId, + extractCoordinate, + parseKeyChord, + buildMonitorNote, + handleSwitchDisplay, + uniqueDisplayLabels, +}; diff --git a/src/vendor/computer-use-mcp/tools.ts b/src/vendor/computer-use-mcp/tools.ts new file mode 100644 index 00000000..c744a232 --- /dev/null +++ b/src/vendor/computer-use-mcp/tools.ts @@ -0,0 +1,706 @@ +/** + * MCP tool schemas for the computer-use server. Mirrors + * claude-for-chrome-mcp/src/browserTools.ts in shape (plain `Tool`-shaped + * object literals, no zod). + * + * Coordinate descriptions are baked in at tool-list build time from the + * `chicago_coordinate_mode` gate. The model sees exactly ONE coordinate + * convention in the param descriptions and never learns the other exists. + * The host (`serverDef.ts`) reads the same frozen gate value for + * `scaleCoord` — both must agree or clicks land in the wrong space. + */ + +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; + +import type { CoordinateMode } from "./types.js"; + +// See packages/desktop/computer-use-mcp/COORDINATES.md before touching any +// model-facing coordinate text. Chrome's browserTools.ts:143 is the reference +// phrasing — "pixels from the left edge", no geometry, no number to do math with. +const COORD_DESC: Record = { + pixels: { + x: "Horizontal pixel position read directly from the most recent screenshot image, measured from the left edge. The server handles all scaling.", + y: "Vertical pixel position read directly from the most recent screenshot image, measured from the top edge. The server handles all scaling.", + }, + normalized_0_100: { + x: "Horizontal position as a percentage of screen width, 0.0–100.0 (0 = left edge, 100 = right edge).", + y: "Vertical position as a percentage of screen height, 0.0–100.0 (0 = top edge, 100 = bottom edge).", + }, +}; + +const FRONTMOST_GATE_DESC = + "The frontmost application must be in the session allowlist at the time of this call, or this tool returns an error and does nothing."; + +/** + * Item schema for the `actions` array in `computer_batch`, `teach_step`, and + * `teach_batch`. All three dispatch through the same `dispatchAction` path + * with the same validation — keep this enum in sync with `BATCHABLE_ACTIONS` + * in toolCalls.ts. + */ +const BATCH_ACTION_ITEM_SCHEMA = { + type: "object", + properties: { + action: { + type: "string", + enum: [ + "key", + "type", + "mouse_move", + "left_click", + "left_click_drag", + "right_click", + "middle_click", + "double_click", + "triple_click", + "scroll", + "hold_key", + "screenshot", + "cursor_position", + "left_mouse_down", + "left_mouse_up", + "wait", + ], + description: "The action to perform.", + }, + coordinate: { + type: "array", + items: { type: "number" }, + minItems: 2, + maxItems: 2, + description: + "(x, y) for click/mouse_move/scroll/left_click_drag end point.", + }, + start_coordinate: { + type: "array", + items: { type: "number" }, + minItems: 2, + maxItems: 2, + description: + "(x, y) drag start — left_click_drag only. Omit to drag from current cursor.", + }, + text: { + type: "string", + description: + "For type: the text. For key/hold_key: the chord string. For click/scroll: modifier keys to hold.", + }, + scroll_direction: { + type: "string", + enum: ["up", "down", "left", "right"], + }, + scroll_amount: { type: "integer", minimum: 0, maximum: 100 }, + duration: { + type: "number", + description: "Seconds (0–100). For hold_key/wait.", + }, + repeat: { + type: "integer", + minimum: 1, + maximum: 100, + description: "For key: repeat count.", + }, + }, + required: ["action"], +}; + +/** + * Build the tool list. Parameterized by capabilities and coordinate mode so + * descriptions are honest and unambiguous (plan §1 — "Unfiltered + honest"). + * + * `coordinateMode` MUST match what the host passes to `scaleCoord` at tool- + * -call time. Both should read the same frozen-at-load gate constant. + * + * `installedAppNames` — optional pre-sanitized list of app display names to + * enumerate in the `request_access` description. The caller is responsible + * for sanitization (length cap, character allowlist, sort, count cap) — + * this function just splices the list into the description verbatim. Omit + * to fall back to the generic "display names or bundle IDs" wording. + */ +export function buildComputerUseTools( + caps: { + screenshotFiltering: "native" | "none"; + platform: "darwin" | "win32"; + /** Include request_teach_access + teach_step. Read once at server construction. */ + teachMode?: boolean; + }, + coordinateMode: CoordinateMode, + installedAppNames?: string[], +): Tool[] { + const coord = COORD_DESC[coordinateMode]; + + // Shared hint suffix for BOTH request_access and request_teach_access — + // they use the same resolveRequestedApps path, so the model should get + // the same enumeration for both. + const installedAppsHint = + installedAppNames && installedAppNames.length > 0 + ? ` Available applications on this machine: ${installedAppNames.join(", ")}.` + : ""; + + // [x, y]` tuple — param shape for all + // click/move/scroll tools. + const coordinateTuple = { + type: "array", + items: { type: "number" }, + minItems: 2, + maxItems: 2, + description: `(x, y): ${coord.x}`, + }; + // Modifier hold during click. Shared across all 5 click variants. + const clickModifierText = { + type: "string", + description: + 'Modifier keys to hold during the click (e.g. "shift", "ctrl+shift"). Supports the same syntax as the key tool.', + }; + + const screenshotDesc = + caps.screenshotFiltering === "native" + ? "Take a screenshot of the primary display. Applications not in the session allowlist are excluded at the compositor level — only granted apps and the desktop are visible." + : "Take a screenshot of the primary display. On this platform, screenshots are NOT filtered — all open windows are visible. Input actions targeting apps not in the session allowlist are rejected."; + + return [ + { + name: "request_access", + description: + "Request user permission to control a set of applications for this session. Must be called before any other tool in this server. " + + "The user sees a single dialog listing all requested apps and either allows the whole set or denies it. " + + "Call this again mid-session to add more apps; previously granted apps remain granted. " + + "Returns the granted apps, denied apps, and screenshot filtering capability.", + inputSchema: { + type: "object" as const, + properties: { + apps: { + type: "array", + items: { type: "string" }, + description: + "Application display names (e.g. \"Slack\", \"Calendar\") or bundle identifiers (e.g. \"com.tinyspeck.slackmacgap\"). Display names are resolved case-insensitively against installed apps." + + installedAppsHint, + }, + reason: { + type: "string", + description: + "One-sentence explanation shown to the user in the approval dialog. Explain the task, not the mechanism.", + }, + clipboardRead: { + type: "boolean", + description: + "Also request permission to read the user's clipboard (separate checkbox in the dialog).", + }, + clipboardWrite: { + type: "boolean", + description: + "Also request permission to write the user's clipboard. When granted, multi-line `type` calls use the clipboard fast path.", + }, + systemKeyCombos: { + type: "boolean", + description: + "Also request permission to send system-level key combos (quit app, switch app, lock screen). Without this, those specific combos are blocked.", + }, + }, + required: ["apps", "reason"], + }, + }, + + { + name: "screenshot", + description: + screenshotDesc + + " Returns an error if the allowlist is empty. The returned image is what subsequent click coordinates are relative to.", + inputSchema: { + type: "object" as const, + properties: { + save_to_disk: { + type: "boolean", + description: + "Save the image to disk so it can be attached to a message for the user. Returns the saved path in the tool result. Only set this when you intend to share the image — screenshots you're just looking at don't need saving.", + }, + }, + required: [], + }, + }, + + { + name: "zoom", + description: + "Take a higher-resolution screenshot of a specific region of the last full-screen screenshot. Use this liberally to inspect small text, button labels, or fine UI details that are hard to read in the downsampled full-screen image. " + + "IMPORTANT: Coordinates in subsequent click calls always refer to the full-screen screenshot, never the zoomed image. This tool is read-only for inspecting detail.", + inputSchema: { + type: "object" as const, + properties: { + region: { + type: "array", + items: { type: "integer" }, + minItems: 4, + maxItems: 4, + description: + "(x0, y0, x1, y1): Rectangle to zoom into, in the coordinate space of the most recent full-screen screenshot. x0,y0 = top-left, x1,y1 = bottom-right.", + }, + save_to_disk: { + type: "boolean", + description: + "Save the image to disk so it can be attached to a message for the user. Returns the saved path in the tool result. Only set this when you intend to share the image.", + }, + }, + required: ["region"], + }, + }, + + { + name: "left_click", + description: `Left-click at the given coordinates. ${FRONTMOST_GATE_DESC}`, + inputSchema: { + type: "object" as const, + properties: { + coordinate: coordinateTuple, + text: clickModifierText, + }, + required: ["coordinate"], + }, + }, + + { + name: "double_click", + description: `Double-click at the given coordinates. Selects a word in most text editors. ${FRONTMOST_GATE_DESC}`, + inputSchema: { + type: "object" as const, + properties: { + coordinate: coordinateTuple, + text: clickModifierText, + }, + required: ["coordinate"], + }, + }, + + { + name: "triple_click", + description: `Triple-click at the given coordinates. Selects a line in most text editors. ${FRONTMOST_GATE_DESC}`, + inputSchema: { + type: "object" as const, + properties: { + coordinate: coordinateTuple, + text: clickModifierText, + }, + required: ["coordinate"], + }, + }, + + { + name: "right_click", + description: `Right-click at the given coordinates. Opens a context menu in most applications. ${FRONTMOST_GATE_DESC}`, + inputSchema: { + type: "object" as const, + properties: { + coordinate: coordinateTuple, + text: clickModifierText, + }, + required: ["coordinate"], + }, + }, + + { + name: "middle_click", + description: `Middle-click (scroll-wheel click) at the given coordinates. ${FRONTMOST_GATE_DESC}`, + inputSchema: { + type: "object" as const, + properties: { + coordinate: coordinateTuple, + text: clickModifierText, + }, + required: ["coordinate"], + }, + }, + + { + name: "type", + description: `Type text into whatever currently has keyboard focus. ${FRONTMOST_GATE_DESC} Newlines are supported. For keyboard shortcuts use \`key\` instead.`, + inputSchema: { + type: "object" as const, + properties: { + text: { type: "string", description: "Text to type." }, + }, + required: ["text"], + }, + }, + + { + name: "key", + description: + `Press a key or key combination (e.g. "return", "escape", "cmd+a", "ctrl+shift+tab"). ${FRONTMOST_GATE_DESC} ` + + "System-level combos (quit app, switch app, lock screen) require the `systemKeyCombos` grant — without it they return an error. All other combos work.", + inputSchema: { + type: "object" as const, + properties: { + text: { + type: "string", + description: 'Modifiers joined with "+", e.g. "cmd+shift+a".', + }, + repeat: { + type: "integer", + minimum: 1, + maximum: 100, + description: "Number of times to repeat the key press. Default is 1.", + }, + }, + required: ["text"], + }, + }, + + { + name: "scroll", + description: `Scroll at the given coordinates. ${FRONTMOST_GATE_DESC}`, + inputSchema: { + type: "object" as const, + properties: { + coordinate: coordinateTuple, + scroll_direction: { + type: "string", + enum: ["up", "down", "left", "right"], + description: "Direction to scroll.", + }, + scroll_amount: { + type: "integer", + minimum: 0, + maximum: 100, + description: "Number of scroll ticks.", + }, + }, + required: ["coordinate", "scroll_direction", "scroll_amount"], + }, + }, + + { + name: "left_click_drag", + description: `Press, move to target, and release. ${FRONTMOST_GATE_DESC}`, + inputSchema: { + type: "object" as const, + properties: { + coordinate: { + ...coordinateTuple, + description: `(x, y) end point: ${coord.x}`, + }, + start_coordinate: { + ...coordinateTuple, + description: `(x, y) start point. If omitted, drags from the current cursor position. ${coord.x}`, + }, + }, + required: ["coordinate"], + }, + }, + + { + name: "mouse_move", + description: `Move the mouse cursor without clicking. Useful for triggering hover states. ${FRONTMOST_GATE_DESC}`, + inputSchema: { + type: "object" as const, + properties: { + coordinate: coordinateTuple, + }, + required: ["coordinate"], + }, + }, + + { + name: "open_application", + description: + "Bring an application to the front, launching it if necessary. The target application must already be in the session allowlist — call request_access first.", + inputSchema: { + type: "object" as const, + properties: { + app: { + type: "string", + description: + "Display name (e.g. \"Slack\") or bundle identifier (e.g. \"com.tinyspeck.slackmacgap\").", + }, + }, + required: ["app"], + }, + }, + + { + name: "switch_display", + description: + "Switch which monitor subsequent screenshots capture. Use this when the " + + "application you need is on a different monitor than the one shown. " + + "The screenshot tool tells you which monitor it captured and lists " + + "other attached monitors by name — pass one of those names here. " + + "After switching, call screenshot to see the new monitor. " + + 'Pass "auto" to return to automatic monitor selection.', + inputSchema: { + type: "object" as const, + properties: { + display: { + type: "string", + description: + 'Monitor name from the screenshot note (e.g. "Built-in Retina Display", ' + + '"LG UltraFine"), or "auto" to re-enable automatic selection.', + }, + }, + required: ["display"], + }, + }, + + { + name: "list_granted_applications", + description: + "List the applications currently in the session allowlist, plus the active grant flags and coordinate mode. No side effects.", + inputSchema: { + type: "object" as const, + properties: {}, + required: [], + }, + }, + + { + name: "read_clipboard", + description: + "Read the current clipboard contents as text. Requires the `clipboardRead` grant.", + inputSchema: { + type: "object" as const, + properties: {}, + required: [], + }, + }, + + { + name: "write_clipboard", + description: + "Write text to the clipboard. Requires the `clipboardWrite` grant.", + inputSchema: { + type: "object" as const, + properties: { + text: { type: "string" }, + }, + required: ["text"], + }, + }, + + { + name: "wait", + description: "Wait for a specified duration.", + inputSchema: { + type: "object" as const, + properties: { + duration: { + type: "number", + description: "Duration in seconds (0–100).", + }, + }, + required: ["duration"], + }, + }, + + { + name: "cursor_position", + description: + "Get the current mouse cursor position. Returns image-pixel coordinates relative to the most recent screenshot, or logical points if no screenshot has been taken.", + inputSchema: { + type: "object" as const, + properties: {}, + required: [], + }, + }, + + { + name: "hold_key", + description: + `Press and hold a key or key combination for the specified duration, then release. ${FRONTMOST_GATE_DESC} ` + + "System-level combos require the `systemKeyCombos` grant.", + inputSchema: { + type: "object" as const, + properties: { + text: { + type: "string", + description: 'Key or chord to hold, e.g. "space", "shift+down".', + }, + duration: { + type: "number", + description: "Duration in seconds (0–100).", + }, + }, + required: ["text", "duration"], + }, + }, + + { + name: "left_mouse_down", + description: + `Press the left mouse button at the current cursor position and leave it held. ${FRONTMOST_GATE_DESC} ` + + "Use mouse_move first to position the cursor. Call left_mouse_up to release. Errors if the button is already held.", + inputSchema: { + type: "object" as const, + properties: {}, + required: [], + }, + }, + + { + name: "left_mouse_up", + description: + `Release the left mouse button at the current cursor position. ${FRONTMOST_GATE_DESC} ` + + "Pairs with left_mouse_down. Safe to call even if the button is not currently held.", + inputSchema: { + type: "object" as const, + properties: {}, + required: [], + }, + }, + + { + name: "computer_batch", + description: + "Execute a sequence of actions in ONE tool call. Each individual tool call requires a model→API round trip (seconds); " + + "batching a predictable sequence eliminates all but one. Use this whenever you can predict the outcome of several actions ahead — " + + "e.g. click a field, type into it, press Return. Actions execute sequentially and stop on the first error. " + + `${FRONTMOST_GATE_DESC} The frontmost check runs before EACH action inside the batch — if an action opens a non-allowed app, the next action's gate fires and the batch stops there. ` + + "Mid-batch screenshot actions are allowed for inspection but coordinates in subsequent clicks always refer to the PRE-BATCH full-screen screenshot.", + inputSchema: { + type: "object" as const, + properties: { + actions: { + type: "array", + minItems: 1, + items: BATCH_ACTION_ITEM_SCHEMA, + description: + 'List of actions. Example: [{"action":"left_click","coordinate":[100,200]},{"action":"type","text":"hello"},{"action":"key","text":"Return"}]', + }, + }, + required: ["actions"], + }, + }, + + ...(caps.teachMode ? buildTeachTools(coord, installedAppsHint) : []), + ]; +} + +/** + * Teach-mode tools. Split out so the spread above stays a single expression; + * takes `coord` so `teach_step.anchor`'s description uses the same + * frozen coordinate-mode phrasing as click coords, and `installedAppsHint` + * so `request_teach_access.apps` gets the same enumeration as + * `request_access.apps` (same resolution path → same hint). + */ +function buildTeachTools( + coord: { x: string; y: string }, + installedAppsHint: string, +): Tool[] { + // Shared between teach_step (top-level) and teach_batch (inside steps[] + // items). Depends on coord, so it lives inside this factory. + const teachStepProperties = { + explanation: { + type: "string", + description: + "Tooltip body text. Explain what the user is looking at and why it matters. " + + "This is the ONLY place the user sees your words — be complete but concise.", + }, + next_preview: { + type: "string", + description: + "One line describing exactly what will happen when the user clicks Next. " + + 'Example: "Next: I\'ll click Create Bucket and type the name." ' + + "Shown below the explanation in a smaller font.", + }, + anchor: { + type: "array", + items: { type: "number" }, + minItems: 2, + maxItems: 2, + description: + `(x, y) — where the tooltip arrow points. ${coord.x} ` + + "Omit to center the tooltip with no arrow (for general-context steps).", + }, + actions: { + type: "array", + // Empty allowed — "read this, click Next" steps. + items: BATCH_ACTION_ITEM_SCHEMA, + description: + "Actions to execute when the user clicks Next. Same item schema as computer_batch.actions. " + + "Empty array is valid for purely explanatory steps. Actions run sequentially and stop on first error.", + }, + } as const; + + return [ + { + name: "request_teach_access", + description: + "Request permission to guide the user through a task step-by-step with on-screen tooltips. " + + "Use this INSTEAD OF request_access when the user wants to LEARN how to do something " + + '(phrases like "teach me", "walk me through", "show me how", "help me learn"). ' + + "On approval the main Claude window hides and a fullscreen tooltip overlay appears. " + + "You then call teach_step repeatedly; each call shows one tooltip and waits for the user to click Next. " + + "Same app-allowlist semantics as request_access, but no clipboard/system-key flags. " + + "Teach mode ends automatically when your turn ends.", + inputSchema: { + type: "object" as const, + properties: { + apps: { + type: "array", + items: { type: "string" }, + description: + 'Application display names (e.g. "Slack", "Calendar") or bundle identifiers. Resolved case-insensitively against installed apps.' + + installedAppsHint, + }, + reason: { + type: "string", + description: + 'What you will be teaching. Shown in the approval dialog as "Claude wants to guide you through {reason}". Keep it short and task-focused.', + }, + }, + required: ["apps", "reason"], + }, + }, + + { + name: "teach_step", + description: + "Show one guided-tour tooltip and wait for the user to click Next. On Next, execute the actions, " + + "take a fresh screenshot, and return both — you do NOT need a separate screenshot call between steps. " + + "The returned image shows the state after your actions ran; anchor the next teach_step against it. " + + "IMPORTANT — the user only sees the tooltip during teach mode. Put ALL narration in `explanation`. " + + "Text you emit outside teach_step calls is NOT visible until teach mode ends. " + + "Pack as many actions as possible into each step's `actions` array — the user waits through " + + "the whole round trip between clicks, so one step that fills a form beats five steps that fill one field each. " + + "Returns {exited:true} if the user clicks Exit — do not call teach_step again after that. " + + "Take an initial screenshot before your FIRST teach_step to anchor it.", + inputSchema: { + type: "object" as const, + properties: teachStepProperties, + required: ["explanation", "next_preview", "actions"], + }, + }, + + { + name: "teach_batch", + description: + "Queue multiple teach steps in one tool call. Parallels computer_batch: " + + "N steps → one model↔API round trip instead of N. Each step still shows a tooltip " + + "and waits for the user's Next click, but YOU aren't waiting for a round trip between steps. " + + "You can call teach_batch multiple times in one tour — treat each batch as one predictable " + + "SEGMENT (typically: all the steps on one page). The returned screenshot shows the state " + + "after the batch's final actions; anchor the NEXT teach_batch against it. " + + "WITHIN a batch, all anchors and click coordinates refer to the PRE-BATCH screenshot " + + "(same invariant as computer_batch) — for steps 2+ in a batch, either omit anchor " + + "(centered tooltip) or target elements you know won't have moved. " + + "Good pattern: batch 5 tooltips on page A (last step navigates) → read returned screenshot → " + + "batch 3 tooltips on page B → done. " + + "Returns {exited:true, stepsCompleted:N} if the user clicks Exit — do NOT call again after that; " + + "{stepsCompleted, stepFailed, ...} if an action errors mid-batch; " + + "otherwise {stepsCompleted, results:[...]} plus a final screenshot. " + + "Fall back to individual teach_step calls when you need to react to each intermediate screenshot.", + inputSchema: { + type: "object" as const, + properties: { + steps: { + type: "array", + minItems: 1, + items: { + type: "object", + properties: teachStepProperties, + required: ["explanation", "next_preview", "actions"], + }, + description: + "Ordered steps. Validated upfront — a typo in step 5 errors before any tooltip shows.", + }, + }, + required: ["steps"], + }, + }, + ]; +} diff --git a/src/vendor/computer-use-mcp/types.ts b/src/vendor/computer-use-mcp/types.ts new file mode 100644 index 00000000..3519a733 --- /dev/null +++ b/src/vendor/computer-use-mcp/types.ts @@ -0,0 +1,621 @@ +import type { + ComputerExecutor, + InstalledApp, + ScreenshotResult, +} from "./executor.js"; + +/** `ScreenshotResult` without the base64 blob. The shape hosts persist for + * cross-respawn `scaleCoord` survival. */ +export type ScreenshotDims = Omit; + +/** Shape mirrors claude-for-chrome-mcp/src/types.ts:1-7 */ +export interface Logger { + info: (message: string, ...args: unknown[]) => void; + error: (message: string, ...args: unknown[]) => void; + warn: (message: string, ...args: unknown[]) => void; + debug: (message: string, ...args: unknown[]) => void; + silly: (message: string, ...args: unknown[]) => void; +} + +/** + * Per-app permission tier. Hardcoded by category at grant time — the + * approval dialog displays the tier but the user cannot change it (for now). + * + * - `"read"` — visible in screenshots, NO interaction (no clicks, no typing). + * Browsers land here: the model can read a page that's already open, but + * must use the Claude-in-Chrome MCP for any navigation/clicking. Trading + * platforms land here too (no CiC alternative — the model asks the user). + * - `"click"` — visible + plain left-click, scroll. NO typing/keys, + * NO right/middle-click, NO modifier-clicks, NO drag-drop (all text- + * injection vectors). Terminals/IDEs land here: the model can click a + * Run button or scroll test output, but `type("rm -rf /")` is blocked + * and so is right-click→Paste and dragging text onto the terminal. + * - `"full"` — visible + click + type/key/paste. Everything else. + * + * Enforced in `runInputActionGates` via the frontmost-app check: keyboard + * actions require `"full"`, mouse actions require `"click"` or higher. + */ +export type CuAppPermTier = "read" | "click" | "full"; + +/** + * A single app the user has approved for the current session. Session-scoped + * only — there is no "once" or "forever" scope (unlike Chrome's per-domain + * three-way). CU has no natural "once" unit; one task = hundreds of clicks. + * Mirrors how `chromeAllowedDomains` is a plain `string[]` with no per-item + * scope. + */ +export interface AppGrant { + bundleId: string; + displayName: string; + /** Epoch ms. For Settings-page display ("Granted 3m ago"). */ + grantedAt: number; + /** Undefined → `"full"` (back-compat for pre-tier grants persisted in + * session state). */ + tier?: CuAppPermTier; +} + +/** Orthogonal to the app allowlist. */ +export interface CuGrantFlags { + clipboardRead: boolean; + clipboardWrite: boolean; + /** + * When false, the `key` tool rejects combos in `keyBlocklist.ts` + * (cmd+q, cmd+tab, cmd+space, cmd+shift+q, ctrl+alt+delete). All other + * key sequences work regardless. + */ + systemKeyCombos: boolean; +} + +export const DEFAULT_GRANT_FLAGS: CuGrantFlags = { + clipboardRead: false, + clipboardWrite: false, + systemKeyCombos: false, +}; + +/** + * Host picks via GrowthBook JSON feature `chicago_coordinate_mode`, baked + * into tool param descriptions at server-construction time. The model sees + * ONE convention and never learns the other exists. `normalized_0_100` + * sidesteps the Retina scaleFactor bug class entirely. + */ +export type CoordinateMode = "pixels" | "normalized_0_100"; + +/** + * Independent kill switches for subtle/risky ported behaviors. Read from + * GrowthBook by the host adapter, consulted in `toolCalls.ts`. + */ +export interface CuSubGates { + /** 9×9 exact-byte staleness guard before click. */ + pixelValidation: boolean; + /** Route `type("foo\nbar")` through clipboard instead of keystroke-by-keystroke. */ + clipboardPasteMultiline: boolean; + /** + * Ease-out-cubic mouse glide at 60fps, distance-proportional duration + * (2000 px/sec, capped at 0.5s). Adds up to ~0.5s latency + * per click. When off, cursor teleports instantly. + */ + mouseAnimation: boolean; + /** + * Pre-action sequence: hide non-allowlisted apps, then defocus us (from the + * Vercept acquisition). When off, the + * frontmost gate fires in the normal case and the model gets stuck — this + * is the A/B-test-the-old-broken-behavior switch. + */ + hideBeforeAction: boolean; + /** + * Auto-resolve the target display before each screenshot when the + * selected display has no allowed-app windows. When on, `handleScreenshot` + * uses the atomic Swift path; off → sticks with `selectedDisplayId`. + */ + autoTargetDisplay: boolean; + /** + * Stash+clear the clipboard while a tier-"click" app is frontmost. + * Closes the gap where a click-tier terminal/IDE has a UI Paste button + * that's plain-left-clickable — without this, the tier "click" + * keyboard block can be routed around by clicking Paste. Restored when + * a non-"click" app becomes frontmost, or at turn end. + */ + clipboardGuard: boolean; +} + +// ---------------------------------------------------------------------------- +// Permission request/response (mirror of BridgePermissionRequest, types.ts:77-94) +// ---------------------------------------------------------------------------- + +/** One entry per app the model asked for, after name → bundle ID resolution. */ +export interface ResolvedAppRequest { + /** What the model asked for (e.g. "Slack", "com.tinyspeck.slackmacgap"). */ + requestedName: string; + /** The resolved InstalledApp if found, else undefined (shown greyed in the UI). */ + resolved?: InstalledApp; + /** Shell-access-equivalent bundle IDs get a UI warning. See sentinelApps.ts. */ + isSentinel: boolean; + /** Already in the allowlist → skip the checkbox, return in `granted` immediately. */ + alreadyGranted: boolean; + /** Hardcoded tier for this app (browser→"read", terminal→"click", else "full"). + * The dialog displays this read-only; the renderer passes it through + * verbatim in the AppGrant. */ + proposedTier: CuAppPermTier; +} + +/** + * Payload for the renderer approval dialog. Rides through the existing + * `ToolPermissionRequest.input: unknown` field + * (packages/utils/desktop/bridge/common/claude.web.ts:1262) — no IPC schema + * change needed. + */ +export interface CuPermissionRequest { + requestId: string; + /** Model-provided reason string. Shown prominently in the approval UI. */ + reason: string; + apps: ResolvedAppRequest[]; + /** What the model asked for. User can toggle independently of apps. */ + requestedFlags: Partial; + /** + * For the "On Windows, Claude can see all apps..." footnote. Taken from + * `executor.capabilities.screenshotFiltering` so the renderer doesn't + * need to know about platforms. + */ + screenshotFiltering: "native" | "none"; + /** + * Present only when TCC permissions are NOT yet granted. When present, + * the renderer shows a TCC toggle panel (two rows: Accessibility, Screen + * Recording) INSTEAD OF the app list. Clicking a row's "Request" button + * triggers the OS prompt; the store polls on window-focus and flips the + * toggle when the grant is detected. macOS itself prompts the user to + * restart after granting Screen Recording — we don't. + */ + tccState?: { + accessibility: boolean; + screenRecording: boolean; + }; + /** + * Apps with windows on the CU display that aren't in the requested + * allowlist. These will be hidden the first time Claude takes an action. + * Computed at request_access time — may be slightly stale by the time the + * user clicks Allow, but it's a preview, not a contract. Absent when + * empty so the renderer can skip the section cleanly. + */ + willHide?: Array<{ bundleId: string; displayName: string }>; + /** + * `chicagoAutoUnhide` app preference at request time. The renderer picks + * between "...then restored when Claude is done" and "...will be hidden" + * copy. Absent when `willHide` is absent (same condition). + */ + autoUnhideEnabled?: boolean; +} + +/** + * What the renderer stuffs into `updatedInput._cuGrants` when the user clicks + * "Allow for this session" (mirror of the `_allowAllSites` sentinel at + * LocalAgentModeSessionManager.ts:2794). + */ +export interface CuPermissionResponse { + granted: AppGrant[]; + /** Bundle IDs the user unchecked, or apps that weren't installed. */ + denied: Array<{ bundleId: string; reason: "user_denied" | "not_installed" }>; + flags: CuGrantFlags; + /** + * Whether the user clicked Allow in THIS dialog. Only set by the + * teach-mode handler — regular request_access doesn't need it (the + * session manager's `result.behavior` gates the merge there). Needed + * because when all requested apps are already granted (skipDialogGrants + * non-empty, needDialog empty), Allow and Deny produce identical + * `{granted:[], denied:[]}` payloads and the tool handler can't tell + * them apart without this. Undefined → legacy/regular path, do not + * gate on it. + */ + userConsented?: boolean; +} + +// ---------------------------------------------------------------------------- +// Host adapter (mirror of ClaudeForChromeContext, types.ts:33-62) +// ---------------------------------------------------------------------------- + +/** + * Process-lifetime singleton dependencies. Everything that does NOT vary per + * tool call. Built once by `apps/desktop/src/main/nest-only/chicago/hostAdapter.ts`. + * No Electron imports in this package — the host injects everything. + */ +export interface ComputerUseHostAdapter { + serverName: string; + logger: Logger; + executor: ComputerExecutor; + + /** + * TCC state check — Accessibility + Screen Recording on macOS. Pure check, + * no dialog, no relaunch. When either is missing, `request_access` threads + * the state through to the renderer which shows a toggle panel; all other + * tools return a tool error. + */ + ensureOsPermissions(): Promise< + { granted: boolean; accessibility?: boolean; screenRecording?: boolean } + >; + + /** The Settings-page kill switch (`chicagoEnabled` app preference). */ + isDisabled(): boolean; + + /** + * The `chicagoAutoUnhide` app preference. Consumed by `buildAccessRequest` + * to populate `CuPermissionRequest.autoUnhideEnabled` so the renderer's + * "will be hidden" copy can say "then restored" only when true. + */ + getAutoUnhideEnabled(): boolean; + + /** + * Sub-gates re-read on every tool call so GrowthBook flips take effect + * mid-session without restart. + */ + getSubGates(): CuSubGates; + + /** + * JPEG decode + crop + raw pixel bytes, for the PixelCompare staleness guard. + * Injected so this package stays Electron-free. The host implements it via + * `nativeImage.createFromBuffer(jpeg).crop(rect).toBitmap()` — Chromium's + * decoders, BSD-licensed, no `.node` binary. + * + * Returns null on decode/crop failure — caller treats null as `skipped`, + * click proceeds (validation failure must never block the action). + */ + cropRawPatch( + jpegBase64: string, + rect: { x: number; y: number; width: number; height: number }, + ): Buffer | null; +} + +// ---------------------------------------------------------------------------- +// Session context (getter/callback bag for bindSessionContext) +// ---------------------------------------------------------------------------- + +/** + * Per-session state binding for `bindSessionContext`. Hosts build this once + * per session with getters that read fresh from their session store and + * callbacks that write back. The returned dispatcher builds + * `ComputerUseOverrides` from these getters on every call. + * + * Callbacks must be set at construction time — `bindSessionContext` reads + * them once at bind, not per call. + * + * The lock hooks are **async** — `bindSessionContext` awaits them before + * `handleToolCall`, then passes `checkCuLock: undefined` in overrides so the + * sync Gate-3 in `handleToolCall` no-ops. Hosts with in-memory sync locks + * (Cowork) wrap them trivially; hosts with cross-process locks (the CLI's + * O_EXCL file) call the real async primitive directly. + */ +export interface ComputerUseSessionContext { + // ── Read state fresh per call ────────────────────────────────────── + + getAllowedApps(): readonly AppGrant[]; + getGrantFlags(): CuGrantFlags; + /** Per-user auto-deny list (Settings page). Empty array = none. */ + getUserDeniedBundleIds(): readonly string[]; + getSelectedDisplayId(): number | undefined; + getDisplayPinnedByModel?(): boolean; + getDisplayResolvedForApps?(): string | undefined; + getTeachModeActive?(): boolean; + /** Dims-only fallback when `lastScreenshot` is unset (cross-respawn). + * `bindSessionContext` reconstructs `{...dims, base64: ""}` so scaleCoord + * works and pixelCompare correctly skips. */ + getLastScreenshotDims?(): ScreenshotDims | undefined; + + // ── Write-back callbacks ─────────────────────────────────────────── + + /** Shows the approval dialog. Host routes to its UI, awaits user. The + * signal is aborted if the tool call finishes before the user answers + * (MCP timeout, etc.) — hosts dismiss the dialog on abort. */ + onPermissionRequest?( + req: CuPermissionRequest, + signal: AbortSignal, + ): Promise; + /** Teach-mode sibling of `onPermissionRequest`. */ + onTeachPermissionRequest?( + req: CuTeachPermissionRequest, + signal: AbortSignal, + ): Promise; + /** Called by `bindSessionContext` after merging a permission response into + * the allowlist (dedupe on bundleId, truthy-only flag spread). Host + * persists for resume survival. */ + onAllowedAppsChanged?(apps: readonly AppGrant[], flags: CuGrantFlags): void; + onAppsHidden?(bundleIds: string[]): void; + /** Reads the session's clipboardGuard stash. undefined → no stash held. */ + getClipboardStash?(): string | undefined; + /** Writes the clipboardGuard stash. undefined clears it. */ + onClipboardStashChanged?(stash: string | undefined): void; + onResolvedDisplayUpdated?(displayId: number): void; + onDisplayPinned?(displayId: number | undefined): void; + onDisplayResolvedForApps?(sortedBundleIdsKey: string): void; + /** Called after each screenshot. Host persists for respawn survival. */ + onScreenshotCaptured?(dims: ScreenshotDims): void; + onTeachModeActivated?(): void; + onTeachStep?(req: TeachStepRequest): Promise; + onTeachWorking?(): void; + + // ── Lock (async) ─────────────────────────────────────────────────── + + /** At most one session uses CU at a time. Awaited by `bindSessionContext` + * before dispatch. Undefined → no lock gating (proceed). */ + checkCuLock?(): Promise<{ holder: string | undefined; isSelf: boolean }>; + /** Take the lock. Called when `checkCuLock` returned `holder: undefined` + * on a non-deferring tool. Host emits enter-CU signals here. */ + acquireCuLock?(): Promise; + /** Host-specific lock-held error text. Default is the package's generic + * message. The CLI host includes the holder session-ID prefix. */ + formatLockHeldMessage?(holder: string): string; + + /** User-abort signal. Passed through to `ComputerUseOverrides.isAborted` + * for the mid-loop checks in handleComputerBatch / handleType. See that + * field for semantics. */ + isAborted?(): boolean; +} + +// ---------------------------------------------------------------------------- +// Per-call overrides (mirror of PermissionOverrides, types.ts:97-102) +// ---------------------------------------------------------------------------- + +/** + * Built FRESH on every tool call by `bindSessionContext` from + * `ComputerUseSessionContext` getters. This is what lets a singleton MCP + * server carry per-session state — the state lives on the host's session + * store, not the server. + */ +export interface ComputerUseOverrides { + allowedApps: AppGrant[]; + grantFlags: CuGrantFlags; + coordinateMode: CoordinateMode; + + /** + * User-configured auto-deny list (Settings → Desktop app → Computer Use). + * Bundle IDs + * here are stripped from request_access BEFORE the approval dialog — they + * never reach the user for approval regardless of tier. The response tells + * the agent to ask the user to remove the app from their deny list in + * Settings if access is genuinely needed. + * + * Per-USER, persists across restarts (read from appPreferences per call, + * not session state). Contrast with `allowedApps` which is per-session. + * Empty array = no user-configured denies (the default). + */ + userDeniedBundleIds: readonly string[]; + + /** + * Display CU operates on; read fresh per call. `scaleCoord` uses the + * `originX/Y` snapshotted in `lastScreenshot`, so mid-session switches + * only affect the NEXT screenshot/prepare call. + */ + selectedDisplayId?: number; + + /** + * The `request_access` tool handler calls this and awaits. The wrapper + * closure in serverDef.ts (mirroring InternalMcpServerManager.ts:131-177) + * routes through `handleToolPermission` → IPC → renderer ChicagoApproval. + * When it resolves, the wrapper side-effectfully mutates + * `InternalServerContext.cuAllowedApps` BEFORE returning here. + * + * Undefined when the session wasn't wired with a permission handler (e.g. + * a future headless mode). `request_access` returns a tool error in that case. + */ + onPermissionRequest?: (req: CuPermissionRequest) => Promise; + + /** + * For the pixel-validation staleness guard. The model's-last-screenshot, + * stashed by serverDef.ts after each `screenshot` tool call. Undefined on + * cold start → pixel validation skipped (click proceeds). + */ + lastScreenshot?: ScreenshotResult; + + /** + * Fired after every `prepareForAction` with the bundle IDs it just hid. + * The wrapper closure in serverDef.ts accumulates these into + * `Session.cuHiddenDuringTurn` via a write-through callback (same pattern + * as `onCuPermissionUpdated`). At turn end (`sdkMessage.type === "result"`), + * if the `chicagoAutoUnhide` setting is on, everything in the set is + * unhidden. Set is cleared regardless of the setting so it doesn't leak + * across turns. + * + * Undefined when the session wasn't wired with a tracker — unhide just + * doesn't happen. + */ + onAppsHidden?: (bundleIds: string[]) => void; + + /** + * Reads the clipboardGuard stash from session state. `undefined` means no + * stash is held — `syncClipboardStash` stashes on first entry to click-tier + * and clears on restore. Sibling of the `cuHiddenDuringTurn` getter pattern + * — state lives on the host's session, not module-level here. + */ + getClipboardStash?: () => string | undefined; + + /** + * Writes the clipboardGuard stash to session state. `undefined` clears. + * Sibling of `onAppsHidden` — the wrapper closure writes through to + * `Session.cuClipboardStash`. At turn end the host reads + clears it + * directly and restores via Electron's `clipboard.writeText` (no nest-only + * import surface). + */ + onClipboardStashChanged?: (stash: string | undefined) => void; + + /** + * Write the resolver's picked display back to session so teach overlay + * positioning and subsequent non-resolver calls use the same display. + * Fired by `handleScreenshot` in the atomic `autoTargetDisplay` path when + * `resolvePrepareCapture`'s pick differs from `selectedDisplayId`. + * Fire-and-forget. + */ + onResolvedDisplayUpdated?: (displayId: number) => void; + + /** + * Set when the model explicitly picked a display via `switch_display`. + * When true, `handleScreenshot` passes `autoResolve: false` so the Swift + * resolver honors `selectedDisplayId` directly (straight cuDisplayInfo + * passthrough) instead of running the co-location/chase chain. The + * resolver's Step 2 ("host + allowed co-located → host") otherwise + * overrides any `selectedDisplayId` whenever an allowed app shares the + * host's monitor. + */ + displayPinnedByModel?: boolean; + + /** + * Write the model's explicit display pick to session. `displayId: + * undefined` clears both `selectedDisplayId` and the pin (back to auto). + * Sibling of `onResolvedDisplayUpdated` but also sets the pin flag — + * the two are semantically distinct (resolver-picked vs model-picked). + */ + onDisplayPinned?: (displayId: number | undefined) => void; + + /** + * Sorted comma-joined bundle-ID set the display was last auto-resolved + * for. `handleScreenshot` compares this to the current allowed set and + * only passes `autoResolve: true` when they differ — so the resolver + * doesn't yank the display on every screenshot, only when the app set + * has changed since the last resolve (or manual switch). + */ + displayResolvedForApps?: string; + + /** + * Records which app set the current display selection was made for. Fired + * alongside `onResolvedDisplayUpdated` when the resolver picks, so the next + * screenshot sees a matching set and skips auto-resolve. + */ + onDisplayResolvedForApps?: (sortedBundleIdsKey: string) => void; + + /** + * Global CU lock — at most one session actively uses CU at a time. Checked + * in `handleToolCall` after kill-switch/TCC, before dispatch. Every CU tool + * including `request_access` goes through it. + * + * - `holder === undefined` → lock is free, safe to acquire + * - `isSelf === true` → this session already holds it (no-op, proceed) + * - `holder !== undefined && !isSelf` → blocked, return tool error + * + * `undefined` callback → lock system not wired (e.g. CCD). Proceed without + * gating — absence of the mechanism ≠ locked out. + * + * The host manages release (on session idle/stop/archive) — this package + * never releases. + */ + checkCuLock?: () => { holder: string | undefined; isSelf: boolean }; + + /** + * Take the lock for this session. `handleToolCall` calls this exactly once + * per turn, on the FIRST CU tool call when `checkCuLock().holder` is + * undefined. No-op if already held (defensive — the check should have + * short-circuited). Host emits an event the overlay listens to. + */ + acquireCuLock?: () => void; + + /** + * User-abort signal. Checked mid-iteration inside `handleComputerBatch` + * and `handleType`'s grapheme loop so an in-flight batch/type stops + * promptly on overlay Stop instead of running to completion after the + * host has already abandoned the tool result. + * + * Undefined → never aborts (e.g. unwired host). Live per-check read — + * same lazy-getter pattern as `checkCuLock`. + */ + isAborted?: () => boolean; + + // ── Teach mode ─────────────────────────────────────────────────────── + // Wired only when the host's teachModeEnabled gate is on. All five + // undefined → `request_teach_access` / `teach_step` return tool errors + // and teach mode is effectively off. + + /** + * Sibling of `onPermissionRequest`. Same blocking-await-on-renderer-dialog + * semantics, but routes to ComputerUseTeachApproval.tsx (which explains + * the window-hides-during-guide behavior) instead of ComputerUseApproval. + * The wrapper closure in serverDef.ts writes grants through to session state + * via `onCuPermissionUpdated` exactly as `onPermissionRequest` does. + */ + onTeachPermissionRequest?: ( + req: CuTeachPermissionRequest, + ) => Promise; + + /** + * Called by `handleRequestTeachAccess` after the user approves and at least + * one app was granted. Host sets `session.teachModeActive = true`, emits + * `teachModeChanged` → teach controller hides the main window and shows the + * fullscreen overlay. Cleared by the host on turn end (`transitionTo("idle")`) + * alongside the CU lock release. + */ + onTeachModeActivated?: () => void; + + /** + * Read by `handleRequestAccess` and `handleRequestTeachAccess` to + * short-circuit with a clear tool error when teach mode is active. The + * main window is hidden during teach mode, so permission dialogs render + * invisibly and handleToolPermission blocks forever on an invisible + * prompt. Better to tell the model to exit teach mode first. Getter + * (not a boolean field) because teach mode state lives on the session, + * not on this per-call overrides object. + */ + getTeachModeActive?: () => boolean; + + /** + * Called by `handleTeachStep` with the scaled anchor + text. Host stores + * the resolver, emits `teachStepRequested` → teach controller pushes the + * payload to the overlay → user reads, clicks Next → IPC → host calls the + * stored resolver → this promise resolves. `{action: "exit"}` when the user + * clicks Exit (or the turn is interrupted) — `handleTeachStep` short-circuits + * without executing actions. + * + * Same blocking-promise pattern as `onPermissionRequest`, but resolved by + * the teach overlay's own preload (not the main renderer's tool-approval UI). + */ + onTeachStep?: (req: TeachStepRequest) => Promise; + + /** + * Called immediately after `onTeachStep` resolves with "next", before + * action dispatch begins. Host emits `teachStepWorking` → overlay flips to + * the spinner state (Next button gone, Exit stays, "Working…" + rotating + * notch). The next `onTeachStep` call replaces the spinner with the new + * tooltip content. + */ + onTeachWorking?: () => void; +} + +// ---------------------------------------------------------------------------- +// Teach mode (guided-tour tooltips with Next-button action execution) +// ---------------------------------------------------------------------------- + +/** + * Payload the host pushes to the teach overlay BrowserWindow. Built by + * `handleTeachStep` in toolCalls.ts from the model's `teach_step` args. + * + * `anchorLogical` here is POST-`scaleCoord` — **full-display** logical + * macOS points (origin = monitor top-left, menu bar included, since + * cuDisplayInfo returns CGDisplayBounds). The overlay window is positioned + * at `workArea.{x,y}` (excludes menu bar/Dock), so `updateTeachStep` in + * teach/window.ts subtracts the workArea offset before IPC so the HTML's + * CSS coords match. + */ +export interface TeachStepRequest { + explanation: string; + nextPreview: string; + /** Full-display logical points. Undefined → overlay centers the tooltip, hides the arrow. */ + anchorLogical?: { x: number; y: number }; +} + +export type TeachStepResult = { action: "next" } | { action: "exit" }; + +/** + * Payload for the renderer's ComputerUseTeachApproval dialog. Rides through + * `ToolPermissionRequest.input: unknown` same as `CuPermissionRequest`. + * Separate type (not a flag on `CuPermissionRequest`) so the two approval + * components can narrow independently and the teach dialog is free to drop + * fields it doesn't render (no grant-flag checkboxes in teach mode). + */ +export interface CuTeachPermissionRequest { + requestId: string; + /** Model-provided reason. Shown in the dialog headline ("guide you through {reason}"). */ + reason: string; + apps: ResolvedAppRequest[]; + screenshotFiltering: "native" | "none"; + /** Present only when TCC is ungranted — same semantics as `CuPermissionRequest.tccState`. */ + tccState?: { + accessibility: boolean; + screenRecording: boolean; + }; + willHide?: Array<{ bundleId: string; displayName: string }>; + /** Same semantics as `CuPermissionRequest.autoUnhideEnabled`. */ + autoUnhideEnabled?: boolean; +}