claude-code-best/src/utils/computerUse/win32/bridgeClient.ts

205 lines
5.5 KiB
TypeScript

/**
* Python Bridge Client — manages a long-lived Python subprocess for Windows
* Computer Use operations.
*
* Replaces per-call PowerShell spawning with a persistent Python process
* that communicates via JSON lines over stdin/stdout.
*
* Performance: ~1-5ms per call vs ~200-500ms per PowerShell spawn.
*/
import * as path from 'path'
import type { Writable } from 'stream'
interface BridgeRequest {
id: number
method: string
params: Record<string, unknown>
}
interface BridgeResponse {
id: number
result?: unknown
error?: string
}
let bridgeProc: ReturnType<typeof Bun.spawn> | null = null
let requestId = 0
const pendingRequests = new Map<
number,
{
resolve: (value: unknown) => void
reject: (error: Error) => void
}
>()
let outputBuffer = ''
/**
* Start the Python bridge process if not already running.
*/
export function ensureBridge(): boolean {
if (bridgeProc) return true
try {
const scriptPath = path.join(__dirname, 'bridge.py')
bridgeProc = Bun.spawn(['python', '-u', scriptPath], {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'ignore',
env: { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONUNBUFFERED: '1' },
})
// Read stdout lines asynchronously
const reader = (bridgeProc.stdout as ReadableStream<Uint8Array>).getReader()
const readLoop = async () => {
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
outputBuffer += new TextDecoder().decode(value)
// Process complete lines
let newlineIdx: number
while ((newlineIdx = outputBuffer.indexOf('\n')) !== -1) {
const line = outputBuffer.slice(0, newlineIdx).trim()
outputBuffer = outputBuffer.slice(newlineIdx + 1)
if (!line) continue
try {
const resp: BridgeResponse = JSON.parse(line)
const pending = pendingRequests.get(resp.id)
if (pending) {
pendingRequests.delete(resp.id)
if (resp.error) {
pending.reject(new Error(resp.error))
} else {
pending.resolve(resp.result)
}
}
} catch {}
}
}
} catch {}
}
readLoop()
return true
} catch {
bridgeProc = null
return false
}
}
/**
* Send a request to the Python bridge and wait for the response.
*/
export async function call<T = unknown>(
method: string,
params: Record<string, unknown> = {},
timeoutMs: number = 10000,
): Promise<T> {
if (!ensureBridge()) {
throw new Error('Python bridge not available')
}
const id = ++requestId
const req: BridgeRequest = { id, method, params }
return new Promise<T>((resolve, reject) => {
pendingRequests.set(id, {
resolve: resolve as (v: unknown) => void,
reject,
})
// Timeout
const timer = setTimeout(() => {
pendingRequests.delete(id)
reject(new Error(`Bridge call ${method} timed out after ${timeoutMs}ms`))
}, timeoutMs)
// Clear timeout on resolve/reject
const origResolve = resolve as (v: unknown) => void
const origReject = reject
pendingRequests.set(id, {
resolve: v => {
clearTimeout(timer)
origResolve(v)
},
reject: e => {
clearTimeout(timer)
origReject(e)
},
})
try {
const stdin = bridgeProc!.stdin
if (stdin) {
const writable = stdin as unknown as Writable
writable.write(JSON.stringify(req) + '\n')
if (typeof (writable as any).flush === 'function') {
(writable as any).flush()
}
}
} catch (err) {
clearTimeout(timer)
pendingRequests.delete(id)
reject(new Error(`Bridge write failed: ${err}`))
}
})
}
/**
* Synchronous call — blocks the event loop. Use sparingly.
* Falls back to PowerShell if bridge is not available.
*/
export function callSync<T = unknown>(
method: string,
params: Record<string, unknown> = {},
timeoutMs: number = 10000,
): T | null {
// For sync calls, spawn a one-shot Python process.
// SECURITY: JSON is passed via stdin (not embedded in -c) to prevent code injection.
try {
const scriptPath = path.join(__dirname, 'bridge.py')
const req = JSON.stringify({ id: 1, method, params })
const result = Bun.spawnSync({
cmd: ['python', '-u', scriptPath],
stdin: Buffer.from(req + '\n'),
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
timeout: timeoutMs,
})
const out = new TextDecoder().decode(result.stdout).trim()
if (!out) return null
const resp: BridgeResponse = JSON.parse(out)
if (resp.error) throw new Error(resp.error)
return resp.result as T
} catch {
return null
}
}
/**
* Kill the bridge process.
*/
export function stopBridge(): void {
if (bridgeProc) {
try {
const stdin = bridgeProc.stdin
if (stdin) {
const writable = stdin as unknown as Writable
if (typeof writable.end === 'function') {
writable.end()
}
}
bridgeProc.kill()
} catch {}
bridgeProc = null
}
pendingRequests.clear()
outputBuffer = ''
}
// NOTE: No process exit handlers here — the platform-level win32.ts
// already registers exit/SIGINT/SIGTERM handlers that call cleanupAll(),
// which includes stopBridge(). Adding handlers here would cause double
// cleanup and duplicate process.exit() calls.