style: 格式化 packages/@ant/ 下所有文件以通过 biome ci

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-05-01 21:55:51 +08:00
parent c32f26cf21
commit 9ea9859dce
92 changed files with 5903 additions and 5188 deletions

View File

@@ -31,7 +31,7 @@
* duplicated as a string literal below rather than imported.
*/
export type DeniedCategory = "browser" | "terminal" | "trading";
export type DeniedCategory = 'browser' | 'terminal' | 'trading'
/**
* Map a category to its hardcoded tier. Return-type is the string-literal
@@ -44,54 +44,54 @@ export type DeniedCategory = "browser" | "terminal" | "trading";
*/
export function categoryToTier(
category: DeniedCategory | null,
): "read" | "click" | "full" {
if (category === "browser" || category === "trading") return "read";
if (category === "terminal") return "click";
return "full";
): '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<string> = new Set([
// Apple
"com.apple.Safari",
"com.apple.SafariTechnologyPreview",
'com.apple.Safari',
'com.apple.SafariTechnologyPreview',
// Google
"com.google.Chrome",
"com.google.Chrome.beta",
"com.google.Chrome.dev",
"com.google.Chrome.canary",
'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",
'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",
'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",
'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)
'company.thebrowser.Browser', // Arc
'company.thebrowser.dia', // Dia (agentic)
// Privacy-focused
"org.torproject.torbrowser",
"com.duckduckgo.macos.browser",
"ru.yandex.desktop.yandex-browser",
'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
'ai.perplexity.comet',
'com.sigmaos.sigmaos.macos', // SigmaOS
// Webkit-based misc
"com.kagi.kagimacOS", // Orion
]);
'com.kagi.kagimacOS', // Orion
])
/**
* Terminals + IDEs with integrated terminals. Supersets
@@ -101,66 +101,66 @@ const BROWSER_BUNDLE_IDS: ReadonlySet<string> = new Set([
*/
const TERMINAL_BUNDLE_IDS: ReadonlySet<string> = 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
'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",
'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)
'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",
'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
'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",
]);
'com.apple.ScriptEditor2',
'com.apple.Automator',
'com.apple.shortcuts',
])
/**
* Trading / crypto platforms — granted at tier `"read"` so the agent can see
@@ -178,29 +178,29 @@ const TERMINAL_BUNDLE_IDS: ReadonlySet<string> = new Set([
const TRADING_BUNDLE_IDS: ReadonlySet<string> = 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)
'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",
'com.install4j.5889-6375-8446-2021',
// Crypto
"com.binance.BinanceDesktop",
"com.electron.exodus",
'com.binance.BinanceDesktop',
'com.electron.exodus',
// Electrum uses PyInstaller with bundle_identifier=None → defaults to
// org.pythonmac.unspecified.<AppName>. 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",
'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) ─────────────────
//
@@ -215,78 +215,78 @@ const TRADING_BUNDLE_IDS: ReadonlySet<string> = new Set([
const POLICY_DENIED_BUNDLE_IDS: ReadonlySet<string> = 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",
'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
'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)
'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",
'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+",
'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",
'tubi',
'crunchyroll',
'vudu',
// E-readers / audiobooks
"kindle",
"apple books",
"kobo",
"play books",
"calibre",
"libby",
"readium",
"audible",
"libro.fm",
"speechify",
'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",
'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",
'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
@@ -298,19 +298,19 @@ export function isPolicyDenied(
bundleId: string | undefined,
displayName: string,
): boolean {
if (bundleId && POLICY_DENIED_BUNDLE_IDS.has(bundleId)) return true;
const lower = displayName.toLowerCase();
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;
if (lower.includes(sub)) return true
}
return false;
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;
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) ──────────────────────────────
@@ -325,160 +325,160 @@ export function getDeniedCategory(bundleId: string): DeniedCategory | null {
* first match, but groupings are by category for readability).
*/
const BROWSER_NAME_SUBSTRINGS: readonly string[] = [
"safari",
"chrome",
"firefox",
"microsoft edge",
"brave",
"opera",
"vivaldi",
"chromium",
'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",
'arc browser',
'tor browser',
'duckduckgo',
'yandex',
'orion browser',
// Agentic / AI browsers
"comet", // Perplexity's browser — "Comet" substring risks false positives
'comet', // Perplexity's browser — "Comet" substring risks false positives
// but leaving for now; "comet" in an app name is rare
"sigmaos",
"dia browser",
];
'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",
'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",
'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",
'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",
'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",
'intellij',
'pycharm',
'webstorm',
'clion',
'goland',
'rubymine',
'phpstorm',
'datagrip',
'rider',
'appcode',
'rustrover',
'fleet',
'android studio',
// Other IDEs
"sublime text",
"macvim",
"neovim",
"emacs",
"xcode",
"eclipse",
"netbeans",
];
'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
'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",
'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",
'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",
'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",
];
'decentraland',
'axie infinity',
'gods unchained',
]
/**
* Display-name substring match. Called when bundle-ID resolution returned
@@ -491,20 +491,20 @@ const TRADING_NAME_SUBSTRINGS: readonly string[] = [
export function getDeniedCategoryByDisplayName(
name: string,
): DeniedCategory | null {
const lower = name.toLowerCase();
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";
if (lower.includes(sub)) return 'trading'
}
for (const sub of BROWSER_NAME_SUBSTRINGS) {
if (lower.includes(sub)) return "browser";
if (lower.includes(sub)) return 'browser'
}
for (const sub of TERMINAL_NAME_SUBSTRINGS) {
if (lower.includes(sub)) return "terminal";
if (lower.includes(sub)) return 'terminal'
}
return null;
return null
}
/**
@@ -520,10 +520,10 @@ export function getDeniedCategoryForApp(
displayName: string,
): DeniedCategory | null {
if (bundleId) {
const byId = getDeniedCategory(bundleId);
if (byId) return byId;
const byId = getDeniedCategory(bundleId)
if (byId) return byId
}
return getDeniedCategoryByDisplayName(displayName);
return getDeniedCategoryByDisplayName(displayName)
}
/**
@@ -537,8 +537,8 @@ export function getDeniedCategoryForApp(
export function getDefaultTierForApp(
bundleId: string | undefined,
displayName: string,
): "read" | "click" | "full" {
return categoryToTier(getDeniedCategoryForApp(bundleId, displayName));
): 'read' | 'click' | 'full' {
return categoryToTier(getDeniedCategoryForApp(bundleId, displayName))
}
export const _test = {
@@ -550,4 +550,4 @@ export const _test = {
TERMINAL_NAME_SUBSTRINGS,
TRADING_NAME_SUBSTRINGS,
POLICY_DENIED_NAME_SUBSTRINGS,
};
}

View File

@@ -116,9 +116,17 @@ export interface ComputerExecutor {
// ── Window management (Windows only, optional) ──────────────────────────
/** Perform a window management action on the bound window. Win32 API only — no global shortcuts. */
manageWindow?(action: string, opts?: { x?: number; y?: number; width?: number; height?: number }): Promise<boolean>
manageWindow?(
action: string,
opts?: { x?: number; y?: number; width?: number; height?: number },
): Promise<boolean>
/** Get the current window rect of the bound window */
getWindowRect?(): Promise<{ x: number; y: number; width: number; height: number } | null>
getWindowRect?(): Promise<{
x: number
y: number
width: number
height: number
} | null>
// ── Element-targeted actions (Windows UIA, optional) ────────────────────
/** Open terminal and launch an agent CLI */
@@ -129,17 +137,32 @@ export interface ComputerExecutor {
workingDirectory?: string
}): Promise<{ hwnd: string; title: string; launched: boolean } | null>
/** Bind to a window by hwnd/title/pid. Returns bound window info or null. */
bindToWindow?(query: { hwnd?: string; title?: string; pid?: number }): Promise<{ hwnd: string; title: string; pid: number } | null>
bindToWindow?(query: {
hwnd?: string
title?: string
pid?: number
}): Promise<{ hwnd: string; title: string; pid: number } | null>
/** Unbind from the current window */
unbindFromWindow?(): Promise<void>
/** Cheap binding-state check for window-targeted routing decisions. */
hasBoundWindow?(): Promise<boolean>
/** Get current binding status */
getBindingStatus?(): Promise<{ bound: boolean; hwnd?: string; title?: string; pid?: number; rect?: { x: number; y: number; width: number; height: number } } | null>
getBindingStatus?(): Promise<{
bound: boolean
hwnd?: string
title?: string
pid?: number
rect?: { x: number; y: number; width: number; height: number }
} | null>
/** List all visible windows */
listVisibleWindows?(): Promise<Array<{ hwnd: string; pid: number; title: string }>>
listVisibleWindows?(): Promise<
Array<{ hwnd: string; pid: number; title: string }>
>
/** Control the status indicator overlay */
statusIndicator?(action: 'show' | 'hide' | 'status', message?: string): Promise<{ active: boolean; message?: string }>
statusIndicator?(
action: 'show' | 'hide' | 'status',
message?: string,
): Promise<{ active: boolean; message?: string }>
/** Virtual keyboard — send keys/text/combos to bound window only */
virtualKeyboard?(opts: {
action: 'type' | 'combo' | 'press' | 'release' | 'hold'
@@ -149,12 +172,26 @@ export interface ComputerExecutor {
}): Promise<boolean>
/** Virtual mouse — click/move/drag on bound window only */
virtualMouse?(opts: {
action: 'click' | 'double_click' | 'right_click' | 'move' | 'drag' | 'down' | 'up'
x: number; y: number
startX?: number; startY?: number
action:
| 'click'
| 'double_click'
| 'right_click'
| 'move'
| 'drag'
| 'down'
| 'up'
x: number
y: number
startX?: number
startY?: number
}): Promise<boolean>
/** Mouse wheel scroll at client coordinates (works on Excel, browsers, modern UI) */
mouseWheel?(x: number, y: number, delta: number, horizontal?: boolean): Promise<boolean>
mouseWheel?(
x: number,
y: number,
delta: number,
horizontal?: boolean,
): Promise<boolean>
/** Activate the bound window (foreground + click to focus) */
activateWindow?(clickX?: number, clickY?: number): Promise<boolean>
/** Handle a terminal prompt (yes/no/select/type + enter) */
@@ -165,7 +202,14 @@ export interface ComputerExecutor {
text?: string
}): Promise<boolean>
/** Click an element by name/role/automationId via UI Automation */
clickElement?(query: { name?: string; role?: string; automationId?: string }): Promise<boolean>
clickElement?(query: {
name?: string
role?: string
automationId?: string
}): Promise<boolean>
/** Type text into an element by name/role/automationId via UI Automation ValuePattern */
typeIntoElement?(query: { name?: string; role?: string; automationId?: string }, text: string): Promise<boolean>
typeIntoElement?(
query: { name?: string; role?: string; automationId?: string },
text: string,
): Promise<boolean>
}

