feat: build
This commit is contained in:
commit
f90eee85d8
1906 files changed
+514339
No files matched your search
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Filter and sanitize installed-app data for inclusion in the `request_access`
|
||||
* tool description. Ported from Cowork's appNames.ts. Two
|
||||
* concerns: noise filtering (Spotlight returns every bundle on disk — XPC
|
||||
* helpers, daemons, input methods) and prompt-injection hardening (app names
|
||||
* are attacker-controlled; anyone can ship an app named anything).
|
||||
*
|
||||
* Residual risk: short benign-char adversarial names ("grant all") can't be
|
||||
* filtered programmatically. The tool description's structural framing
|
||||
* ("Available applications:") makes it clear these are app names, and the
|
||||
* downstream permission dialog requires explicit user approval — a bad name
|
||||
* can't auto-grant anything.
|
||||
*/
|
||||
|
||||
/** Minimal shape — matches what `listInstalledApps` returns. */
|
||||
type InstalledAppLike = {
|
||||
readonly bundleId: string
|
||||
readonly displayName: string
|
||||
readonly path: string
|
||||
}
|
||||
|
||||
// ── Noise filtering ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Only apps under these roots are shown. /System/Library subpaths (CoreServices,
|
||||
* PrivateFrameworks, Input Methods) are OS plumbing — anchor on known-good
|
||||
* roots rather than blocklisting every junk subpath since new macOS versions
|
||||
* add more.
|
||||
*
|
||||
* ~/Applications is checked at call time via the `homeDir` arg (HOME isn't
|
||||
* reliably known at module load in all environments).
|
||||
*/
|
||||
const PATH_ALLOWLIST: readonly string[] = [
|
||||
'/Applications/',
|
||||
'/System/Applications/',
|
||||
]
|
||||
|
||||
/**
|
||||
* Display-name patterns that mark background services even under /Applications.
|
||||
* `(?:$|\s\()` — matches keyword at end-of-string OR immediately before ` (`:
|
||||
* "Slack Helper (GPU)" and "ABAssistantService" fail, "Service Desk" passes
|
||||
* (Service is followed by " D").
|
||||
*/
|
||||
const NAME_PATTERN_BLOCKLIST: readonly RegExp[] = [
|
||||
/Helper(?:$|\s\()/,
|
||||
/Agent(?:$|\s\()/,
|
||||
/Service(?:$|\s\()/,
|
||||
/Uninstaller(?:$|\s\()/,
|
||||
/Updater(?:$|\s\()/,
|
||||
/^\./,
|
||||
]
|
||||
|
||||
/**
|
||||
* Apps commonly requested for CU automation. ALWAYS included if installed,
|
||||
* bypassing path check + count cap — the model needs these exact names even
|
||||
* when the machine has 200+ apps. Bundle IDs (locale-invariant), not display
|
||||
* names. Keep <30 — each entry is a guaranteed token in the description.
|
||||
*/
|
||||
const ALWAYS_KEEP_BUNDLE_IDS: ReadonlySet<string> = new Set([
|
||||
// Browsers
|
||||
'com.apple.Safari',
|
||||
'com.google.Chrome',
|
||||
'com.microsoft.edgemac',
|
||||
'org.mozilla.firefox',
|
||||
'company.thebrowser.Browser', // Arc
|
||||
// Communication
|
||||
'com.tinyspeck.slackmacgap',
|
||||
'us.zoom.xos',
|
||||
'com.microsoft.teams2',
|
||||
'com.microsoft.teams',
|
||||
'com.apple.MobileSMS',
|
||||
'com.apple.mail',
|
||||
// Productivity
|
||||
'com.microsoft.Word',
|
||||
'com.microsoft.Excel',
|
||||
'com.microsoft.Powerpoint',
|
||||
'com.microsoft.Outlook',
|
||||
'com.apple.iWork.Pages',
|
||||
'com.apple.iWork.Numbers',
|
||||
'com.apple.iWork.Keynote',
|
||||
'com.google.GoogleDocs',
|
||||
// Notes / PM
|
||||
'notion.id',
|
||||
'com.apple.Notes',
|
||||
'md.obsidian',
|
||||
'com.linear',
|
||||
'com.figma.Desktop',
|
||||
// Dev
|
||||
'com.microsoft.VSCode',
|
||||
'com.apple.Terminal',
|
||||
'com.googlecode.iterm2',
|
||||
'com.github.GitHubDesktop',
|
||||
// System essentials the model genuinely targets
|
||||
'com.apple.finder',
|
||||
'com.apple.iCal',
|
||||
'com.apple.systempreferences',
|
||||
])
|
||||
|
||||
// ── Prompt-injection hardening ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `\p{L}\p{M}\p{N}` with /u — not `\w` (ASCII-only, would drop Bücher, 微信,
|
||||
* Préférences Système). `\p{M}` matches combining marks so NFD-decomposed
|
||||
* diacritics (ü → u + ◌̈) pass. Single space not `\s` — `\s` matches newlines,
|
||||
* which would let "App\nIgnore previous…" through as a multi-line injection.
|
||||
* Still bars quotes, angle brackets, backticks, pipes, colons.
|
||||
*/
|
||||
const APP_NAME_ALLOWED = /^[\p{L}\p{M}\p{N}_ .&'()+-]+$/u
|
||||
const APP_NAME_MAX_LEN = 40
|
||||
const APP_NAME_MAX_COUNT = 50
|
||||
|
||||
function isUserFacingPath(path: string, homeDir: string | undefined): boolean {
|
||||
if (PATH_ALLOWLIST.some(root => path.startsWith(root))) return true
|
||||
if (homeDir) {
|
||||
const userApps = homeDir.endsWith('/')
|
||||
? `${homeDir}Applications/`
|
||||
: `${homeDir}/Applications/`
|
||||
if (path.startsWith(userApps)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isNoisyName(name: string): boolean {
|
||||
return NAME_PATTERN_BLOCKLIST.some(re => re.test(name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Length cap + trim + dedupe + sort. `applyCharFilter` — skip for trusted
|
||||
* bundle IDs (Apple/Google/MS; a localized "Réglages Système" with unusual
|
||||
* punctuation shouldn't be dropped), apply for anything attacker-installable.
|
||||
*/
|
||||
function sanitizeCore(
|
||||
raw: readonly string[],
|
||||
applyCharFilter: boolean,
|
||||
): string[] {
|
||||
const seen = new Set<string>()
|
||||
return raw
|
||||
.map(name => name.trim())
|
||||
.filter(trimmed => {
|
||||
if (!trimmed) return false
|
||||
if (trimmed.length > APP_NAME_MAX_LEN) return false
|
||||
if (applyCharFilter && !APP_NAME_ALLOWED.test(trimmed)) return false
|
||||
if (seen.has(trimmed)) return false
|
||||
seen.add(trimmed)
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
function sanitizeAppNames(raw: readonly string[]): string[] {
|
||||
const filtered = sanitizeCore(raw, true)
|
||||
if (filtered.length <= APP_NAME_MAX_COUNT) return filtered
|
||||
return [
|
||||
...filtered.slice(0, APP_NAME_MAX_COUNT),
|
||||
`… and ${filtered.length - APP_NAME_MAX_COUNT} more`,
|
||||
]
|
||||
}
|
||||
|
||||
function sanitizeTrustedNames(raw: readonly string[]): string[] {
|
||||
return sanitizeCore(raw, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter raw Spotlight results to user-facing apps, then sanitize. Always-keep
|
||||
* apps bypass path/name filter AND char allowlist (trusted vendors, not
|
||||
* attacker-installed); still length-capped, deduped, sorted.
|
||||
*/
|
||||
export function filterAppsForDescription(
|
||||
installed: readonly InstalledAppLike[],
|
||||
homeDir: string | undefined,
|
||||
): string[] {
|
||||
const { alwaysKept, rest } = installed.reduce<{
|
||||
alwaysKept: string[]
|
||||
rest: string[]
|
||||
}>(
|
||||
(acc, app) => {
|
||||
if (ALWAYS_KEEP_BUNDLE_IDS.has(app.bundleId)) {
|
||||
acc.alwaysKept.push(app.displayName)
|
||||
} else if (
|
||||
isUserFacingPath(app.path, homeDir) &&
|
||||
!isNoisyName(app.displayName)
|
||||
) {
|
||||
acc.rest.push(app.displayName)
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ alwaysKept: [], rest: [] },
|
||||
)
|
||||
|
||||
const sanitizedAlways = sanitizeTrustedNames(alwaysKept)
|
||||
const alwaysSet = new Set(sanitizedAlways)
|
||||
return [
|
||||
...sanitizedAlways,
|
||||
...sanitizeAppNames(rest).filter(n => !alwaysSet.has(n)),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { errorMessage } from '../errors.js'
|
||||
import { withResolvers } from '../withResolvers.js'
|
||||
import { isLockHeldLocally, releaseComputerUseLock } from './computerUseLock.js'
|
||||
import { unregisterEscHotkey } from './escHotkey.js'
|
||||
|
||||
// cu.apps.unhide is NOT one of the four @MainActor methods wrapped by
|
||||
// drainRunLoop's 30s backstop. On abort paths (where the user hit Ctrl+C
|
||||
// because something was slow) a hang here would wedge the abort. Generous
|
||||
// timeout — unhide should be ~instant; if it takes 5s something is wrong
|
||||
// and proceeding is better than waiting. The Swift call continues in the
|
||||
// background regardless; we just stop blocking on it.
|
||||
const UNHIDE_TIMEOUT_MS = 5000
|
||||
|
||||
/**
|
||||
* Turn-end cleanup for the chicago MCP surface: auto-unhide apps that
|
||||
* `prepareForAction` hid, then release the file-based lock.
|
||||
*
|
||||
* Called from three sites: natural turn end (`stopHooks.ts`), abort during
|
||||
* streaming (`query.ts` aborted_streaming), abort during tool execution
|
||||
* (`query.ts` aborted_tools). All three reach this via dynamic import gated
|
||||
* on `feature('CHICAGO_MCP')`. `executor.js` (which pulls both native
|
||||
* modules) is dynamic-imported below so non-CU turns don't load native
|
||||
* modules just to no-op.
|
||||
*
|
||||
* No-ops cheaply on non-CU turns: both gate checks are zero-syscall.
|
||||
*/
|
||||
export async function cleanupComputerUseAfterTurn(
|
||||
ctx: Pick<
|
||||
ToolUseContext,
|
||||
'getAppState' | 'setAppState' | 'sendOSNotification'
|
||||
>,
|
||||
): Promise<void> {
|
||||
const appState = ctx.getAppState()
|
||||
|
||||
const hidden = appState.computerUseMcpState?.hiddenDuringTurn
|
||||
if (hidden && hidden.size > 0) {
|
||||
const { unhideComputerUseApps } = await import('./executor.js')
|
||||
const unhide = unhideComputerUseApps([...hidden]).catch(err =>
|
||||
logForDebugging(
|
||||
`[Computer Use MCP] auto-unhide failed: ${errorMessage(err)}`,
|
||||
),
|
||||
)
|
||||
const timeout = withResolvers<void>()
|
||||
const timer = setTimeout(timeout.resolve, UNHIDE_TIMEOUT_MS)
|
||||
await Promise.race([unhide, timeout.promise]).finally(() =>
|
||||
clearTimeout(timer),
|
||||
)
|
||||
ctx.setAppState(prev =>
|
||||
prev.computerUseMcpState?.hiddenDuringTurn === undefined
|
||||
? prev
|
||||
: {
|
||||
...prev,
|
||||
computerUseMcpState: {
|
||||
...prev.computerUseMcpState,
|
||||
hiddenDuringTurn: undefined,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Zero-syscall pre-check so non-CU turns don't touch disk. Release is still
|
||||
// idempotent (returns false if already released or owned by another session).
|
||||
if (!isLockHeldLocally()) return
|
||||
|
||||
// Unregister before lock release so the pump-retain drops as soon as the
|
||||
// CU session ends. Idempotent — no-ops if registration failed at acquire.
|
||||
// Swallow throws so a NAPI unregister error never prevents lock release —
|
||||
// a held lock blocks the next CU session with "in use by another session".
|
||||
try {
|
||||
unregisterEscHotkey()
|
||||
} catch (err) {
|
||||
logForDebugging(
|
||||
`[Computer Use MCP] unregisterEscHotkey failed: ${errorMessage(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (await releaseComputerUseLock()) {
|
||||
ctx.sendOSNotification?.({
|
||||
message: 'Claude is done using your computer',
|
||||
notificationType: 'computer_use_exit',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { normalizeNameForMCP } from '../../services/mcp/normalization.js'
|
||||
import { env } from '../env.js'
|
||||
|
||||
export const COMPUTER_USE_MCP_SERVER_NAME = 'computer-use'
|
||||
|
||||
/**
|
||||
* Sentinel bundle ID for the frontmost gate. Claude Code is a terminal — it has
|
||||
* no window. This never matches a real `NSWorkspace.frontmostApplication`, so
|
||||
* the package's "host is frontmost" branch (mouse click-through exemption,
|
||||
* keyboard safety-net) is dead code for us. `prepareForAction`'s "exempt our
|
||||
* own window" is likewise a no-op — there is no window to exempt.
|
||||
*/
|
||||
export const CLI_HOST_BUNDLE_ID = 'com.anthropic.claude-code.cli-no-window'
|
||||
|
||||
/**
|
||||
* Fallback `env.terminal` → bundleId map for when `__CFBundleIdentifier` is
|
||||
* unset. Covers the macOS terminals we can distinguish — Linux entries
|
||||
* (konsole, gnome-terminal, xterm) are deliberately absent since
|
||||
* `createCliExecutor` is darwin-guarded.
|
||||
*/
|
||||
const TERMINAL_BUNDLE_ID_FALLBACK: Readonly<Record<string, string>> = {
|
||||
'iTerm.app': 'com.googlecode.iterm2',
|
||||
Apple_Terminal: 'com.apple.Terminal',
|
||||
ghostty: 'com.mitchellh.ghostty',
|
||||
kitty: 'net.kovidgoyal.kitty',
|
||||
WarpTerminal: 'dev.warp.Warp-Stable',
|
||||
vscode: 'com.microsoft.VSCode',
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle ID of the terminal emulator we're running inside, so `prepareDisplay`
|
||||
* can exempt it from hiding and `captureExcluding` can keep it out of
|
||||
* screenshots. Returns null when undetectable (ssh, cleared env, unknown
|
||||
* terminal) — caller must handle the null case.
|
||||
*
|
||||
* `__CFBundleIdentifier` is set by LaunchServices when a .app bundle spawns a
|
||||
* process and is inherited by children. It's the exact bundleId, no lookup
|
||||
* needed — handles terminals the fallback table doesn't know about. Under
|
||||
* tmux/screen it reflects the terminal that started the SERVER, which may
|
||||
* differ from the attached client. That's harmless here: we exempt A
|
||||
* terminal window, and the screenshots exclude it regardless.
|
||||
*/
|
||||
export function getTerminalBundleId(): string | null {
|
||||
const cfBundleId = process.env.__CFBundleIdentifier
|
||||
if (cfBundleId) return cfBundleId
|
||||
return TERMINAL_BUNDLE_ID_FALLBACK[env.terminal ?? ''] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Static capabilities for macOS CLI. `hostBundleId` is not here — it's added
|
||||
* by `executor.ts` per `ComputerExecutor.capabilities`. `buildComputerUseTools`
|
||||
* takes this shape (no `hostBundleId`, no `teachMode`).
|
||||
*/
|
||||
export const CLI_CU_CAPABILITIES = {
|
||||
screenshotFiltering: 'native' as const,
|
||||
platform: 'darwin' as const,
|
||||
}
|
||||
|
||||
export function isComputerUseMCPServer(name: string): boolean {
|
||||
return normalizeNameForMCP(name) === COMPUTER_USE_MCP_SERVER_NAME
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { mkdir, readFile, unlink, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { getSessionId } from '../../bootstrap/state.js'
|
||||
import { registerCleanup } from '../../utils/cleanupRegistry.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||
import { jsonParse, jsonStringify } from '../../utils/slowOperations.js'
|
||||
import { getErrnoCode } from '../errors.js'
|
||||
|
||||
const LOCK_FILENAME = 'computer-use.lock'
|
||||
|
||||
// Holds the unregister function for the shutdown cleanup handler.
|
||||
// Set when the lock is acquired, cleared when released.
|
||||
let unregisterCleanup: (() => void) | undefined
|
||||
|
||||
type ComputerUseLock = {
|
||||
readonly sessionId: string
|
||||
readonly pid: number
|
||||
readonly acquiredAt: number
|
||||
}
|
||||
|
||||
export type AcquireResult =
|
||||
| { readonly kind: 'acquired'; readonly fresh: boolean }
|
||||
| { readonly kind: 'blocked'; readonly by: string }
|
||||
|
||||
export type CheckResult =
|
||||
| { readonly kind: 'free' }
|
||||
| { readonly kind: 'held_by_self' }
|
||||
| { readonly kind: 'blocked'; readonly by: string }
|
||||
|
||||
const FRESH: AcquireResult = { kind: 'acquired', fresh: true }
|
||||
const REENTRANT: AcquireResult = { kind: 'acquired', fresh: false }
|
||||
|
||||
function isComputerUseLock(value: unknown): value is ComputerUseLock {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
return (
|
||||
'sessionId' in value &&
|
||||
typeof value.sessionId === 'string' &&
|
||||
'pid' in value &&
|
||||
typeof value.pid === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function getLockPath(): string {
|
||||
return join(getClaudeConfigHomeDir(), LOCK_FILENAME)
|
||||
}
|
||||
|
||||
async function readLock(): Promise<ComputerUseLock | undefined> {
|
||||
try {
|
||||
const raw = await readFile(getLockPath(), 'utf8')
|
||||
const parsed: unknown = jsonParse(raw)
|
||||
return isComputerUseLock(parsed) ? parsed : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a process is still running (signal 0 probe).
|
||||
*
|
||||
* Note: there is a small window for PID reuse — if the owning process
|
||||
* exits and an unrelated process is assigned the same PID, the check
|
||||
* will return true. This is extremely unlikely in practice.
|
||||
*/
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to create the lock file atomically with O_EXCL.
|
||||
* Returns true on success, false if the file already exists.
|
||||
* Throws for other errors.
|
||||
*/
|
||||
async function tryCreateExclusive(lock: ComputerUseLock): Promise<boolean> {
|
||||
try {
|
||||
await writeFile(getLockPath(), jsonStringify(lock), { flag: 'wx' })
|
||||
return true
|
||||
} catch (e: unknown) {
|
||||
if (getErrnoCode(e) === 'EEXIST') return false
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a shutdown cleanup handler so the lock is released even if
|
||||
* turn-end cleanup is never reached (e.g. the user runs /exit while
|
||||
* a tool call is in progress).
|
||||
*/
|
||||
function registerLockCleanup(): void {
|
||||
unregisterCleanup?.()
|
||||
unregisterCleanup = registerCleanup(async () => {
|
||||
await releaseComputerUseLock()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check lock state without acquiring. Used for `request_access` /
|
||||
* `list_granted_applications` — the package's `defersLockAcquire` contract:
|
||||
* these tools check but don't take the lock, so the enter-notification and
|
||||
* overlay don't fire while the model is only asking for permission.
|
||||
*
|
||||
* Does stale-PID recovery (unlinks) so a dead session's lock doesn't block
|
||||
* `request_access`. Does NOT create — that's `tryAcquireComputerUseLock`'s job.
|
||||
*/
|
||||
export async function checkComputerUseLock(): Promise<CheckResult> {
|
||||
const existing = await readLock()
|
||||
if (!existing) return { kind: 'free' }
|
||||
if (existing.sessionId === getSessionId()) return { kind: 'held_by_self' }
|
||||
if (isProcessRunning(existing.pid)) {
|
||||
return { kind: 'blocked', by: existing.sessionId }
|
||||
}
|
||||
logForDebugging(
|
||||
`Recovering stale computer-use lock from session ${existing.sessionId} (PID ${existing.pid})`,
|
||||
)
|
||||
await unlink(getLockPath()).catch(() => {})
|
||||
return { kind: 'free' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-syscall check: does THIS process believe it holds the lock?
|
||||
* True iff `tryAcquireComputerUseLock` succeeded and `releaseComputerUseLock`
|
||||
* hasn't run yet. Used to gate the per-turn release in `cleanup.ts` so
|
||||
* non-CU turns don't touch disk.
|
||||
*/
|
||||
export function isLockHeldLocally(): boolean {
|
||||
return unregisterCleanup !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to acquire the computer-use lock for the current session.
|
||||
*
|
||||
* `{kind: 'acquired', fresh: true}` — first tool call of a CU turn. Callers fire
|
||||
* enter notifications on this. `{kind: 'acquired', fresh: false}` — re-entrant,
|
||||
* same session already holds it. `{kind: 'blocked', by}` — another live session
|
||||
* holds it.
|
||||
*
|
||||
* Uses O_EXCL (open 'wx') for atomic test-and-set — the OS guarantees at
|
||||
* most one process sees the create succeed. If the file already exists,
|
||||
* we check ownership and PID liveness; for a stale lock we unlink and
|
||||
* retry the exclusive create once. If two sessions race to recover the
|
||||
* same stale lock, only one create succeeds (the other reads the winner).
|
||||
*/
|
||||
export async function tryAcquireComputerUseLock(): Promise<AcquireResult> {
|
||||
const sessionId = getSessionId()
|
||||
const lock: ComputerUseLock = {
|
||||
sessionId,
|
||||
pid: process.pid,
|
||||
acquiredAt: Date.now(),
|
||||
}
|
||||
|
||||
await mkdir(getClaudeConfigHomeDir(), { recursive: true })
|
||||
|
||||
// Fresh acquisition.
|
||||
if (await tryCreateExclusive(lock)) {
|
||||
registerLockCleanup()
|
||||
return FRESH
|
||||
}
|
||||
|
||||
const existing = await readLock()
|
||||
|
||||
// Corrupt/unparseable — treat as stale (can't extract a blocking ID).
|
||||
if (!existing) {
|
||||
await unlink(getLockPath()).catch(() => {})
|
||||
if (await tryCreateExclusive(lock)) {
|
||||
registerLockCleanup()
|
||||
return FRESH
|
||||
}
|
||||
return { kind: 'blocked', by: (await readLock())?.sessionId ?? 'unknown' }
|
||||
}
|
||||
|
||||
// Already held by this session.
|
||||
if (existing.sessionId === sessionId) return REENTRANT
|
||||
|
||||
// Another live session holds it — blocked.
|
||||
if (isProcessRunning(existing.pid)) {
|
||||
return { kind: 'blocked', by: existing.sessionId }
|
||||
}
|
||||
|
||||
// Stale lock — recover. Unlink then retry the exclusive create.
|
||||
// If another session is also recovering, one EEXISTs and reads the winner.
|
||||
logForDebugging(
|
||||
`Recovering stale computer-use lock from session ${existing.sessionId} (PID ${existing.pid})`,
|
||||
)
|
||||
await unlink(getLockPath()).catch(() => {})
|
||||
if (await tryCreateExclusive(lock)) {
|
||||
registerLockCleanup()
|
||||
return FRESH
|
||||
}
|
||||
return { kind: 'blocked', by: (await readLock())?.sessionId ?? 'unknown' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the computer-use lock if the current session owns it. Returns
|
||||
* `true` if we actually unlinked the file (i.e., we held it) — callers fire
|
||||
* exit notifications on this. Idempotent: subsequent calls return `false`.
|
||||
*/
|
||||
export async function releaseComputerUseLock(): Promise<boolean> {
|
||||
unregisterCleanup?.()
|
||||
unregisterCleanup = undefined
|
||||
|
||||
const existing = await readLock()
|
||||
if (!existing || existing.sessionId !== getSessionId()) return false
|
||||
try {
|
||||
await unlink(getLockPath())
|
||||
logForDebugging('Released computer-use lock')
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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<typeof setInterval> | undefined
|
||||
let pending = 0
|
||||
|
||||
function drainTick(cu: ReturnType<typeof requireComputerUseSwift>): 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.
|
||||
*/
|
||||
export async function drainRunLoop<T>(fn: () => Promise<T>): Promise<T> {
|
||||
retain()
|
||||
let timer: ReturnType<typeof setTimeout> | 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<never>()
|
||||
timer = setTimeout(timeoutReject, TIMEOUT_MS, timeout.reject)
|
||||
return await Promise.race([work, timeout.promise])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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.
|
||||
*/
|
||||
|
||||
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 unregisterEscHotkey(): void {
|
||||
if (!registered) return
|
||||
try {
|
||||
requireComputerUseSwift().hotkey.unregister()
|
||||
} finally {
|
||||
releasePump()
|
||||
registered = false
|
||||
logForDebugging('[cu-esc] unregistered')
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyExpectedEscape(): void {
|
||||
if (!registered) return
|
||||
requireComputerUseSwift().hotkey.notifyExpectedEscape()
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ComputerExecutor,
|
||||
DisplayGeometry,
|
||||
FrontmostApp,
|
||||
InstalledApp,
|
||||
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'
|
||||
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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const SCREENSHOT_JPEG_QUALITY = 0.75
|
||||
|
||||
/** Logical → physical → API target dims. See `targetImageSize` + COORDINATES.md. */
|
||||
function computeTargetDims(
|
||||
logicalW: number,
|
||||
logicalH: number,
|
||||
scaleFactor: number,
|
||||
): [number, number] {
|
||||
const physW = Math.round(logicalW * scaleFactor)
|
||||
const physH = Math.round(logicalH * scaleFactor)
|
||||
return targetImageSize(physW, physH, API_RESIZE_PARAMS)
|
||||
}
|
||||
|
||||
async function readClipboardViaPbpaste(): Promise<string> {
|
||||
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<void> {
|
||||
const { code } = await execFileNoThrow('pbcopy', [], {
|
||||
input: text,
|
||||
useCwd: false,
|
||||
})
|
||||
if (code !== 0) {
|
||||
throw new Error(`pbcopy exited with code ${code}`)
|
||||
}
|
||||
}
|
||||
|
||||
type Input = ReturnType<typeof requireComputerUseInput>
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<void> {
|
||||
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<T>(
|
||||
input: Input,
|
||||
mods: string[],
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
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<void> {
|
||||
let saved: string | undefined
|
||||
try {
|
||||
saved = await readClipboardViaPbpaste()
|
||||
} catch {
|
||||
logForDebugging(
|
||||
'[computer-use] pbpaste before paste failed; proceeding without restore',
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await writeClipboardViaPbcopy(text)
|
||||
if ((await readClipboardViaPbpaste()) !== text) {
|
||||
throw new Error('Clipboard write did not round-trip.')
|
||||
}
|
||||
await input.keys(['command', 'v'])
|
||||
await sleep(100)
|
||||
} finally {
|
||||
if (typeof saved === 'string') {
|
||||
try {
|
||||
await writeClipboardViaPbcopy(saved)
|
||||
} catch {
|
||||
logForDebugging('[computer-use] clipboard restore after paste failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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: {
|
||||
getMouseAnimationEnabled: () => boolean
|
||||
getHideBeforeActionEnabled: () => boolean
|
||||
}): ComputerExecutor {
|
||||
if (process.platform !== 'darwin') {
|
||||
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<string[]> {
|
||||
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 previewHideSet(
|
||||
allowlistBundleIds: string[],
|
||||
displayId?: number,
|
||||
): Promise<Array<{ bundleId: string; displayName: string }>> {
|
||||
return cu.apps.previewHideSet(
|
||||
[...allowlistBundleIds, surrogateHost],
|
||||
displayId,
|
||||
)
|
||||
},
|
||||
|
||||
// ── Display ──────────────────────────────────────────────────────────
|
||||
|
||||
async getDisplaySize(displayId?: number): Promise<DisplayGeometry> {
|
||||
return cu.display.getSize(displayId)
|
||||
},
|
||||
|
||||
async listDisplays(): Promise<DisplayGeometry[]> {
|
||||
return cu.display.listAll()
|
||||
},
|
||||
|
||||
async findWindowDisplays(
|
||||
bundleIds: string[],
|
||||
): Promise<Array<{ bundleId: string; displayIds: number[] }>> {
|
||||
return cu.apps.findWindowDisplays(bundleIds)
|
||||
},
|
||||
|
||||
async resolvePrepareCapture(opts: {
|
||||
allowedBundleIds: string[]
|
||||
preferredDisplayId?: number
|
||||
autoResolve: boolean
|
||||
doHide?: boolean
|
||||
}): Promise<ResolvePrepareCaptureResult> {
|
||||
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,
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* 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<ScreenshotResult> {
|
||||
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 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<void> {
|
||||
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 holdKey(keyNames: string[], durationMs: number): Promise<void> {
|
||||
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 type(text: string, opts: { viaClipboard: boolean }): Promise<void> {
|
||||
const input = requireComputerUseInput()
|
||||
if (opts.viaClipboard) {
|
||||
// keys(['command','v']) inside needs the pump.
|
||||
await drainRunLoop(() => typeViaClipboard(input, 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)
|
||||
},
|
||||
|
||||
readClipboard: readClipboardViaPbpaste,
|
||||
|
||||
writeClipboard: writeClipboardViaPbcopy,
|
||||
|
||||
// ── Mouse ────────────────────────────────────────────────────────────
|
||||
|
||||
async moveMouse(x: number, y: number): Promise<void> {
|
||||
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<void> {
|
||||
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 mouseDown(): Promise<void> {
|
||||
await requireComputerUseInput().mouseButton('left', 'press')
|
||||
},
|
||||
|
||||
async mouseUp(): Promise<void> {
|
||||
await requireComputerUseInput().mouseButton('left', 'release')
|
||||
},
|
||||
|
||||
async getCursorPosition(): Promise<{ x: number; y: number }> {
|
||||
return requireComputerUseInput().mouseLocation()
|
||||
},
|
||||
|
||||
/**
|
||||
* `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<void> {
|
||||
const input = requireComputerUseInput()
|
||||
if (from !== undefined) {
|
||||
await moveAndSettle(input, from.x, from.y)
|
||||
}
|
||||
await input.mouseButton('left', 'press')
|
||||
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<void> {
|
||||
const input = requireComputerUseInput()
|
||||
await moveAndSettle(input, x, y)
|
||||
if (dy !== 0) {
|
||||
await input.mouseScroll(dy, 'vertical')
|
||||
}
|
||||
if (dx !== 0) {
|
||||
await input.mouseScroll(dx, 'horizontal')
|
||||
}
|
||||
},
|
||||
|
||||
// ── App management ───────────────────────────────────────────────────
|
||||
|
||||
async getFrontmostApp(): Promise<FrontmostApp | null> {
|
||||
const info = requireComputerUseInput().getFrontmostAppInfo()
|
||||
if (!info || !info.bundleId) return null
|
||||
return { bundleId: info.bundleId, displayName: info.appName }
|
||||
},
|
||||
|
||||
async appUnderPoint(
|
||||
x: number,
|
||||
y: number,
|
||||
): Promise<{ bundleId: string; displayName: string } | null> {
|
||||
return cu.apps.appUnderPoint(x, y)
|
||||
},
|
||||
|
||||
async listInstalledApps(): Promise<InstalledApp[]> {
|
||||
// `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<string | undefined> {
|
||||
return cu.apps.iconDataUrl(path) ?? undefined
|
||||
},
|
||||
|
||||
async listRunningApps(): Promise<RunningApp[]> {
|
||||
return cu.apps.listRunning()
|
||||
},
|
||||
|
||||
async openApp(bundleId: string): Promise<void> {
|
||||
await cu.apps.open(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<void> {
|
||||
if (bundleIds.length === 0) return
|
||||
const cu = requireComputerUseSwift()
|
||||
await cu.apps.unhide([...bundleIds])
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { CoordinateMode, CuSubGates } from '@ant/computer-use-mcp/types'
|
||||
|
||||
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
|
||||
coordinateMode: CoordinateMode
|
||||
}
|
||||
|
||||
const DEFAULTS: ChicagoConfig = {
|
||||
enabled: false,
|
||||
pixelValidation: false,
|
||||
clipboardPasteMultiline: true,
|
||||
mouseAnimation: true,
|
||||
hideBeforeAction: true,
|
||||
autoTargetDisplay: true,
|
||||
clipboardGuard: true,
|
||||
coordinateMode: 'pixels',
|
||||
}
|
||||
|
||||
// Spread over defaults so a partial JSON ({"enabled": true} alone) inherits the
|
||||
// rest. The generic on getDynamicConfig is a type assertion, not a validator —
|
||||
// GB returning a partial object would otherwise surface undefined fields.
|
||||
function readConfig(): ChicagoConfig {
|
||||
return {
|
||||
...DEFAULTS,
|
||||
...getDynamicConfig_CACHED_MAY_BE_STALE<Partial<ChicagoConfig>>(
|
||||
'tengu_malort_pedway',
|
||||
DEFAULTS,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
export function getChicagoSubGates(): CuSubGates {
|
||||
const { enabled: _e, coordinateMode: _c, ...subGates } = readConfig()
|
||||
return subGates
|
||||
}
|
||||
|
||||
// Frozen at first read — setup.ts builds tool descriptions and executor.ts
|
||||
// scales coordinates off the same value. A live read here lets a mid-session
|
||||
// GB flip tell the model "pixels" while transforming clicks as normalized.
|
||||
let frozenCoordinateMode: CoordinateMode | undefined
|
||||
export function getChicagoCoordinateMode(): CoordinateMode {
|
||||
frozenCoordinateMode ??= readConfig().coordinateMode
|
||||
return frozenCoordinateMode
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
ComputerUseHostAdapter,
|
||||
Logger,
|
||||
} from '@ant/computer-use-mcp/types'
|
||||
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'
|
||||
|
||||
class DebugLogger implements Logger {
|
||||
silly(message: string, ...args: unknown[]): void {
|
||||
logForDebugging(format(message, ...args), { level: 'debug' })
|
||||
}
|
||||
debug(message: string, ...args: unknown[]): void {
|
||||
logForDebugging(format(message, ...args), { level: 'debug' })
|
||||
}
|
||||
info(message: string, ...args: unknown[]): void {
|
||||
logForDebugging(format(message, ...args), { level: 'info' })
|
||||
}
|
||||
warn(message: string, ...args: unknown[]): void {
|
||||
logForDebugging(format(message, ...args), { level: 'warn' })
|
||||
}
|
||||
error(message: string, ...args: unknown[]): void {
|
||||
logForDebugging(format(message, ...args), { level: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
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 = {
|
||||
serverName: COMPUTER_USE_MCP_SERVER_NAME,
|
||||
logger: new DebugLogger(),
|
||||
executor: createCliExecutor({
|
||||
getMouseAnimationEnabled: () => getChicagoSubGates().mouseAnimation,
|
||||
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 }
|
||||
},
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
buildComputerUseTools,
|
||||
createComputerUseMcpServer,
|
||||
} from '@ant/computer-use-mcp'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { shutdownDatadog } from '../../services/analytics/datadog.js'
|
||||
import { shutdown1PEventLogging } from '../../services/analytics/firstPartyEventLogger.js'
|
||||
import { initializeAnalyticsSink } from '../../services/analytics/sink.js'
|
||||
import { enableConfigs } from '../config.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { filterAppsForDescription } from './appNames.js'
|
||||
import { getChicagoCoordinateMode } from './gates.js'
|
||||
import { getComputerUseHostAdapter } from './hostAdapter.js'
|
||||
|
||||
const APP_ENUM_TIMEOUT_MS = 1000
|
||||
|
||||
/**
|
||||
* Enumerate installed apps, timed. Fails soft — if Spotlight is slow or
|
||||
* claude-swift throws, the tool description just omits the list. Resolution
|
||||
* happens at call time regardless; the model just doesn't get hints.
|
||||
*/
|
||||
async function tryGetInstalledAppNames(): Promise<string[] | undefined> {
|
||||
const adapter = getComputerUseHostAdapter()
|
||||
const enumP = adapter.executor.listInstalledApps()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timeoutP = new Promise<undefined>(resolve => {
|
||||
timer = setTimeout(resolve, APP_ENUM_TIMEOUT_MS, undefined)
|
||||
})
|
||||
const installed = await Promise.race([enumP, timeoutP])
|
||||
.catch(() => undefined)
|
||||
.finally(() => clearTimeout(timer))
|
||||
if (!installed) {
|
||||
// The enumeration continues in the background — swallow late rejections.
|
||||
void enumP.catch(() => {})
|
||||
logForDebugging(
|
||||
`[Computer Use MCP] app enumeration exceeded ${APP_ENUM_TIMEOUT_MS}ms or failed; tool description omits list`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
return filterAppsForDescription(installed, homedir())
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct the in-process server. Delegates to the package's
|
||||
* `createComputerUseMcpServer` for the Server object + stub CallTool handler,
|
||||
* then REPLACES the ListTools handler with one that includes installed-app
|
||||
* names in the `request_access` description (the package's factory doesn't
|
||||
* take `installedAppNames`, and Cowork builds its own tool array in
|
||||
* serverDef.ts for the same reason).
|
||||
*
|
||||
* Async so the 1s app-enumeration timeout doesn't block startup — called from
|
||||
* an `await import()` in `client.ts` on first CU connection, not `main.tsx`.
|
||||
*
|
||||
* Real dispatch still goes through `wrapper.tsx`'s `.call()` override; this
|
||||
* server exists only to answer ListTools.
|
||||
*/
|
||||
export async function createComputerUseMcpServerForCli(): Promise<
|
||||
ReturnType<typeof createComputerUseMcpServer>
|
||||
> {
|
||||
const adapter = getComputerUseHostAdapter()
|
||||
const coordinateMode = getChicagoCoordinateMode()
|
||||
const server = createComputerUseMcpServer(adapter, coordinateMode)
|
||||
|
||||
const installedAppNames = await tryGetInstalledAppNames()
|
||||
const tools = buildComputerUseTools(
|
||||
adapter.executor.capabilities,
|
||||
coordinateMode,
|
||||
installedAppNames,
|
||||
)
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () =>
|
||||
adapter.isDisabled() ? { tools: [] } : { tools },
|
||||
)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
/**
|
||||
* Subprocess entrypoint for `--computer-use-mcp`. Mirror of
|
||||
* `runClaudeInChromeMcpServer` — stdio transport, exit on stdin close,
|
||||
* flush analytics before exit.
|
||||
*/
|
||||
export async function runComputerUseMcpServer(): Promise<void> {
|
||||
enableConfigs()
|
||||
initializeAnalyticsSink()
|
||||
|
||||
const server = await createComputerUseMcpServerForCli()
|
||||
const transport = new StdioServerTransport()
|
||||
|
||||
let exiting = false
|
||||
const shutdownAndExit = async (): Promise<void> => {
|
||||
if (exiting) return
|
||||
exiting = true
|
||||
await Promise.all([shutdown1PEventLogging(), shutdownDatadog()])
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(0)
|
||||
}
|
||||
process.stdin.on('end', () => void shutdownAndExit())
|
||||
process.stdin.on('error', () => void shutdownAndExit())
|
||||
|
||||
logForDebugging('[Computer Use MCP] Starting MCP server')
|
||||
await server.connect(transport)
|
||||
logForDebugging('[Computer Use MCP] MCP server started')
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { buildComputerUseTools } from '@ant/computer-use-mcp'
|
||||
import { join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { buildMcpToolName } from '../../services/mcp/mcpStringUtils.js'
|
||||
import type { ScopedMcpServerConfig } from '../../services/mcp/types.js'
|
||||
|
||||
import { isInBundledMode } from '../bundledMode.js'
|
||||
import { CLI_CU_CAPABILITIES, COMPUTER_USE_MCP_SERVER_NAME } from './common.js'
|
||||
import { getChicagoCoordinateMode } from './gates.js'
|
||||
|
||||
/**
|
||||
* Build the dynamic MCP config + allowed tool names. Mirror of
|
||||
* `setupClaudeInChrome`. The `mcp__computer-use__*` tools are added to
|
||||
* `allowedTools` so they bypass the normal permission prompt — the package's
|
||||
* `request_access` handles approval for the whole session.
|
||||
*
|
||||
* The MCP layer isn't ceremony: the API backend detects `mcp__computer-use__*`
|
||||
* tool names and emits a CU availability hint into the system prompt
|
||||
* (COMPUTER_USE_MCP_AVAILABILITY_HINT in the anthropic repo). Built-in tools
|
||||
* with different names wouldn't trigger it. Cowork uses the same names for the
|
||||
* same reason (apps/desktop/src/main/local-agent-mode/systemPrompt.ts:314).
|
||||
*/
|
||||
export function setupComputerUseMCP(): {
|
||||
mcpConfig: Record<string, ScopedMcpServerConfig>
|
||||
allowedTools: string[]
|
||||
} {
|
||||
const allowedTools = buildComputerUseTools(
|
||||
CLI_CU_CAPABILITIES,
|
||||
getChicagoCoordinateMode(),
|
||||
).map(t => buildMcpToolName(COMPUTER_USE_MCP_SERVER_NAME, t.name))
|
||||
|
||||
// command/args are never spawned — client.ts intercepts by name and
|
||||
// uses the in-process server. The config just needs to exist with
|
||||
// type 'stdio' to hit the right branch. Mirrors Chrome's setup.
|
||||
const args = isInBundledMode()
|
||||
? ['--computer-use-mcp']
|
||||
: [
|
||||
join(fileURLToPath(import.meta.url), '..', 'cli.js'),
|
||||
'--computer-use-mcp',
|
||||
]
|
||||
|
||||
return {
|
||||
mcpConfig: {
|
||||
[COMPUTER_USE_MCP_SERVER_NAME]: {
|
||||
type: 'stdio',
|
||||
command: process.execPath,
|
||||
args,
|
||||
scope: 'dynamic',
|
||||
} as const,
|
||||
},
|
||||
allowedTools,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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 type { ComputerUseAPI }
|
||||
@@ -0,0 +1,125 @@
|
||||
import * as React from 'react';
|
||||
import { MessageResponse } from '../../components/MessageResponse.js';
|
||||
import { Text } from '../../ink.js';
|
||||
import { truncateToWidth } from '../format.js';
|
||||
import type { MCPToolResult } from '../mcpValidation.js';
|
||||
type CuToolInput = Record<string, unknown> & {
|
||||
coordinate?: [number, number];
|
||||
start_coordinate?: [number, number];
|
||||
text?: string;
|
||||
apps?: Array<{
|
||||
displayName?: string;
|
||||
}>;
|
||||
region?: [number, number, number, number];
|
||||
direction?: string;
|
||||
amount?: number;
|
||||
duration?: number;
|
||||
};
|
||||
function fmtCoord(c: [number, number] | undefined): string {
|
||||
return c ? `(${c[0]}, ${c[1]})` : '';
|
||||
}
|
||||
const RESULT_SUMMARY: Readonly<Partial<Record<string, string>>> = {
|
||||
screenshot: 'Captured',
|
||||
zoom: 'Captured',
|
||||
request_access: 'Access updated',
|
||||
left_click: 'Clicked',
|
||||
right_click: 'Clicked',
|
||||
middle_click: 'Clicked',
|
||||
double_click: 'Clicked',
|
||||
triple_click: 'Clicked',
|
||||
type: 'Typed',
|
||||
key: 'Pressed',
|
||||
hold_key: 'Pressed',
|
||||
scroll: 'Scrolled',
|
||||
left_click_drag: 'Dragged',
|
||||
open_application: 'Opened'
|
||||
};
|
||||
|
||||
/**
|
||||
* Rendering overrides for `mcp__computer-use__*` tools. Spread into the MCP
|
||||
* tool object in `client.ts` after the default `userFacingName`, so these win.
|
||||
* Mirror of `getClaudeInChromeMCPToolOverrides`.
|
||||
*/
|
||||
export function getComputerUseMCPRenderingOverrides(toolName: string): {
|
||||
userFacingName: () => string;
|
||||
renderToolUseMessage: (input: Record<string, unknown>, options: {
|
||||
verbose: boolean;
|
||||
}) => React.ReactNode;
|
||||
renderToolResultMessage: (output: MCPToolResult, progressMessages: unknown[], options: {
|
||||
verbose: boolean;
|
||||
}) => React.ReactNode;
|
||||
} {
|
||||
return {
|
||||
userFacingName() {
|
||||
return `Computer Use[${toolName}]`;
|
||||
},
|
||||
// AssistantToolUseMessage.tsx contract: null hides the ENTIRE row, '' shows
|
||||
// the tool name without "(args)". Every path below returns '' when there's
|
||||
// nothing to show — never null.
|
||||
renderToolUseMessage(input: CuToolInput) {
|
||||
switch (toolName) {
|
||||
case 'screenshot':
|
||||
case 'left_mouse_down':
|
||||
case 'left_mouse_up':
|
||||
case 'cursor_position':
|
||||
case 'list_granted_applications':
|
||||
case 'read_clipboard':
|
||||
return '';
|
||||
case 'left_click':
|
||||
case 'right_click':
|
||||
case 'middle_click':
|
||||
case 'double_click':
|
||||
case 'triple_click':
|
||||
case 'mouse_move':
|
||||
return fmtCoord(input.coordinate);
|
||||
case 'left_click_drag':
|
||||
return input.start_coordinate ? `${fmtCoord(input.start_coordinate)} → ${fmtCoord(input.coordinate)}` : `to ${fmtCoord(input.coordinate)}`;
|
||||
case 'type':
|
||||
return typeof input.text === 'string' ? `"${truncateToWidth(input.text, 40)}"` : '';
|
||||
case 'key':
|
||||
case 'hold_key':
|
||||
return typeof input.text === 'string' ? input.text : '';
|
||||
case 'scroll':
|
||||
return [input.direction, input.amount && `×${input.amount}`, input.coordinate && `at ${fmtCoord(input.coordinate)}`].filter(Boolean).join(' ');
|
||||
case 'zoom':
|
||||
{
|
||||
const r = input.region;
|
||||
return Array.isArray(r) && r.length === 4 ? `[${r[0]}, ${r[1]}, ${r[2]}, ${r[3]}]` : '';
|
||||
}
|
||||
case 'wait':
|
||||
return typeof input.duration === 'number' ? `${input.duration}s` : '';
|
||||
case 'write_clipboard':
|
||||
return typeof input.text === 'string' ? `"${truncateToWidth(input.text, 40)}"` : '';
|
||||
case 'open_application':
|
||||
return typeof input.bundle_id === 'string' ? String(input.bundle_id) : '';
|
||||
case 'request_access':
|
||||
{
|
||||
const apps = input.apps;
|
||||
if (!Array.isArray(apps)) return '';
|
||||
const names = apps.map(a => typeof a?.displayName === 'string' ? a.displayName : '').filter(Boolean);
|
||||
return names.join(', ');
|
||||
}
|
||||
case 'computer_batch':
|
||||
{
|
||||
const actions = input.actions;
|
||||
return Array.isArray(actions) ? `${actions.length} actions` : '';
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
},
|
||||
renderToolResultMessage(output, _progress, {
|
||||
verbose
|
||||
}) {
|
||||
if (verbose || typeof output !== 'object' || output === null) return null;
|
||||
|
||||
// Non-verbose: one-line dim summary, like Chrome's pattern.
|
||||
const summary = RESULT_SUMMARY[toolName];
|
||||
if (!summary) return null;
|
||||
return <MessageResponse height={1}>
|
||||
<Text dimColor>{summary}</Text>
|
||||
</MessageResponse>;
|
||||
}
|
||||
};
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIk1lc3NhZ2VSZXNwb25zZSIsIlRleHQiLCJ0cnVuY2F0ZVRvV2lkdGgiLCJNQ1BUb29sUmVzdWx0IiwiQ3VUb29sSW5wdXQiLCJSZWNvcmQiLCJjb29yZGluYXRlIiwic3RhcnRfY29vcmRpbmF0ZSIsInRleHQiLCJhcHBzIiwiQXJyYXkiLCJkaXNwbGF5TmFtZSIsInJlZ2lvbiIsImRpcmVjdGlvbiIsImFtb3VudCIsImR1cmF0aW9uIiwiZm10Q29vcmQiLCJjIiwiUkVTVUxUX1NVTU1BUlkiLCJSZWFkb25seSIsIlBhcnRpYWwiLCJzY3JlZW5zaG90Iiwiem9vbSIsInJlcXVlc3RfYWNjZXNzIiwibGVmdF9jbGljayIsInJpZ2h0X2NsaWNrIiwibWlkZGxlX2NsaWNrIiwiZG91YmxlX2NsaWNrIiwidHJpcGxlX2NsaWNrIiwidHlwZSIsImtleSIsImhvbGRfa2V5Iiwic2Nyb2xsIiwibGVmdF9jbGlja19kcmFnIiwib3Blbl9hcHBsaWNhdGlvbiIsImdldENvbXB1dGVyVXNlTUNQUmVuZGVyaW5nT3ZlcnJpZGVzIiwidG9vbE5hbWUiLCJ1c2VyRmFjaW5nTmFtZSIsInJlbmRlclRvb2xVc2VNZXNzYWdlIiwiaW5wdXQiLCJvcHRpb25zIiwidmVyYm9zZSIsIlJlYWN0Tm9kZSIsInJlbmRlclRvb2xSZXN1bHRNZXNzYWdlIiwib3V0cHV0IiwicHJvZ3Jlc3NNZXNzYWdlcyIsImZpbHRlciIsIkJvb2xlYW4iLCJqb2luIiwiciIsImlzQXJyYXkiLCJsZW5ndGgiLCJidW5kbGVfaWQiLCJTdHJpbmciLCJuYW1lcyIsIm1hcCIsImEiLCJhY3Rpb25zIiwiX3Byb2dyZXNzIiwic3VtbWFyeSJdLCJzb3VyY2VzIjpbInRvb2xSZW5kZXJpbmcudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCAqIGFzIFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgTWVzc2FnZVJlc3BvbnNlIH0gZnJvbSAnLi4vLi4vY29tcG9uZW50cy9NZXNzYWdlUmVzcG9uc2UuanMnXG5pbXBvcnQgeyBUZXh0IH0gZnJvbSAnLi4vLi4vaW5rLmpzJ1xuaW1wb3J0IHsgdHJ1bmNhdGVUb1dpZHRoIH0gZnJvbSAnLi4vZm9ybWF0LmpzJ1xuaW1wb3J0IHR5cGUgeyBNQ1BUb29sUmVzdWx0IH0gZnJvbSAnLi4vbWNwVmFsaWRhdGlvbi5qcydcblxudHlwZSBDdVRvb2xJbnB1dCA9IFJlY29yZDxzdHJpbmcsIHVua25vd24+ICYge1xuICBjb29yZGluYXRlPzogW251bWJlciwgbnVtYmVyXVxuICBzdGFydF9jb29yZGluYXRlPzogW251bWJlciwgbnVtYmVyXVxuICB0ZXh0Pzogc3RyaW5nXG4gIGFwcHM/OiBBcnJheTx7IGRpc3BsYXlOYW1lPzogc3RyaW5nIH0+XG4gIHJlZ2lvbj86IFtudW1iZXIsIG51bWJlciwgbnVtYmVyLCBudW1iZXJdXG4gIGRpcmVjdGlvbj86IHN0cmluZ1xuICBhbW91bnQ/OiBudW1iZXJcbiAgZHVyYXRpb24/OiBudW1iZXJcbn1cblxuZnVuY3Rpb24gZm10Q29vcmQoYzogW251bWJlciwgbnVtYmVyXSB8IHVuZGVmaW5lZCk6IHN0cmluZyB7XG4gIHJldHVybiBjID8gYCgke2NbMF19LCAke2NbMV19KWAgOiAnJ1xufVxuXG5jb25zdCBSRVNVTFRfU1VNTUFSWTogUmVhZG9ubHk8UGFydGlhbDxSZWNvcmQ8c3RyaW5nLCBzdHJpbmc+Pj4gPSB7XG4gIHNjcmVlbnNob3Q6ICdDYXB0dXJlZCcsXG4gIHpvb206ICdDYXB0dXJlZCcsXG4gIHJlcXVlc3RfYWNjZXNzOiAnQWNjZXNzIHVwZGF0ZWQnLFxuICBsZWZ0X2NsaWNrOiAnQ2xpY2tlZCcsXG4gIHJpZ2h0X2NsaWNrOiAnQ2xpY2tlZCcsXG4gIG1pZGRsZV9jbGljazogJ0NsaWNrZWQnLFxuICBkb3VibGVfY2xpY2s6ICdDbGlja2VkJyxcbiAgdHJpcGxlX2NsaWNrOiAnQ2xpY2tlZCcsXG4gIHR5cGU6ICdUeXBlZCcsXG4gIGtleTogJ1ByZXNzZWQnLFxuICBob2xkX2tleTogJ1ByZXNzZWQnLFxuICBzY3JvbGw6ICdTY3JvbGxlZCcsXG4gIGxlZnRfY2xpY2tfZHJhZzogJ0RyYWdnZWQnLFxuICBvcGVuX2FwcGxpY2F0aW9uOiAnT3BlbmVkJyxcbn1cblxuLyoqXG4gKiBSZW5kZXJpbmcgb3ZlcnJpZGVzIGZvciBgbWNwX19jb21wdXRlci11c2VfXypgIHRvb2xzLiBTcHJlYWQgaW50byB0aGUgTUNQXG4gKiB0b29sIG9iamVjdCBpbiBgY2xpZW50LnRzYCBhZnRlciB0aGUgZGVmYXVsdCBgdXNlckZhY2luZ05hbWVgLCBzbyB0aGVzZSB3aW4uXG4gKiBNaXJyb3Igb2YgYGdldENsYXVkZUluQ2hyb21lTUNQVG9vbE92ZXJyaWRlc2AuXG4gKi9cbmV4cG9ydCBmdW5jdGlvbiBnZXRDb21wdXRlclVzZU1DUFJlbmRlcmluZ092ZXJyaWRlcyh0b29sTmFtZTogc3RyaW5nKToge1xuICB1c2VyRmFjaW5nTmFtZTogKCkgPT4gc3RyaW5nXG4gIHJlbmRlclRvb2xVc2VNZXNzYWdlOiAoXG4gICAgaW5wdXQ6IFJlY29yZDxzdHJpbmcsIHVua25vd24+LFxuICAgIG9wdGlvbnM6IHsgdmVyYm9zZTogYm9vbGVhbiB9LFxuICApID0+IFJlYWN0LlJlYWN0Tm9kZVxuICByZW5kZXJUb29sUmVzdWx0TWVzc2FnZTogKFxuICAgIG91dHB1dDogTUNQVG9vbFJlc3VsdCxcbiAgICBwcm9ncmVzc01lc3NhZ2VzOiB1bmtub3duW10sXG4gICAgb3B0aW9uczogeyB2ZXJib3NlOiBib29sZWFuIH0sXG4gICkgPT4gUmVhY3QuUmVhY3ROb2RlXG59IHtcbiAgcmV0dXJuIHtcbiAgICB1c2VyRmFjaW5nTmFtZSgpIHtcbiAgICAgIHJldHVybiBgQ29tcHV0ZXIgVXNlWyR7dG9vbE5hbWV9XWBcbiAgICB9LFxuXG4gICAgLy8gQXNzaXN0YW50VG9vbFVzZU1lc3NhZ2UudHN4IGNvbnRyYWN0OiBudWxsIGhpZGVzIHRoZSBFTlRJUkUgcm93LCAnJyBzaG93c1xuICAgIC8vIHRoZSB0b29sIG5hbWUgd2l0aG91dCBcIihhcmdzKVwiLiBFdmVyeSBwYXRoIGJlbG93IHJldHVybnMgJycgd2hlbiB0aGVyZSdzXG4gICAgLy8gbm90aGluZyB0byBzaG93IOKAlCBuZXZlciBudWxsLlxuICAgIHJlbmRlclRvb2xVc2VNZXNzYWdlKGlucHV0OiBDdVRvb2xJbnB1dCkge1xuICAgICAgc3dpdGNoICh0b29sTmFtZSkge1xuICAgICAgICBjYXNlICdzY3JlZW5zaG90JzpcbiAgICAgICAgY2FzZSAnbGVmdF9tb3VzZV9kb3duJzpcbiAgICAgICAgY2FzZSAnbGVmdF9tb3VzZV91cCc6XG4gICAgICAgIGNhc2UgJ2N1cnNvcl9wb3NpdGlvbic6XG4gICAgICAgIGNhc2UgJ2xpc3RfZ3JhbnRlZF9hcHBsaWNhdGlvbnMnOlxuICAgICAgICBjYXNlICdyZWFkX2NsaXBib2FyZCc6XG4gICAgICAgICAgcmV0dXJuICcnXG5cbiAgICAgICAgY2FzZSAnbGVmdF9jbGljayc6XG4gICAgICAgIGNhc2UgJ3JpZ2h0X2NsaWNrJzpcbiAgICAgICAgY2FzZSAnbWlkZGxlX2NsaWNrJzpcbiAgICAgICAgY2FzZSAnZG91YmxlX2NsaWNrJzpcbiAgICAgICAgY2FzZSAndHJpcGxlX2NsaWNrJzpcbiAgICAgICAgY2FzZSAnbW91c2VfbW92ZSc6XG4gICAgICAgICAgcmV0dXJuIGZtdENvb3JkKGlucHV0LmNvb3JkaW5hdGUpXG5cbiAgICAgICAgY2FzZSAnbGVmdF9jbGlja19kcmFnJzpcbiAgICAgICAgICByZXR1cm4gaW5wdXQuc3RhcnRfY29vcmRpbmF0ZVxuICAgICAgICAgICAgPyBgJHtmbXRDb29yZChpbnB1dC5zdGFydF9jb29yZGluYXRlKX0g4oaSICR7Zm10Q29vcmQoaW5wdXQuY29vcmRpbmF0ZSl9YFxuICAgICAgICAgICAgOiBgdG8gJHtmbXRDb29yZChpbnB1dC5jb29yZGluYXRlKX1gXG5cbiAgICAgICAgY2FzZSAndHlwZSc6XG4gICAgICAgICAgcmV0dXJuIHR5cGVvZiBpbnB1dC50ZXh0ID09PSAnc3RyaW5nJ1xuICAgICAgICAgICAgPyBgXCIke3RydW5jYXRlVG9XaWR0aChpbnB1dC50ZXh0LCA0MCl9XCJgXG4gICAgICAgICAgICA6ICcnXG5cbiAgICAgICAgY2FzZSAna2V5JzpcbiAgICA
Line truncated
|
||||
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* The `.call()` override — thin adapter between `ToolUseContext` and
|
||||
* `bindSessionContext`. Spread into the MCP tool object in `client.ts`
|
||||
* (same pattern as Chrome's rendering overrides, plus `.call()`).
|
||||
*
|
||||
* The wrapper-closure logic (build overrides fresh, lock gate, permission
|
||||
* merge, screenshot stash) lives in `@ant/computer-use-mcp`'s
|
||||
* `bindSessionContext`. This file binds it once per process,
|
||||
* caches the dispatcher, and updates a per-call ref for the pieces of
|
||||
* `ToolUseContext` that vary per-call (`abortController`, `setToolJSX`,
|
||||
* `sendOSNotification`). AppState accessors are read through the ref too —
|
||||
* they're likely stable but we don't depend on that.
|
||||
*
|
||||
* External callers reach this via the lazy require thunk in `client.ts`, gated
|
||||
* on `feature('CHICAGO_MCP')`. Runtime enablement is controlled by the
|
||||
* 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 * as React from 'react';
|
||||
import { getSessionId } from '../../bootstrap/state.js';
|
||||
import { ComputerUseApproval } from '../../components/permissions/ComputerUseApproval/ComputerUseApproval.js';
|
||||
import type { Tool, ToolUseContext } from '../../Tool.js';
|
||||
import { logForDebugging } from '../debug.js';
|
||||
import { checkComputerUseLock, tryAcquireComputerUseLock } from './computerUseLock.js';
|
||||
import { registerEscHotkey } from './escHotkey.js';
|
||||
import { getChicagoCoordinateMode } from './gates.js';
|
||||
import { getComputerUseHostAdapter } from './hostAdapter.js';
|
||||
import { getComputerUseMCPRenderingOverrides } from './toolRendering.js';
|
||||
type CallOverride = Pick<Tool, 'call'>['call'];
|
||||
type Binding = {
|
||||
ctx: ComputerUseSessionContext;
|
||||
dispatch: (name: string, args: unknown) => Promise<CuCallToolResult>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cached binding — built on first `.call()`, reused for process lifetime.
|
||||
* The dispatcher's closure-held screenshot blob persists across calls.
|
||||
*
|
||||
* `currentToolUseContext` is updated on every call. Every getter/callback in
|
||||
* `ctx` reads through it, so the per-call pieces (`abortController`,
|
||||
* `setToolJSX`, `sendOSNotification`) are always current.
|
||||
*
|
||||
* Module-level `let` is a deliberate exception to the no-module-scope-state
|
||||
* rule (src/CLAUDE.md): the dispatcher closure must persist across calls so
|
||||
* its internal screenshot blob survives, but `ToolUseContext` is per-call.
|
||||
* Tests will need to either inject the cache or run serially.
|
||||
*/
|
||||
let binding: Binding | undefined;
|
||||
let currentToolUseContext: ToolUseContext | undefined;
|
||||
function tuc(): ToolUseContext {
|
||||
// Safe: `binding` is only populated when `currentToolUseContext` is set.
|
||||
// Called only from within `ctx` callbacks, which only fire during dispatch.
|
||||
return currentToolUseContext!;
|
||||
}
|
||||
function formatLockHeld(holder: string): string {
|
||||
return `Computer use is in use by another Claude session (${holder.slice(0, 8)}…). Wait for that session to finish or run /exit there.`;
|
||||
}
|
||||
export function buildSessionContext(): ComputerUseSessionContext {
|
||||
return {
|
||||
// ── Read state fresh via the per-call ref ─────────────────────────────
|
||||
getAllowedApps: () => tuc().getAppState().computerUseMcpState?.allowedApps ?? [],
|
||||
getGrantFlags: () => tuc().getAppState().computerUseMcpState?.grantFlags ?? DEFAULT_GRANT_FLAGS,
|
||||
// cc-2 has no Settings page for user-denied apps yet.
|
||||
getUserDeniedBundleIds: () => [],
|
||||
getSelectedDisplayId: () => tuc().getAppState().computerUseMcpState?.selectedDisplayId,
|
||||
getDisplayPinnedByModel: () => tuc().getAppState().computerUseMcpState?.displayPinnedByModel ?? false,
|
||||
getDisplayResolvedForApps: () => tuc().getAppState().computerUseMcpState?.displayResolvedForApps,
|
||||
getLastScreenshotDims: (): ScreenshotDims | undefined => {
|
||||
const d = tuc().getAppState().computerUseMcpState?.lastScreenshotDims;
|
||||
return d ? {
|
||||
...d,
|
||||
displayId: d.displayId ?? 0,
|
||||
originX: d.originX ?? 0,
|
||||
originY: d.originY ?? 0
|
||||
} : undefined;
|
||||
},
|
||||
// ── Write-backs ────────────────────────────────────────────────────────
|
||||
// `setToolJSX` is guaranteed present — the gate in `main.tsx` excludes
|
||||
// non-interactive sessions. The package's `_dialogSignal` (tool-finished
|
||||
// dismissal) is irrelevant here: `setToolJSX` blocks the tool call, so
|
||||
// the dialog can't outlive it. Ctrl+C is what matters, and
|
||||
// `runPermissionDialog` wires that from the per-call ref's abortController.
|
||||
onPermissionRequest: (req, _dialogSignal) => runPermissionDialog(req),
|
||||
// Package does the merge (dedupe + truthy-only flags). We just persist.
|
||||
onAllowedAppsChanged: (apps, flags) => tuc().setAppState(prev => {
|
||||
const cu = prev.computerUseMcpState;
|
||||
const prevApps = cu?.allowedApps;
|
||||
const prevFlags = cu?.grantFlags;
|
||||
const sameApps = prevApps?.length === apps.length && apps.every((a, i) => prevApps[i]?.bundleId === a.bundleId);
|
||||
const sameFlags = prevFlags?.clipboardRead === flags.clipboardRead && prevFlags?.clipboardWrite === flags.clipboardWrite && prevFlags?.systemKeyCombos === flags.systemKeyCombos;
|
||||
return sameApps && sameFlags ? prev : {
|
||||
...prev,
|
||||
computerUseMcpState: {
|
||||
...cu,
|
||||
allowedApps: [...apps],
|
||||
grantFlags: flags
|
||||
}
|
||||
};
|
||||
}),
|
||||
onAppsHidden: ids => {
|
||||
if (ids.length === 0) return;
|
||||
tuc().setAppState(prev => {
|
||||
const cu = prev.computerUseMcpState;
|
||||
const existing = cu?.hiddenDuringTurn;
|
||||
if (existing && ids.every(id => existing.has(id))) return prev;
|
||||
return {
|
||||
...prev,
|
||||
computerUseMcpState: {
|
||||
...cu,
|
||||
hiddenDuringTurn: new Set([...(existing ?? []), ...ids])
|
||||
}
|
||||
};
|
||||
});
|
||||
},
|
||||
// Resolver writeback only fires under a pin when Swift fell back to main
|
||||
// (pinned display unplugged) — the pin is semantically dead, so clear it
|
||||
// and the app-set key so the chase chain runs next time. When autoResolve
|
||||
// was true, onDisplayResolvedForApps re-sets the key in the same tick.
|
||||
onResolvedDisplayUpdated: id => tuc().setAppState(prev => {
|
||||
const cu = prev.computerUseMcpState;
|
||||
if (cu?.selectedDisplayId === id && !cu.displayPinnedByModel && cu.displayResolvedForApps === undefined) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
computerUseMcpState: {
|
||||
...cu,
|
||||
selectedDisplayId: id,
|
||||
displayPinnedByModel: false,
|
||||
displayResolvedForApps: undefined
|
||||
}
|
||||
};
|
||||
}),
|
||||
// switch_display(name) pins; switch_display("auto") unpins and clears the
|
||||
// app-set key so the next screenshot auto-resolves fresh.
|
||||
onDisplayPinned: id => tuc().setAppState(prev => {
|
||||
const cu = prev.computerUseMcpState;
|
||||
const pinned = id !== undefined;
|
||||
const nextResolvedFor = pinned ? cu?.displayResolvedForApps : undefined;
|
||||
if (cu?.selectedDisplayId === id && cu?.displayPinnedByModel === pinned && cu?.displayResolvedForApps === nextResolvedFor) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
computerUseMcpState: {
|
||||
...cu,
|
||||
selectedDisplayId: id,
|
||||
displayPinnedByModel: pinned,
|
||||
displayResolvedForApps: nextResolvedFor
|
||||
}
|
||||
};
|
||||
}),
|
||||
onDisplayResolvedForApps: key => tuc().setAppState(prev => {
|
||||
const cu = prev.computerUseMcpState;
|
||||
if (cu?.displayResolvedForApps === key) return prev;
|
||||
return {
|
||||
...prev,
|
||||
computerUseMcpState: {
|
||||
...cu,
|
||||
displayResolvedForApps: key
|
||||
}
|
||||
};
|
||||
}),
|
||||
onScreenshotCaptured: dims => tuc().setAppState(prev => {
|
||||
const cu = prev.computerUseMcpState;
|
||||
const p = cu?.lastScreenshotDims;
|
||||
return p?.width === dims.width && p?.height === dims.height && p?.displayWidth === dims.displayWidth && p?.displayHeight === dims.displayHeight && p?.displayId === dims.displayId && p?.originX === dims.originX && p?.originY === dims.originY ? prev : {
|
||||
...prev,
|
||||
computerUseMcpState: {
|
||||
...cu,
|
||||
lastScreenshotDims: dims
|
||||
}
|
||||
};
|
||||
}),
|
||||
// ── Lock — async, direct file-lock calls ───────────────────────────────
|
||||
// No `lockHolderForGate` dance: the package's gate is async now. It
|
||||
// awaits `checkCuLock`, and on `holder: undefined` + non-deferring tool
|
||||
// awaits `acquireCuLock`. `defersLockAcquire` is the PACKAGE's set —
|
||||
// the local copy is gone.
|
||||
checkCuLock: async () => {
|
||||
const c = await checkComputerUseLock();
|
||||
switch (c.kind) {
|
||||
case 'free':
|
||||
return {
|
||||
holder: undefined,
|
||||
isSelf: false
|
||||
};
|
||||
case 'held_by_self':
|
||||
return {
|
||||
holder: getSessionId(),
|
||||
isSelf: true
|
||||
};
|
||||
case 'blocked':
|
||||
return {
|
||||
holder: c.by,
|
||||
isSelf: false
|
||||
};
|
||||
}
|
||||
},
|
||||
// Called only when checkCuLock returned `holder: undefined`. The O_EXCL
|
||||
// acquire is atomic — if another process grabbed it in the gap (rare),
|
||||
// throw so the tool fails instead of proceeding without the lock.
|
||||
// `fresh: false` (re-entrant) shouldn't happen given check said free,
|
||||
// but is possible under parallel tool-use interleaving — don't spam the
|
||||
// notification in that case.
|
||||
acquireCuLock: async () => {
|
||||
const r = await tryAcquireComputerUseLock();
|
||||
if (r.kind === 'blocked') {
|
||||
throw new Error(formatLockHeld(r.by));
|
||||
}
|
||||
if (r.fresh) {
|
||||
// Global Escape → abort. Consumes the event (PI defense — prompt
|
||||
// injection can't dismiss dialogs with Escape). The CGEventTap's
|
||||
// CFRunLoopSource is processed by the drainRunLoop pump, so this
|
||||
// holds a pump retain until unregisterEscHotkey() in cleanup.ts.
|
||||
const escRegistered = registerEscHotkey(() => {
|
||||
logForDebugging('[cu-esc] user escape, aborting turn');
|
||||
tuc().abortController.abort();
|
||||
});
|
||||
tuc().sendOSNotification?.({
|
||||
message: escRegistered ? 'Claude is using your computer · press Esc to stop' : 'Claude is using your computer · press Ctrl+C to stop',
|
||||
notificationType: 'computer_use_enter'
|
||||
});
|
||||
}
|
||||
},
|
||||
formatLockHeldMessage: formatLockHeld
|
||||
};
|
||||
}
|
||||
function getOrBind(): Binding {
|
||||
if (binding) return binding;
|
||||
const ctx = buildSessionContext();
|
||||
binding = {
|
||||
ctx,
|
||||
dispatch: bindSessionContext(getComputerUseHostAdapter(), getChicagoCoordinateMode(), ctx)
|
||||
};
|
||||
return binding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full override object for a single `mcp__computer-use__{toolName}`
|
||||
* tool: rendering overrides from `toolRendering.tsx` plus a `.call()` that
|
||||
* dispatches through the cached binder.
|
||||
*/
|
||||
type ComputerUseMCPToolOverrides = ReturnType<typeof getComputerUseMCPRenderingOverrides> & {
|
||||
call: CallOverride;
|
||||
};
|
||||
export function getComputerUseMCPToolOverrides(toolName: string): ComputerUseMCPToolOverrides {
|
||||
const call: CallOverride = async (args, context: ToolUseContext) => {
|
||||
currentToolUseContext = context;
|
||||
const {
|
||||
dispatch
|
||||
} = getOrBind();
|
||||
const {
|
||||
telemetry,
|
||||
...result
|
||||
} = await dispatch(toolName, args);
|
||||
if (telemetry?.error_kind) {
|
||||
logForDebugging(`[Computer Use MCP] ${toolName} error_kind=${telemetry.error_kind}`);
|
||||
}
|
||||
|
||||
// MCP content blocks → Anthropic API blocks. CU only produces text and
|
||||
// pre-sized JPEG (executor.ts computeTargetDims → targetImageSize), so
|
||||
// unlike the generic MCP path there's no resize needed — the MCP image
|
||||
// shape just maps to the API's base64-source shape. The package's result
|
||||
// type admits audio/resource too, but CU's handleToolCall never emits
|
||||
// those; the fallthrough coerces them to empty text.
|
||||
const data = Array.isArray(result.content) ? result.content.map(item => item.type === 'image' ? {
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64' as const,
|
||||
media_type: item.mimeType ?? 'image/jpeg',
|
||||
data: item.data
|
||||
}
|
||||
} : {
|
||||
type: 'text' as const,
|
||||
text: item.type === 'text' ? item.text : ''
|
||||
}) : result.content;
|
||||
return {
|
||||
data
|
||||
};
|
||||
};
|
||||
return {
|
||||
...getComputerUseMCPRenderingOverrides(toolName),
|
||||
call
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the approval dialog mid-call via `setToolJSX` + `Promise`, wait for
|
||||
* the user. Mirrors `spawnMultiAgent.ts:419-436` (the `It2SetupPrompt` pattern).
|
||||
*
|
||||
* The merge-into-AppState that used to live here (dedupe + truthy-only flags)
|
||||
* is now in the package's `bindSessionContext` → `onAllowedAppsChanged`.
|
||||
*/
|
||||
async function runPermissionDialog(req: CuPermissionRequest): Promise<CuPermissionResponse> {
|
||||
const context = tuc();
|
||||
const setToolJSX = context.setToolJSX;
|
||||
if (!setToolJSX) {
|
||||
// Shouldn't happen — main.tsx gate excludes non-interactive. Fail safe.
|
||||
return {
|
||||
granted: [],
|
||||
denied: [],
|
||||
flags: DEFAULT_GRANT_FLAGS
|
||||
};
|
||||
}
|
||||
try {
|
||||
return await new Promise<CuPermissionResponse>((resolve, reject) => {
|
||||
const signal = context.abortController.signal;
|
||||
// If already aborted, addEventListener won't fire — reject now so the
|
||||
// promise doesn't hang waiting for a user who Ctrl+C'd.
|
||||
if (signal.aborted) {
|
||||
reject(new Error('Computer Use permission dialog aborted'));
|
||||
return;
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
reject(new Error('Computer Use permission dialog aborted'));
|
||||
};
|
||||
signal.addEventListener('abort', onAbort);
|
||||
setToolJSX({
|
||||
jsx: React.createElement(ComputerUseApproval, {
|
||||
request: req,
|
||||
onDone: (resp: CuPermissionResponse) => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve(resp);
|
||||
}
|
||||
}),
|
||||
shouldHidePromptInput: true
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
setToolJSX(null);
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJiaW5kU2Vzc2lvbkNvbnRleHQiLCJDb21wdXRlclVzZVNlc3Npb25Db250ZXh0IiwiQ3VDYWxsVG9vbFJlc3VsdCIsIkN1UGVybWlzc2lvblJlcXVlc3QiLCJDdVBlcm1pc3Npb25SZXNwb25zZSIsIkRFRkFVTFRfR1JBTlRfRkxBR1MiLCJTY3JlZW5zaG90RGltcyIsIlJlYWN0IiwiZ2V0U2Vzc2lvbklkIiwiQ29tcHV0ZXJVc2VBcHByb3ZhbCIsIlRvb2wiLCJUb29sVXNlQ29udGV4dCIsImxvZ0ZvckRlYnVnZ2luZyIsImNoZWNrQ29tcHV0ZXJVc2VMb2NrIiwidHJ5QWNxdWlyZUNvbXB1dGVyVXNlTG9jayIsInJlZ2lzdGVyRXNjSG90a2V5IiwiZ2V0Q2hpY2Fnb0Nvb3JkaW5hdGVNb2RlIiwiZ2V0Q29tcHV0ZXJVc2VIb3N0QWRhcHRlciIsImdldENvbXB1dGVyVXNlTUNQUmVuZGVyaW5nT3ZlcnJpZGVzIiwiQ2FsbE92ZXJyaWRlIiwiUGljayIsIkJpbmRpbmciLCJjdHgiLCJkaXNwYXRjaCIsIm5hbWUiLCJhcmdzIiwiUHJvbWlzZSIsImJpbmRpbmciLCJjdXJyZW50VG9vbFVzZUNvbnRleHQiLCJ0dWMiLCJmb3JtYXRMb2NrSGVsZCIsImhvbGRlciIsInNsaWNlIiwiYnVpbGRTZXNzaW9uQ29udGV4dCIsImdldEFsbG93ZWRBcHBzIiwiZ2V0QXBwU3RhdGUiLCJjb21wdXRlclVzZU1jcFN0YXRlIiwiYWxsb3dlZEFwcHMiLCJnZXRHcmFudEZsYWdzIiwiZ3JhbnRGbGFncyIsImdldFVzZXJEZW5pZWRCdW5kbGVJZHMiLCJnZXRTZWxlY3RlZERpc3BsYXlJZCIsInNlbGVjdGVkRGlzcGxheUlkIiwiZ2V0RGlzcGxheVBpbm5lZEJ5TW9kZWwiLCJkaXNwbGF5UGlubmVkQnlNb2RlbCIsImdldERpc3BsYXlSZXNvbHZlZEZvckFwcHMiLCJkaXNwbGF5UmVzb2x2ZWRGb3JBcHBzIiwiZ2V0TGFzdFNjcmVlbnNob3REaW1zIiwiZCIsImxhc3RTY3JlZW5zaG90RGltcyIsImRpc3BsYXlJZCIsIm9yaWdpblgiLCJvcmlnaW5ZIiwidW5kZWZpbmVkIiwib25QZXJtaXNzaW9uUmVxdWVzdCIsInJlcSIsIl9kaWFsb2dTaWduYWwiLCJydW5QZXJtaXNzaW9uRGlhbG9nIiwib25BbGxvd2VkQXBwc0NoYW5nZWQiLCJhcHBzIiwiZmxhZ3MiLCJzZXRBcHBTdGF0ZSIsInByZXYiLCJjdSIsInByZXZBcHBzIiwicHJldkZsYWdzIiwic2FtZUFwcHMiLCJsZW5ndGgiLCJldmVyeSIsImEiLCJpIiwiYnVuZGxlSWQiLCJzYW1lRmxhZ3MiLCJjbGlwYm9hcmRSZWFkIiwiY2xpcGJvYXJkV3JpdGUiLCJzeXN0ZW1LZXlDb21ib3MiLCJvbkFwcHNIaWRkZW4iLCJpZHMiLCJleGlzdGluZyIsImhpZGRlbkR1cmluZ1R1cm4iLCJpZCIsImhhcyIsIlNldCIsIm9uUmVzb2x2ZWREaXNwbGF5VXBkYXRlZCIsIm9uRGlzcGxheVBpbm5lZCIsInBpbm5lZCIsIm5leHRSZXNvbHZlZEZvciIsIm9uRGlzcGxheVJlc29sdmVkRm9yQXBwcyIsImtleSIsIm9uU2NyZWVuc2hvdENhcHR1cmVkIiwiZGltcyIsInAiLCJ3aWR0aCIsImhlaWdodCIsImRpc3BsYXlXaWR0aCIsImRpc3BsYXlIZWlnaHQiLCJjaGVja0N1TG9jayIsImMiLCJraW5kIiwiaXNTZWxmIiwiYnkiLCJhY3F1aXJlQ3VMb2NrIiwiciIsIkVycm9yIiwiZnJlc2giLCJlc2NSZWdpc3RlcmVkIiwiYWJvcnRDb250cm9sbGVyIiwiYWJvcnQiLCJzZW5kT1NOb3RpZmljYXRpb24iLCJtZXNzYWdlIiwibm90aWZpY2F0aW9uVHlwZSIsImZvcm1hdExvY2tIZWxkTWVzc2FnZSIsImdldE9yQmluZCIsIkNvbXB1dGVyVXNlTUNQVG9vbE92ZXJyaWRlcyIsIlJldHVyblR5cGUiLCJjYWxsIiwiZ2V0Q29tcHV0ZXJVc2VNQ1BUb29sT3ZlcnJpZGVzIiwidG9vbE5hbWUiLCJjb250ZXh0IiwidGVsZW1ldHJ5IiwicmVzdWx0IiwiZXJyb3Jfa2luZCIsImRhdGEiLCJBcnJheSIsImlzQXJyYXkiLCJjb250ZW50IiwibWFwIiwiaXRlbSIsInR5cGUiLCJjb25zdCIsInNvdXJjZSIsIm1lZGlhX3R5cGUiLCJtaW1lVHlwZSIsInRleHQiLCJzZXRUb29sSlNYIiwiZ3JhbnRlZCIsImRlbmllZCIsInJlc29sdmUiLCJyZWplY3QiLCJzaWduYWwiLCJhYm9ydGVkIiwib25BYm9ydCIsInJlbW92ZUV2ZW50TGlzdGVuZXIiLCJhZGRFdmVudExpc3RlbmVyIiwianN4IiwiY3JlYXRlRWxlbWVudCIsInJlcXVlc3QiLCJvbkRvbmUiLCJyZXNwIiwic2hvdWxkSGlkZVByb21wdElucHV0Il0sInNvdXJjZXMiOlsid3JhcHBlci50c3giXSwic291cmNlc0NvbnRlbnQiOlsiLyoqXG4gKiBUaGUgYC5jYWxsKClgIG92ZXJyaWRlIOKAlCB0aGluIGFkYXB0ZXIgYmV0d2VlbiBgVG9vbFVzZUNvbnRleHRgIGFuZFxuICogYGJpbmRTZXNzaW9uQ29udGV4dGAuIFNwcmVhZCBpbnRvIHRoZSBNQ1AgdG9vbCBvYmplY3QgaW4gYGNsaWVudC50c2BcbiAqIChzYW1lIHBhdHRlcm4gYXMgQ2hyb21lJ3MgcmVuZGVyaW5nIG92ZXJyaWRlcywgcGx1cyBgLmNhbGwoKWApLlxuICpcbiAqIFRoZSB3cmFwcGVyLWNsb3N1cmUgbG9naWMgKGJ1aWxkIG92ZXJyaWRlcyBmcmVzaCwgbG9jayBnYXRlLCBwZXJtaXNzaW9uXG4gKiBtZXJnZSwgc2NyZWVuc2hvdCBzdGFzaCkgbGl2ZXMgaW4gYEBhbnQvY29tcHV0ZXItdXNlLW1jcGAnc1xuICogYGJpbmRTZXNzaW9uQ29udGV4dGAuIFRoaXMgZmlsZSBiaW5kcyBpdCBvbmNlIHBlciBwcm9jZXNzLFxuICogY2FjaGVzIHRoZSBkaXNwYXRjaGVyLCBhbmQgdXBkYXRlcyBhIHBlci1jYWxsIHJlZiBmb3IgdGhlIHBpZWNlcyBvZlxuICogYFRvb2xVc2VDb250ZXh0YCB0aGF0IHZhcnkgcGVyLWNhbGwgKGBhYm9ydENvbnRyb2xsZXJgLCBgc2V0VG9vbEpTWGAsXG4gKiBgc2VuZE9TTm90aWZpY2F0aW9uYCkuIEFwcFN0YXRlIGFjY2Vzc29ycyBhcmUgcmVhZCB0aHJvdWdoIHRoZSByZWYgdG9vIOKAlFxuICogdGhleSdyZSBsaWtlbHkgc3RhYmxlIGJ1dCB3ZSBkb24ndCBkZXBlbmQgb24gdGhhdC5cbiAqXG4gKiBFeHRlcm5hbCBjYWxsZXJzIHJlYWNoIHRoaXMgdmlhIHRoZSBsYXp5IHJlcXVpcmUgdGh1bmsgaW4gYGNsaWVudC50c2AsIGdhdGVkXG4gKiBvbiBgZmVhdHVyZSgnQ0hJQ0FHT19NQ1AnKWAuIFJ1bnRpbWUgZW5hYmxlbWVudCBpcyBjb250cm9sbGVkIGJ5IHRoZVxuICogR3Jvd3RoQm9vayBnYXRlIGB0ZW5ndV9tYWxvcnRfcGVkd2F5YCAoc2VlIGdhdGVzLnRzKS5cbiAqL1xuXG5pbXBvcnQge1xuICBiaW5kU2Vzc2lvbkNvbnRleHQsXG4gIHR5cGUgQ29tcHV0ZXJVc2VTZXNzaW9uQ29udGV4dCxcbiAgdHlwZSBDdUNhbGxUb29sUmVzdWx0LFxuICB0eXBlIEN1UGVybWlzc2lvblJlcXVlc3QsXG4gIHR5cGUgQ3VQZXJtaXNzaW9uUmVzcG9uc2UsXG4gIERFRkFVTFRfR1JBTlRfRkxBR1MsXG4gIHR5cGUgU2NyZWVuc2hvdERpbXMsXG59IGZyb20gJ0BhbnQvY29tcHV0ZXItdXNlLW1jcCdcbmltcG9ydCAqIGFzIFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgZ2V0U2Vzc2lvbklkIH0gZnJvbSAnLi4vLi4vYm9vdHN0cmFwL3N0YXRlLmpzJ1xuaW1wb3J0IHsgQ29tcHV0ZXJVc2VBcHByb3ZhbCB9IGZyb20gJy4uLy4uL2NvbXBvbmVudHMvcGVybWlzc2lvbnMvQ29tcHV0ZXJVc2VBcHByb3ZhbC9Db21wdXRlclVzZUFwcHJvdmFsLmpzJ1xuaW1wb3J0IHR5cGUgeyBUb29sLCBUb29sVXNlQ29udGV4dCB9IGZyb20gJy4uLy4uL1Rvb2wuanMnXG5pbXBvcnQgeyBsb2dGb3JEZWJ1Z2dpbmcgfSBmcm9tICcuLi9kZWJ1Zy5qcydcbmltcG9ydCB7XG4gIGNoZWNrQ29tcHV0ZXJVc2VMb2NrLFxuICB0cnlBY3F1aXJlQ29tcHV0ZXJVc2VMb2NrLFxufSBmcm9tICcuL2NvbXB
Line truncated
|
||||
Reference in new issue
Block a user