View File

@@ -13,9 +13,9 @@
*/
export interface ResizeParams {
pxPerToken: number;
maxTargetPx: number;
maxTargetTokens: number;
pxPerToken: number
maxTargetPx: number
maxTargetTokens: number
}
/**
@@ -27,11 +27,11 @@ 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;
return Math.floor((px - 1) / pxPerToken) + 1
}
function nTokensForImg(
@@ -39,7 +39,7 @@ function nTokensForImg(
height: number,
pxPerToken: number,
): number {
return nTokensForPx(width, pxPerToken) * nTokensForPx(height, pxPerToken);
return nTokensForPx(width, pxPerToken) * nTokensForPx(height, pxPerToken)
}
/**
@@ -62,47 +62,47 @@ export function targetImageSize(
height: number,
params: ResizeParams,
): [number, number] {
const { pxPerToken, maxTargetPx, maxTargetTokens } = params;
const { pxPerToken, maxTargetPx, maxTargetTokens } = params
if (
width <= maxTargetPx &&
height <= maxTargetPx &&
nTokensForImg(width, height, pxPerToken) <= maxTargetTokens
) {
return [width, height];
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 [w, h] = targetImageSize(height, width, params)
return [h, w]
}
const aspectRatio = width / height;
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;
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);
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;
lowerBoundWidth = middleWidth
} else {
upperBoundWidth = middleWidth;
upperBoundWidth = middleWidth
}
}
}

View File

@@ -6,7 +6,7 @@ export type {
ResolvePrepareCaptureResult,
RunningApp,
ScreenshotResult,
} from "./executor.js";
} from './executor.js'
export type {
AppGrant,
@@ -25,15 +25,15 @@ export type {
ScreenshotDims,
TeachStepRequest,
TeachStepResult,
} from "./types.js";
} from './types.js'
export { DEFAULT_GRANT_FLAGS } from "./types.js";
export { DEFAULT_GRANT_FLAGS } from './types.js'
export {
SENTINEL_BUNDLE_IDS,
getSentinelCategory,
} from "./sentinelApps.js";
export type { SentinelCategory } from "./sentinelApps.js";
} from './sentinelApps.js'
export type { SentinelCategory } from './sentinelApps.js'
export {
categoryToTier,
@@ -42,28 +42,28 @@ export {
getDeniedCategoryByDisplayName,
getDeniedCategoryForApp,
isPolicyDenied,
} from "./deniedApps.js";
export type { DeniedCategory } from "./deniedApps.js";
} from './deniedApps.js'
export type { DeniedCategory } from './deniedApps.js'
export { isSystemKeyCombo, normalizeKeySequence } from "./keyBlocklist.js";
export { isSystemKeyCombo, normalizeKeySequence } from './keyBlocklist.js'
export { ALL_SUB_GATES_OFF, ALL_SUB_GATES_ON } from "./subGates.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 { API_RESIZE_PARAMS, targetImageSize } from './imageResize.js'
export type { ResizeParams } from './imageResize.js'
export { defersLockAcquire, handleToolCall } from "./toolCalls.js";
export { defersLockAcquire, handleToolCall } from './toolCalls.js'
export type {
CuCallTelemetry,
CuCallToolResult,
CuErrorKind,
} from "./toolCalls.js";
} from './toolCalls.js'
export { bindSessionContext, createComputerUseMcpServer } from "./mcpServer.js";
export { buildComputerUseTools } from "./tools.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";
} from './pixelCompare.js'
export type { CropRawPatchFn, PixelCompareResult } from './pixelCompare.js'

View File

@@ -21,32 +21,32 @@
*/
const CANONICAL_MODIFIER: Readonly<Record<string, string>> = {
// Key::Meta — "meta"|"super"|"command"|"cmd"|"windows"|"win"
meta: "meta",
super: "meta",
command: "meta",
cmd: "meta",
windows: "meta",
win: "meta",
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",
ctrl: 'ctrl',
control: 'ctrl',
lctrl: 'ctrl',
lcontrol: 'ctrl',
rctrl: 'ctrl',
rcontrol: 'ctrl',
// Key::Shift + LShift + RShift
shift: "shift",
lshift: "shift",
rshift: "shift",
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",
};
alt: 'alt',
option: 'alt',
}
/** Sort order for canonicals. ctrl < alt < shift < meta. */
const MODIFIER_ORDER = ["ctrl", "alt", "shift", "meta"];
const MODIFIER_ORDER = ['ctrl', 'alt', 'shift', 'meta']
/**
* Canonical-form entries only. Every modifier must be a CANONICAL_MODIFIER
@@ -54,21 +54,21 @@ const MODIFIER_ORDER = ["ctrl", "alt", "shift", "meta"];
* 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
]);
'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
]);
'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.
@@ -78,25 +78,25 @@ const BLOCKED_WIN32 = new Set([
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[] = [];
.split('+')
.map(p => p.trim())
.filter(Boolean)
const mods: string[] = []
const keys: string[] = []
for (const p of parts) {
const canonical = CANONICAL_MODIFIER[p];
const canonical = CANONICAL_MODIFIER[p]
if (canonical !== undefined) {
mods.push(canonical);
mods.push(canonical)
} else {
keys.push(p);
keys.push(p)
}
}
// Dedupe: "cmd+command+q" → "meta+q", not "meta+meta+q".
const uniqueMods = [...new Set(mods)];
const uniqueMods = [...new Set(mods)]
uniqueMods.sort(
(a, b) => MODIFIER_ORDER.indexOf(a) - MODIFIER_ORDER.indexOf(b),
);
return { mods: uniqueMods, keys };
)
return { mods: uniqueMods, keys }
}
/**
@@ -104,8 +104,8 @@ function partitionKeys(seq: string): { mods: string[]; keys: string[] } {
* canonical, dedupe, sort modifiers, non-modifiers last.
*/
export function normalizeKeySequence(seq: string): string {
const { mods, keys } = partitionKeys(seq);
return [...mods, ...keys].join("+");
const { mods, keys } = partitionKeys(seq)
return [...mods, ...keys].join('+')
}
/**
@@ -123,26 +123,26 @@ export function normalizeKeySequence(seq: string): string {
*/
export function isSystemKeyCombo(
seq: string,
platform: "darwin" | "win32",
platform: 'darwin' | 'win32',
): boolean {
const blocklist = platform === "darwin" ? BLOCKED_DARWIN : BLOCKED_WIN32;
const { mods, keys } = partitionKeys(seq);
const prefix = mods.length > 0 ? mods.join("+") + "+" : "";
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("+"));
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 true
}
}
return false;
return false
}
export const _test = {
@@ -150,4 +150,4 @@ export const _test = {
BLOCKED_DARWIN,
BLOCKED_WIN32,
MODIFIER_ORDER,
};
}

View File

@@ -17,21 +17,21 @@
* is the same either way.
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
} from '@modelcontextprotocol/sdk/types.js'
import type { ScreenshotResult } from "./executor.js";
import type { CuCallToolResult } from "./toolCalls.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";
} from './toolCalls.js'
import { buildComputerUseTools } from './tools.js'
import type {
AppGrant,
ComputerUseHostAdapter,
@@ -40,12 +40,12 @@ import type {
CoordinateMode,
CuGrantFlags,
CuPermissionResponse,
} from "./types.js";
import { DEFAULT_GRANT_FLAGS } from "./types.js";
} 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.";
'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
@@ -60,20 +60,20 @@ function mergePermissionResponse(
existingFlags: CuGrantFlags,
response: CuPermissionResponse,
): { apps: AppGrant[]; flags: CuGrantFlags } {
const seen = new Set(existing.map((a) => a.bundleId));
const seen = new Set(existing.map(a => a.bundleId))
const apps = [
...existing,
...response.granted.filter((g) => !seen.has(g.bundleId)),
];
...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 };
}
return { apps, flags }
}
/**
@@ -91,53 +91,53 @@ export function bindSessionContext(
coordinateMode: CoordinateMode,
ctx: ComputerUseSessionContext,
): (name: string, args: unknown) => Promise<CuCallToolResult> {
const { logger, serverName } = adapter;
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;
let lastScreenshot: ScreenshotResult | undefined
const wrapPermission = ctx.onPermissionRequest
? async (
req: Parameters<NonNullable<typeof ctx.onPermissionRequest>>[0],
signal: AbortSignal,
): Promise<CuPermissionResponse> => {
const response = await ctx.onPermissionRequest!(req, signal);
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;
)
ctx.onAllowedAppsChanged?.(apps, flags)
return response
}
: undefined;
: undefined
const wrapTeachPermission = ctx.onTeachPermissionRequest
? async (
req: Parameters<NonNullable<typeof ctx.onTeachPermissionRequest>>[0],
signal: AbortSignal,
): Promise<CuPermissionResponse> => {
const response = await ctx.onTeachPermissionRequest!(req, signal);
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;
})
return response
}
: undefined;
: undefined
return async (name, args) => {
// ─── Async lock gate ─────────────────────────────────────────────────
@@ -146,18 +146,18 @@ export function bindSessionContext(
// 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();
const lock = await ctx.checkCuLock()
if (lock.holder !== undefined && !lock.isSelf) {
const text =
ctx.formatLockHeldMessage?.(lock.holder) ?? DEFAULT_LOCK_HELD_MESSAGE;
ctx.formatLockHeldMessage?.(lock.holder) ?? DEFAULT_LOCK_HELD_MESSAGE
return {
content: [{ type: "text", text }],
content: [{ type: 'text', text }],
isError: true,
telemetry: { error_kind: "cu_lock_held" },
};
telemetry: { error_kind: 'cu_lock_held' },
}
}
if (lock.holder === undefined && !defersLockAcquire(name)) {
await ctx.acquireCuLock?.();
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
@@ -165,21 +165,21 @@ export function bindSessionContext(
// 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();
const recheck = await ctx.checkCuLock()
if (recheck.holder !== undefined && !recheck.isSelf) {
const text =
ctx.formatLockHeldMessage?.(recheck.holder) ??
DEFAULT_LOCK_HELD_MESSAGE;
DEFAULT_LOCK_HELD_MESSAGE
return {
content: [{ type: "text", text }],
content: [{ type: 'text', text }],
isError: true,
telemetry: { error_kind: "cu_lock_held" },
};
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();
resetMouseButtonHeld()
}
}
@@ -189,12 +189,12 @@ export function bindSessionContext(
// isEmpty → skip.
const dimsFallback = lastScreenshot
? undefined
: ctx.getLastScreenshotDims?.();
: 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 dialogAbort = new AbortController()
const overrides: ComputerUseOverrides = {
allowedApps: [...ctx.getAllowedApps()],
@@ -206,12 +206,12 @@ export function bindSessionContext(
displayResolvedForApps: ctx.getDisplayResolvedForApps?.(),
lastScreenshot:
lastScreenshot ??
(dimsFallback ? { ...dimsFallback, base64: "" } : undefined),
(dimsFallback ? { ...dimsFallback, base64: '' } : undefined),
onPermissionRequest: wrapPermission
? (req) => wrapPermission(req, dialogAbort.signal)
? req => wrapPermission(req, dialogAbort.signal)
: undefined,
onTeachPermissionRequest: wrapTeachPermission
? (req) => wrapTeachPermission(req, dialogAbort.signal)
? req => wrapTeachPermission(req, dialogAbort.signal)
: undefined,
onAppsHidden: ctx.onAppsHidden,
getClipboardStash: ctx.getClipboardStash,
@@ -228,28 +228,28 @@ export function bindSessionContext(
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);
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);
lastScreenshot = result.screenshot
const { base64: _blob, ...dims } = result.screenshot
logger.debug(`[${serverName}] screenshot dims: ${JSON.stringify(dims)}`)
ctx.onScreenshotCaptured?.(dims)
}
return result;
return result
} finally {
dialogAbort.abort();
dialogAbort.abort()
}
};
}
}
export function createComputerUseMcpServer(
@@ -257,35 +257,36 @@ export function createComputerUseMcpServer(
coordinateMode: CoordinateMode,
context?: ComputerUseSessionContext,
): Server {
const { serverName, logger } = adapter;
const { serverName, logger } = adapter
const server = new Server(
{ name: serverName, version: "0.1.3" },
{ 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);
const dispatch = bindSessionContext(adapter, coordinateMode, context)
server.setRequestHandler(
CallToolRequestSchema,
async (request): Promise<CallToolResult> => {
const { screenshot: _s, telemetry: _t, ...result } = await dispatch(
request.params.name,
request.params.arguments ?? {},
);
return result;
const {
screenshot: _s,
telemetry: _t,
...result
} = await dispatch(request.params.name, request.params.arguments ?? {})
return result
},
);
return server;
)
return server
}
// Legacy: no context → stub handler. Reached only if something calls the
@@ -296,18 +297,18 @@ export function createComputerUseMcpServer(
async (request): Promise<CallToolResult> => {
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.",
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;
return server
}

View File

@@ -19,28 +19,28 @@
* this package never imports it — the crop is a function parameter.
*/
import type { ScreenshotResult } from "./executor.js";
import type { Logger } from "./types.js";
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;
) => 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;
const DEFAULT_GRID_SIZE = 9
export interface PixelCompareResult {
/** true → click may proceed. false → patch changed, abort the click. */
valid: boolean;
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;
skipped: boolean
/** Populated when valid === false. Returned to the model verbatim. */
warning?: string;
warning?: string
}
/**
@@ -57,22 +57,22 @@ function computeCropRect(
yPercent: number,
gridSize: number,
): { x: number; y: number; width: number; height: number } | null {
if (!imgW || !imgH) return 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 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 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;
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 };
return { x: cropX, y: cropY, width: cropW, height: cropH }
}
/**
@@ -98,17 +98,17 @@ export function comparePixelAtLocation(
xPercent,
yPercent,
gridSize,
);
if (!rect) return false;
)
if (!rect) return false
const patch1 = crop(lastScreenshot.base64, rect);
const patch2 = crop(freshScreenshot.base64, rect);
if (!patch1 || !patch2) 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);
return patch1.equals(patch2)
}
/**
@@ -135,13 +135,13 @@ export async function validateClickTarget(
gridSize: number = DEFAULT_GRID_SIZE,
): Promise<PixelCompareResult> {
if (!lastScreenshot) {
return { valid: true, skipped: true };
return { valid: true, skipped: true }
}
try {
const fresh = await takeFreshScreenshot();
const fresh = await takeFreshScreenshot()
if (!fresh) {
return { valid: true, skipped: true };
return { valid: true, skipped: true }
}
const pixelsMatch = comparePixelAtLocation(
@@ -151,21 +151,21 @@ export async function validateClickTarget(
xPercent,
yPercent,
gridSize,
);
)
if (pixelsMatch) {
return { valid: true, skipped: false };
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.",
};
'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 };
logger.debug('[pixelCompare] validation error, skipping', err)
return { valid: true, skipped: true }
}
}

View File

@@ -11,33 +11,33 @@
/** 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",
]);
'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 FILESYSTEM_ACCESS_BUNDLE_IDS = new Set(['com.apple.finder'])
const SYSTEM_SETTINGS_BUNDLE_IDS = new Set(["com.apple.systempreferences"]);
const SYSTEM_SETTINGS_BUNDLE_IDS = new Set(['com.apple.systempreferences'])
export const SENTINEL_BUNDLE_IDS: ReadonlySet<string> = new Set([
...SHELL_ACCESS_BUNDLE_IDS,
...FILESYSTEM_ACCESS_BUNDLE_IDS,
...SYSTEM_SETTINGS_BUNDLE_IDS,
]);
])
export type SentinelCategory = "shell" | "filesystem" | "system_settings";
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;
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
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,19 +2,19 @@ import type {
ComputerExecutor,
InstalledApp,
ScreenshotResult,
} from "./executor.js";
} from './executor.js'
/** `ScreenshotResult` without the base64 blob. The shape hosts persist for
* cross-respawn `scaleCoord` survival. */
export type ScreenshotDims = Omit<ScreenshotResult, "base64">;
export type ScreenshotDims = Omit<ScreenshotResult, 'base64'>
/** 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;
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
}
/**
@@ -35,7 +35,7 @@ export interface Logger {
* Enforced in `runInputActionGates` via the frontmost-app check: keyboard
* actions require `"full"`, mouse actions require `"click"` or higher.
*/
export type CuAppPermTier = "read" | "click" | "full";
export type CuAppPermTier = 'read' | 'click' | 'full'
/**
* A single app the user has approved for the current session. Session-scoped
@@ -45,32 +45,32 @@ export type CuAppPermTier = "read" | "click" | "full";
* scope.
*/
export interface AppGrant {
bundleId: string;
displayName: string;
bundleId: string
displayName: string
/** Epoch ms. For Settings-page display ("Granted 3m ago"). */
grantedAt: number;
grantedAt: number
/** Undefined → `"full"` (back-compat for pre-tier grants persisted in
* session state). */
tier?: CuAppPermTier;
tier?: CuAppPermTier
}
/** Orthogonal to the app allowlist. */
export interface CuGrantFlags {
clipboardRead: boolean;
clipboardWrite: boolean;
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;
systemKeyCombos: boolean
}
export const DEFAULT_GRANT_FLAGS: CuGrantFlags = {
clipboardRead: false,
clipboardWrite: false,
systemKeyCombos: false,
};
}
/**
* Host picks via GrowthBook JSON feature `chicago_coordinate_mode`, baked
@@ -78,7 +78,7 @@ export const DEFAULT_GRANT_FLAGS: CuGrantFlags = {
* 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";
export type CoordinateMode = 'pixels' | 'normalized_0_100'
/**
* Independent kill switches for subtle/risky ported behaviors. Read from
@@ -86,28 +86,28 @@ export type CoordinateMode = "pixels" | "normalized_0_100";
*/
export interface CuSubGates {
/** 9×9 exact-byte staleness guard before click. */
pixelValidation: boolean;
pixelValidation: boolean
/** Route `type("foo\nbar")` through clipboard instead of keystroke-by-keystroke. */
clipboardPasteMultiline: boolean;
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;
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;
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;
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
@@ -115,7 +115,7 @@ export interface CuSubGates {
* keyboard block can be routed around by clicking Paste. Restored when
* a non-"click" app becomes frontmost, or at turn end.
*/
clipboardGuard: boolean;
clipboardGuard: boolean
}
// ----------------------------------------------------------------------------
@@ -125,17 +125,17 @@ export interface CuSubGates {
/** 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;
requestedName: string
/** The resolved InstalledApp if found, else undefined (shown greyed in the UI). */
resolved?: InstalledApp;
resolved?: InstalledApp
/** Shell-access-equivalent bundle IDs get a UI warning. See sentinelApps.ts. */
isSentinel: boolean;
isSentinel: boolean
/** Already in the allowlist → skip the checkbox, return in `granted` immediately. */
alreadyGranted: boolean;
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;
proposedTier: CuAppPermTier
}
/**
@@ -145,18 +145,18 @@ export interface ResolvedAppRequest {
* change needed.
*/
export interface CuPermissionRequest {
requestId: string;
requestId: string
/** Model-provided reason string. Shown prominently in the approval UI. */
reason: string;
apps: ResolvedAppRequest[];
reason: string
apps: ResolvedAppRequest[]
/** What the model asked for. User can toggle independently of apps. */
requestedFlags: Partial<CuGrantFlags>;
requestedFlags: Partial<CuGrantFlags>
/**
* 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";
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
@@ -166,9 +166,9 @@ export interface CuPermissionRequest {
* restart after granting Screen Recording — we don't.
*/
tccState?: {
accessibility: boolean;
screenRecording: boolean;
};
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.
@@ -176,13 +176,13 @@ export interface CuPermissionRequest {
* 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 }>;
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;
autoUnhideEnabled?: boolean
}
/**
@@ -191,10 +191,10 @@ export interface CuPermissionRequest {
* LocalAgentModeSessionManager.ts:2794).
*/
export interface CuPermissionResponse {
granted: AppGrant[];
granted: AppGrant[]
/** Bundle IDs the user unchecked, or apps that weren't installed. */
denied: Array<{ bundleId: string; reason: "user_denied" | "not_installed" }>;
flags: CuGrantFlags;
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
@@ -205,7 +205,7 @@ export interface CuPermissionResponse {
* them apart without this. Undefined → legacy/regular path, do not
* gate on it.
*/
userConsented?: boolean;
userConsented?: boolean
}
// ----------------------------------------------------------------------------
@@ -218,9 +218,9 @@ export interface CuPermissionResponse {
* No Electron imports in this package — the host injects everything.
*/
export interface ComputerUseHostAdapter {
serverName: string;
logger: Logger;
executor: ComputerExecutor;
serverName: string
logger: Logger
executor: ComputerExecutor
/**
* TCC state check — Accessibility + Screen Recording on macOS. Pure check,
@@ -231,23 +231,23 @@ export interface ComputerUseHostAdapter {
ensureOsPermissions(): Promise<
| { granted: true }
| { granted: false; accessibility: boolean; screenRecording: boolean }
>;
>
/** The Settings-page kill switch (`chicagoEnabled` app preference). */
isDisabled(): boolean;
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;
getAutoUnhideEnabled(): boolean
/**
* Sub-gates re-read on every tool call so GrowthBook flips take effect
* mid-session without restart.
*/
getSubGates(): CuSubGates;
getSubGates(): CuSubGates
/**
* JPEG decode + crop + raw pixel bytes, for the PixelCompare staleness guard.
@@ -261,7 +261,7 @@ export interface ComputerUseHostAdapter {
cropRawPatch(
jpegBase64: string,
rect: { x: number; y: number; width: number; height: number },
): Buffer | null;
): Buffer | null
}
// ----------------------------------------------------------------------------
@@ -286,18 +286,18 @@ export interface ComputerUseHostAdapter {
export interface ComputerUseSessionContext {
// ── Read state fresh per call ──────────────────────────────────────
getAllowedApps(): readonly AppGrant[];
getGrantFlags(): CuGrantFlags;
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;
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;
getLastScreenshotDims?(): ScreenshotDims | undefined
// ── Write-back callbacks ───────────────────────────────────────────
@@ -307,46 +307,46 @@ export interface ComputerUseSessionContext {
onPermissionRequest?(
req: CuPermissionRequest,
signal: AbortSignal,
): Promise<CuPermissionResponse>;
): Promise<CuPermissionResponse>
/** Teach-mode sibling of `onPermissionRequest`. */
onTeachPermissionRequest?(
req: CuTeachPermissionRequest,
signal: AbortSignal,
): Promise<CuPermissionResponse>;
): Promise<CuPermissionResponse>
/** 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;
onAllowedAppsChanged?(apps: readonly AppGrant[], flags: CuGrantFlags): void
onAppsHidden?(bundleIds: string[]): void
/** Reads the session's clipboardGuard stash. undefined → no stash held. */
getClipboardStash?(): string | undefined;
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;
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<TeachStepResult>;
onTeachWorking?(): void;
onScreenshotCaptured?(dims: ScreenshotDims): void
onTeachModeActivated?(): void
onTeachStep?(req: TeachStepRequest): Promise<TeachStepResult>
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 }>;
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<void>;
acquireCuLock?(): Promise<void>
/** 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;
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;
isAborted?(): boolean
}
// ----------------------------------------------------------------------------
@@ -360,9 +360,9 @@ export interface ComputerUseSessionContext {
* store, not the server.
*/
export interface ComputerUseOverrides {
allowedApps: AppGrant[];
grantFlags: CuGrantFlags;
coordinateMode: CoordinateMode;
allowedApps: AppGrant[]
grantFlags: CuGrantFlags
coordinateMode: CoordinateMode
/**
* User-configured auto-deny list (Settings → Desktop app → Computer Use).
@@ -376,14 +376,14 @@ export interface ComputerUseOverrides {
* not session state). Contrast with `allowedApps` which is per-session.
* Empty array = no user-configured denies (the default).
*/
userDeniedBundleIds: readonly string[];
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;
selectedDisplayId?: number
/**
* The `request_access` tool handler calls this and awaits. The wrapper
@@ -395,14 +395,16 @@ export interface ComputerUseOverrides {
* 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<CuPermissionResponse>;
onPermissionRequest?: (
req: CuPermissionRequest,
) => Promise<CuPermissionResponse>
/**
* 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;
lastScreenshot?: ScreenshotResult
/**
* Fired after every `prepareForAction` with the bundle IDs it just hid.
@@ -416,7 +418,7 @@ export interface ComputerUseOverrides {
* Undefined when the session wasn't wired with a tracker — unhide just
* doesn't happen.
*/
onAppsHidden?: (bundleIds: string[]) => void;
onAppsHidden?: (bundleIds: string[]) => void
/**
* Reads the clipboardGuard stash from session state. `undefined` means no
@@ -424,7 +426,7 @@ export interface ComputerUseOverrides {
* and clears on restore. Sibling of the `cuHiddenDuringTurn` getter pattern
* — state lives on the host's session, not module-level here.
*/
getClipboardStash?: () => string | undefined;
getClipboardStash?: () => string | undefined
/**
* Writes the clipboardGuard stash to session state. `undefined` clears.
@@ -433,7 +435,7 @@ export interface ComputerUseOverrides {
* directly and restores via Electron's `clipboard.writeText` (no nest-only
* import surface).
*/
onClipboardStashChanged?: (stash: string | undefined) => void;
onClipboardStashChanged?: (stash: string | undefined) => void
/**
* Write the resolver's picked display back to session so teach overlay
@@ -442,7 +444,7 @@ export interface ComputerUseOverrides {
* `resolvePrepareCapture`'s pick differs from `selectedDisplayId`.
* Fire-and-forget.
*/
onResolvedDisplayUpdated?: (displayId: number) => void;
onResolvedDisplayUpdated?: (displayId: number) => void
/**
* Set when the model explicitly picked a display via `switch_display`.
@@ -453,7 +455,7 @@ export interface ComputerUseOverrides {
* overrides any `selectedDisplayId` whenever an allowed app shares the
* host's monitor.
*/
displayPinnedByModel?: boolean;
displayPinnedByModel?: boolean
/**
* Write the model's explicit display pick to session. `displayId:
@@ -461,7 +463,7 @@ export interface ComputerUseOverrides {
* Sibling of `onResolvedDisplayUpdated` but also sets the pin flag —
* the two are semantically distinct (resolver-picked vs model-picked).
*/
onDisplayPinned?: (displayId: number | undefined) => void;
onDisplayPinned?: (displayId: number | undefined) => void
/**
* Sorted comma-joined bundle-ID set the display was last auto-resolved
@@ -470,14 +472,14 @@ export interface ComputerUseOverrides {
* doesn't yank the display on every screenshot, only when the app set
* has changed since the last resolve (or manual switch).
*/
displayResolvedForApps?: string;
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;
onDisplayResolvedForApps?: (sortedBundleIdsKey: string) => void
/**
* Global CU lock — at most one session actively uses CU at a time. Checked
@@ -494,7 +496,7 @@ export interface ComputerUseOverrides {
* The host manages release (on session idle/stop/archive) — this package
* never releases.
*/
checkCuLock?: () => { holder: string | undefined; isSelf: boolean };
checkCuLock?: () => { holder: string | undefined; isSelf: boolean }
/**
* Take the lock for this session. `handleToolCall` calls this exactly once
@@ -502,7 +504,7 @@ export interface ComputerUseOverrides {
* undefined. No-op if already held (defensive — the check should have
* short-circuited). Host emits an event the overlay listens to.
*/
acquireCuLock?: () => void;
acquireCuLock?: () => void
/**
* User-abort signal. Checked mid-iteration inside `handleComputerBatch`
@@ -513,7 +515,7 @@ export interface ComputerUseOverrides {
* Undefined → never aborts (e.g. unwired host). Live per-check read —
* same lazy-getter pattern as `checkCuLock`.
*/
isAborted?: () => boolean;
isAborted?: () => boolean
// ── Teach mode ───────────────────────────────────────────────────────
// Wired only when the host's teachModeEnabled gate is on. All five
@@ -529,7 +531,7 @@ export interface ComputerUseOverrides {
*/
onTeachPermissionRequest?: (
req: CuTeachPermissionRequest,
) => Promise<CuPermissionResponse>;
) => Promise<CuPermissionResponse>
/**
* Called by `handleRequestTeachAccess` after the user approves and at least
@@ -538,7 +540,7 @@ export interface ComputerUseOverrides {
* fullscreen overlay. Cleared by the host on turn end (`transitionTo("idle")`)
* alongside the CU lock release.
*/
onTeachModeActivated?: () => void;
onTeachModeActivated?: () => void
/**
* Read by `handleRequestAccess` and `handleRequestTeachAccess` to
@@ -549,7 +551,7 @@ export interface ComputerUseOverrides {
* (not a boolean field) because teach mode state lives on the session,
* not on this per-call overrides object.
*/
getTeachModeActive?: () => boolean;
getTeachModeActive?: () => boolean
/**
* Called by `handleTeachStep` with the scaled anchor + text. Host stores
@@ -562,7 +564,7 @@ export interface ComputerUseOverrides {
* 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<TeachStepResult>;
onTeachStep?: (req: TeachStepRequest) => Promise<TeachStepResult>
/**
* Called immediately after `onTeachStep` resolves with "next", before
@@ -571,7 +573,7 @@ export interface ComputerUseOverrides {
* notch). The next `onTeachStep` call replaces the spinner with the new
* tooltip content.
*/
onTeachWorking?: () => void;
onTeachWorking?: () => void
}
// ----------------------------------------------------------------------------
@@ -590,13 +592,13 @@ export interface ComputerUseOverrides {
* CSS coords match.
*/
export interface TeachStepRequest {
explanation: string;
nextPreview: string;
explanation: string
nextPreview: string
/** Full-display logical points. Undefined → overlay centers the tooltip, hides the arrow. */
anchorLogical?: { x: number; y: number };
anchorLogical?: { x: number; y: number }
}
export type TeachStepResult = { action: "next" } | { action: "exit" };
export type TeachStepResult = { action: 'next' } | { action: 'exit' }
/**
* Payload for the renderer's ComputerUseTeachApproval dialog. Rides through
@@ -606,17 +608,17 @@ export type TeachStepResult = { action: "next" } | { action: "exit" };
* fields it doesn't render (no grant-flag checkboxes in teach mode).
*/
export interface CuTeachPermissionRequest {
requestId: string;
requestId: string
/** Model-provided reason. Shown in the dialog headline ("guide you through {reason}"). */
reason: string;
apps: ResolvedAppRequest[];
screenshotFiltering: "native" | "none";
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 }>;
accessibility: boolean
screenRecording: boolean
}
willHide?: Array<{ bundleId: string; displayName: string }>
/** Same semantics as `CuPermissionRequest.autoUnhideEnabled`. */
autoUnhideEnabled?: boolean;
autoUnhideEnabled?: boolean
}