diff --git a/README.md b/README.md index 2b332ba57f..4f22638c56 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ providers dimmed with the reason. Switch a bot's model mid-conversation. ### πŸ–₯️ Every bot gets a computer Open the Computer panel and the bot's cloud desktop spins up on its own β€” live screen preview while it -works, "Open desktop" to take over in your browser, or point the bot at *this Mac* instead. +works, "Open desktop" to take over in your browser, or point the bot at *this computer* instead. Computer panel with live screen preview @@ -134,7 +134,7 @@ flowchart LR REG[Driver registry] --> BUS[Event bus β†’ SSE] BROKER[Permission broker] end - subgraph agents ["Agents on your Mac"] + subgraph agents ["Agents on your machine"] CL[claude CLI] CX[codex CLI] end @@ -152,12 +152,13 @@ flowchart LR | Harness | `server/harness/` | Registry (configs β†’ live instances) and the fan-in event bus every client folds. | | API | `server/index.ts` | Bots, turns, approvals, model catalog, computer lifecycle, connectors, config β€” HTTP + SSE. | | App | `src/` | The chat shell. Server-backed store, one reducer, zero client-side transports. | -| Desktop | `electron/` | macOS shell: dictation helper (SFSpeechRecognizer), local screen capture, CUA bridge. | +| Desktop | `electron/` | Desktop shell: macOS dictation helper (SFSpeechRecognizer), local screen capture, CUA bridge. | ## Quick start -**Easiest:** [download the latest .dmg](https://github.com/milind-soni/openmausbot-releases/releases/latest), -drag it to Applications, open it. The harness server is embedded β€” no setup. +**Easiest:** [download the latest release](https://github.com/milind-soni/openmausbot-releases/releases/latest) +β€” `.dmg` for macOS (drag to Applications) or `.exe` for Windows (NSIS installer). The harness server is +embedded β€” no setup. **From source:** @@ -170,10 +171,15 @@ pnpm dev # app β†’ http://127.0.0.1:5199 pnpm dev:desktop # or the Electron shell ``` -Requirements: **macOS**, **Node 24+**, **pnpm**, and at least one agent CLI β€” [`claude`](https://claude.com/claude-code) +Requirements: **macOS or Windows**, **Node 24+**, **pnpm**, and at least one agent CLI β€” [`claude`](https://claude.com/claude-code) or [`codex`](https://github.com/openai/codex) β€” installed and logged in. They appear in the model picker automatically. +**Platform notes:** computer use resolves the `cua-driver` binary automatically β€” the CuaDriver app +(macOS) or the trycua Cua app / `cua-driver.exe` on PATH (Windows) β€” and reuses a running daemon when one +is up. Native dictation is macOS-only (Swift/SFSpeechRecognizer); the composer mic button is hidden +elsewhere. Windows packaging: `pnpm package:win`. + Optional, pasted once in **App Settings** (gear in the sidebar footer): | Key | Unlocks | @@ -190,8 +196,9 @@ pnpm build # typecheck + production build ## Status Early but real β€” the loop works end to end: message β†’ agent β†’ streamed reply β†’ tools β†’ approvals β†’ -computer use. Rough edges to expect: routines (scheduled tasks) are a placeholder, sidebar sections aren't -built yet, and Windows/Linux shells haven't been attempted (the harness itself is portable Node). +computer use, on macOS and Windows. Rough edges to expect: routines (scheduled tasks) are a placeholder, +sidebar sections aren't built yet, native dictation is macOS-only, and Linux packaging hasn't been +attempted (the harness itself is portable Node). Contributions welcome β€” the driver SPI in [`server/contracts.ts`](server/contracts.ts) is deliberately small; adding a provider is one file in [`server/drivers/`](server/drivers/) plus a one-line registration. diff --git a/build/icon.ico b/build/icon.ico new file mode 100644 index 0000000000..dd2639d06c Binary files /dev/null and b/build/icon.ico differ diff --git a/dist-server/drivers/claude.js b/dist-server/drivers/claude.js index ee4c5c9c0c..95002a2278 100644 --- a/dist-server/drivers/claude.js +++ b/dist-server/drivers/claude.js @@ -8,8 +8,6 @@ // - Composio Connect (connected apps β†’ tools) over streamable HTTP // - the bot's cloud computer (box.ascii.dev) via server/computer-proxy.ts // β€” screenshot/exec/open_url, the CUA-on-the-box bridge -import { spawn } from "node:child_process"; -import { execFile } from "node:child_process"; import { existsSync, unlinkSync } from "node:fs"; import { createServer as createNetServer } from "node:net"; import { homedir } from "node:os"; @@ -17,6 +15,7 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { DATA_DIR } from "../config.js"; import { newEventId, newId } from "../contracts.js"; +import { cliExec, cliVersion, killProcessTree, spawnCliHidden } from "./cli.js"; import { appendNative } from "./native.js"; const DRIVER_KIND = "claudeAgent"; // model catalog ported from upstream packages/contracts/src/model.ts @@ -56,6 +55,11 @@ function askSummary(ask) { } function permissionSocketPath(threadId) { const tag = threadId.replace(/[^\w-]/g, "").slice(0, 8); + // Windows has no unix sockets; Node maps a listen() path to a named pipe, + // and drive-letter paths (with ':') are invalid pipe names (EACCES). + // Use an explicit \\.\pipe\ name β€” both sides get the same string via argv. + if (process.platform === "win32") + return `\\\\.\\pipe\\ogb-perm-${tag}`; return join(DATA_DIR, `perm-${tag}.sock`); } function createPermissionBroker(opts) { @@ -272,7 +276,7 @@ export const ClaudeDriver = { delete env.ANTHROPIC_API_KEY; delete env.CLAUDECODE; delete env.CLAUDE_CODE_ENTRYPOINT; - const child = spawn(config.cli, args, { + const child = spawnCliHidden(config.cli, args, { cwd: turn.cwd ?? homedir(), env, stdio: ["pipe", "pipe", "pipe"], @@ -370,17 +374,7 @@ export const ClaudeDriver = { settle(false, "exit_before_result"); } }); - const stop = () => { - try { - process.kill(-child.pid, "SIGTERM"); - } - catch { - try { - child.kill("SIGTERM"); - } - catch { } - } - }; + const stop = () => killProcessTree(child.pid); active.set(threadId, { stop, turnId, broker }); emit({ ...base(threadId, turnId), type: "turn.started" }); // prompt over stdin as a stream-json message β€” never argv (ARG_MAX) @@ -391,9 +385,7 @@ export const ClaudeDriver = { return { turnId }; }; const snapshot = async () => { - const version = await new Promise((resolve) => { - execFile(config.cli, ["--version"], { timeout: 8000 }, (err, stdout) => resolve(err ? null : stdout.trim())); - }); + const version = await cliVersion(config.cli); if (!version) return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` }; const authenticated = existsSync(join(homedir(), ".claude", ".credentials.json")); @@ -430,9 +422,12 @@ export const ClaudeDriver = { return () => listeners.delete(listener); }, }, - generateText: (prompt) => new Promise((resolve, reject) => { - execFile(config.cli, ["-p", prompt, "--model", "claude-haiku-4-5", "--output-format", "text"], { timeout: 60_000, env: { ...process.env } }, (err, stdout) => (err ? reject(err) : resolve(stdout.trim()))); - }), + generateText: async (prompt) => { + const res = await cliExec(config.cli, ["-p", prompt, "--model", "claude-haiku-4-5", "--output-format", "text"], { timeout: 60_000, env: { ...process.env } }); + if (!res.ok) + throw new Error(res.stderr || `\`${config.cli}\` failed`); + return res.stdout.trim(); + }, dispose: async () => { for (const { stop } of active.values()) stop(); diff --git a/dist-server/drivers/cli.js b/dist-server/drivers/cli.js new file mode 100644 index 0000000000..0f11b96900 --- /dev/null +++ b/dist-server/drivers/cli.js @@ -0,0 +1,231 @@ +// Windows CLI helpers. +// +// CLIs installed via npm/yarn/pnpm on Windows ship as .cmd batch shims +// (e.g. `codex.cmd` in %APPDATA%\npm). child_process cannot execute .cmd +// files directly β€” spawn/execFile fail with ENOENT unless the command runs +// through cmd.exe, and going through cmd.exe re-opens argv to its quoting +// and %VAR% expansion rules. Instead we resolve the shim to the real JS +// entry and run it with process.execPath β€” no shell at all. Native +// installers (the claude installer β†’ claude.exe) resolve to their .exe. +// +// All helpers are async (or spawn async children) β€” nothing here blocks +// the harness event loop. +import { execFile, spawn, spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +const IS_WIN = process.platform === "win32"; +const SHIM_RE = /\.(cmd|bat)$/i; +// `where` results don't change often; cache per CLI so per-turn spawns +// don't pay a fresh `where` process each time. +const whereCache = new Map(); +const WHERE_TTL = 60_000; +/** Resolve a CLI name to a path that can actually be spawned. */ +export function resolveCli(cli) { + if (!IS_WIN) + return cli; + const hit = whereCache.get(cli); + if (hit && Date.now() - hit.at < WHERE_TTL) + return hit.path; + let resolved = cli; + try { + const out = spawnSync("where", [cli], { encoding: "utf8", timeout: 5000, windowsHide: true }); + if (out.status === 0 && out.stdout) { + const candidates = out.stdout + .split(/\r?\n/) + .map((c) => c.trim()) + .filter(Boolean); + const exe = candidates.find((c) => /\.exe$/i.test(c)); + const shim = candidates.find((c) => SHIM_RE.test(c)); + resolved = exe ?? shim ?? cli; + } + } + catch { + /* keep the raw name */ + } + whereCache.set(cli, { path: resolved, at: Date.now() }); + return resolved; +} +// npm/pnpm/yarn .cmd shims are thin wrappers that exec node on a JS entry +// (e.g. `"%dp0%\node_modules\@openai\codex\bin\codex.js" %*`). Extract that +// entry so we can spawn process.execPath directly and skip cmd.exe. +function shimScriptTarget(shim) { + try { + const text = readFileSync(shim, "utf8"); + const m = text.match(/"([^"]+\.(?:[cm]?js))"/); + if (!m) + return null; + const raw = m[1].replace(/%(?:~)?dp0%/gi, dirname(shim) + "\\"); + if (raw.includes("%")) + return null; // unknown var token β€” give up + return isAbsolute(raw) ? raw : resolve(dirname(shim), raw); + } + catch { + return null; + } +} +/** + * How to spawn a CLI on this platform: + * - POSIX: the raw name (resolved via PATH by the shell-less spawn). + * - Windows: the resolved .exe, or β€” for .cmd shims β€” node running the + * shim's real JS entry, which needs no cmd.exe at all. + * Falls back to the raw name when nothing better can be resolved. + */ +export function resolveCliCommand(cli) { + if (!IS_WIN) + return { command: cli, args: [] }; + const resolved = resolveCli(cli); + if (SHIM_RE.test(resolved)) { + const script = shimScriptTarget(resolved); + if (script) { + return { + command: process.execPath, + args: [script], + // packaged: process.execPath is the Electron binary; run as plain node + env: { ELECTRON_RUN_AS_NODE: "1" }, + }; + } + } + return { command: resolved, args: [] }; +} +/** execFile equivalent that also works for .cmd shims on Windows. */ +export function cliExec(cli, args, opts = {}) { + const { command, args: prefix, env: runEnv } = resolveCliCommand(cli); + return new Promise((resolvePromise) => { + const execOpts = { + timeout: opts.timeout, + env: { ...opts.env, ...runEnv }, + windowsHide: true, + }; + const cb = (err, stdout, stderr) => resolvePromise({ ok: !err, stdout, stderr: stderr ?? "" }); + // last-resort: a .cmd shim we couldn't unwrap runs through cmd.exe + if (IS_WIN && SHIM_RE.test(command)) { + execFile(command, [...prefix, ...args], { ...execOpts, shell: true }, cb); + } + else { + execFile(command, [...prefix, ...args], execOpts, cb); + } + }); +} +/** `cli --version` probe; null when the CLI is missing or errors. */ +export function cliVersion(cli, timeoutMs = 8000) { + return cliExec(cli, ["--version"], { timeout: timeoutMs }).then((r) => r.ok && r.stdout.trim() ? r.stdout.trim() : null); +} +/** + * Kill a spawned CLI and its whole process tree (child MCP servers, the + * codex app-server worker, etc.). POSIX uses the process group + * (children are spawned detached); Windows uses taskkill /T /F because + * process.kill(-pid) throws ESRCH and plain kill() leaves orphans. + */ +export function killProcessTree(pid) { + if (!pid) + return; + if (IS_WIN) { + try { + spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { + windowsHide: true, + timeout: 5000, + }); + } + catch { + /* already gone */ + } + return; + } + try { + process.kill(-pid, "SIGTERM"); + } + catch { + try { + process.kill(pid, "SIGTERM"); + } + catch { + /* already gone */ + } + } +} +// PowerShell wrapper that runs a CLI with a hidden console. windowsHide on +// the direct spawn would give the CLI NO console β€” then every console-app +// it spawns (cmd.exe for device-id probes, MCP servers like cua-driver.exe) +// would create its own VISIBLE console window. A hidden console instead is +// inherited by the whole subtree, so nothing ever flashes. Args travel in a +// JSON file, so there is no cmd/PowerShell quoting hazard. +const PS_HIDDEN_WRAPPER = `param([string]$Cli, [string]$ArgsFile) +$ErrorActionPreference = 'Stop' +$ArgList = @(Get-Content -Raw -LiteralPath $ArgsFile | ConvertFrom-Json) +& $Cli @ArgList +exit $LASTEXITCODE +`; +// PowerShell 5.1 (built into Windows) mangles native args that contain +// embedded quotes β€” fatal for --mcp-config JSON. PowerShell 7 (pwsh) +// passes argv correctly, so prefer it and fall back to a plain +// windowsHide spawn (direct child hidden; grandchildren may flash). +function resolvePwsh() { + const candidates = [ + join(process.env.ProgramFiles ?? "C:\\Program Files", "PowerShell", "7", "pwsh.exe"), + ...(process.env.ProgramW6432 ? [join(process.env.ProgramW6432, "PowerShell", "7", "pwsh.exe")] : []), + ]; + for (const c of candidates) { + if (existsSync(c)) + return c; + } + return null; +} +/** + * Spawn a CLI so that no console window ever appears on Windows β€” including + * for anything the CLI itself spawns. POSIX: plain spawn (no-op). + * Callers pass stdio pipes (["pipe","pipe","pipe"]) and get a child with + * live stdout/stderr streams. + */ +export function spawnCliHidden(cli, args, opts) { + const { command, args: prefix, env: runEnv } = resolveCliCommand(cli); + if (!IS_WIN) { + return spawn(command, [...prefix, ...args], opts); + } + // `detached` is a POSIX-only need here (killProcessTree uses the process + // group). On Windows it maps to DETACHED_PROCESS β€” the child gets NO + // console, and pwsh then exits 0 immediately without running the script + // or writing a byte, which surfaces as "cli exited 0 before result". + // Windows reaps the tree with taskkill /T /F, so drop the flag. + const { detached: _detached, ...winOpts } = opts; + const pwsh = resolvePwsh(); + if (!pwsh) { + // no pwsh: plain spawn with the direct child hidden (grandchildren may + // flash their own consoles β€” acceptable degradation) + return spawn(command, [...prefix, ...args], { + ...winOpts, + env: { ...opts.env, ...runEnv }, + windowsHide: true, + }); + } + // NOTE: no windowsHide here β€” PowerShell must keep a (hidden) console so + // the CLI and its console descendants attach to it instead of flashing. + const dir = mkdtempSync(join(tmpdir(), "omb-spawn-")); + const argsFile = join(dir, "args.json"); + const wrapper = join(dir, "wrap.ps1"); + writeFileSync(argsFile, JSON.stringify([...prefix, ...args])); + writeFileSync(wrapper, PS_HIDDEN_WRAPPER); + const child = spawn(pwsh, [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-WindowStyle", + "Hidden", + "-File", + wrapper, + "-Cli", + command, + "-ArgsFile", + argsFile, + ], { ...winOpts, env: { ...opts.env, ...runEnv } }); + child.once("close", () => { + try { + rmSync(dir, { recursive: true, force: true }); + } + catch { + /* best-effort cleanup */ + } + }); + return child; +} diff --git a/dist-server/drivers/codex.js b/dist-server/drivers/codex.js index 2963d78b69..3f703827d5 100644 --- a/dist-server/drivers/codex.js +++ b/dist-server/drivers/codex.js @@ -9,10 +9,10 @@ // // resumeCursor is the codex thread id; a later turn tries thread/resume // and falls back to a fresh thread/start. -import { spawn, execFile } from "node:child_process"; import { homedir } from "node:os"; import { newEventId, newId } from "../contracts.js"; import { appendNative } from "./native.js"; +import { cliVersion, killProcessTree, spawnCliHidden } from "./cli.js"; const DRIVER_KIND = "codex"; // catalog ported from upstream packages/contracts/src/model.ts const MODELS = { @@ -62,7 +62,7 @@ export const CodexDriver = { // the CLI owns its own ChatGPT login; a leaked API key silently flips // billing to pay-as-you-go (agentcal) delete env.OPENAI_API_KEY; - const child = spawn(config.cli, ["app-server"], { + const child = spawnCliHidden(config.cli, ["app-server"], { cwd: turn.cwd ?? homedir(), env, stdio: ["pipe", "pipe", "pipe"], @@ -84,17 +84,7 @@ export const CodexDriver = { rpcPending.set(id, { resolve, reject }); send({ jsonrpc: "2.0", id, method, params }); }); - const stop = () => { - try { - process.kill(-child.pid, "SIGTERM"); - } - catch { - try { - child.kill("SIGTERM"); - } - catch { } - } - }; + const stop = () => killProcessTree(child.pid); const settle = (ok, stopReason) => { if (state.settled) return; @@ -328,9 +318,7 @@ export const CodexDriver = { return { turnId }; }; const snapshot = async () => { - const version = await new Promise((resolve) => { - execFile(config.cli, ["--version"], { timeout: 8000 }, (err, stdout) => resolve(err ? null : stdout.trim())); - }); + const version = await cliVersion(config.cli); if (!version) return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` }; return { state: "available", version }; diff --git a/dist-server/index.js b/dist-server/index.js index e15f26d809..dfb4c0c4fc 100644 --- a/dist-server/index.js +++ b/dist-server/index.js @@ -190,13 +190,26 @@ function stopScreenPoller(botId) { return entry.last; } // Local computer-use contract written by Electron main on startup -// (~/Library/Application Support/OpenMausBot/cua-connection.json). Read -// fresh each turn β€” Electron may restart or permissions may change. -function readCuaConnection() { +// (app.getPath("userData")/cua-connection.json β€” Electron main passes the +// exact path via OMB_USER_DATA; a standalone dev server falls back to +// per-platform userData locations). Read fresh each turn β€” Electron may +// restart or permissions may change. +function cuaConnectionCandidates() { + const explicit = process.env.OMB_USER_DATA; + if (explicit) + return [join(explicit, "cua-connection.json")]; + const roots = process.platform === "win32" + ? [process.env.APPDATA ?? join(homedir(), "AppData", "Roaming")] + : process.platform === "darwin" + ? [join(homedir(), "Library", "Application Support")] + : [join(homedir(), ".config")]; // new name first; pre-rename desktop builds used the old directory - for (const dir of ["OpenMausBot", "openmausbot", "OpenGrokBot", "opengrokbot"]) { + const dirs = ["OpenMausBot", "openmausbot", "OpenGrokBot", "opengrokbot"]; + return roots.flatMap((root) => dirs.map((dir) => join(root, dir, "cua-connection.json"))); +} +function readCuaConnection() { + for (const p of cuaConnectionCandidates()) { try { - const p = join(homedir(), "Library", "Application Support", dir, "cua-connection.json"); const conn = JSON.parse(readFileSync(p, "utf8")); if (!conn || conn.mode === "unavailable" || !conn.mcpCommand) continue; diff --git a/electron-builder.yml b/electron-builder.yml index 78361dc4e0..a7092730b9 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -25,12 +25,16 @@ extraResources: to: ui - from: dist-server to: server - - from: electron/resources/speech-helper - to: speech-helper - - from: electron/resources/perm-helper - to: perm-helper +# macOS-only helpers (Swift binaries built by pnpm build:speech/build:perm); +# Windows/Linux builds don't ship them β€” dictation is macOS-only and the +# screen-permission helper is CGRequestScreenCaptureAccess (TCC). mac: + extraResources: + - from: electron/resources/speech-helper + to: speech-helper + - from: electron/resources/perm-helper + to: perm-helper target: - target: dmg arch: arm64 @@ -52,3 +56,10 @@ mac: dmg: sign: true artifactName: OpenMausBot-${version}.dmg + +win: + target: + - target: nsis + arch: + - x64 + icon: build/icon.ico diff --git a/electron/cua.mjs b/electron/cua.mjs index 360c518167..9200186a1e 100644 --- a/electron/cua.mjs +++ b/electron/cua.mjs @@ -15,17 +15,51 @@ // /cua-connection.json for the harness server to hand to drivers. import { app, ipcMain } from "electron"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import net from "node:net"; import path from "node:path"; -const INSTALLED_DRIVER = "/Applications/CuaDriver.app/Contents/MacOS/cua-driver"; +const HOST_BUNDLE_ID = "com.openmausbot.app"; +const IS_WIN = process.platform === "win32"; + +// Driver locations outside the app bundle. macOS ships CuaDriver.app; on +// Windows the trycua Cua app installs under %LOCALAPPDATA%\Programs, and +// cua-driver.exe may also be on PATH. const STANDALONE_SOCKET = path.join( app.getPath("home"), "Library/Caches/cua-driver/cua-driver.sock", ); -const HOST_BUNDLE_ID = "com.openmausbot.app"; +const INSTALLED_DRIVER_CANDIDATES = IS_WIN + ? [ + path.join( + process.env.LOCALAPPDATA ?? path.join(app.getPath("home"), "AppData", "Local"), + "Programs", + "Cua", + "cua-driver", + "bin", + "cua-driver.exe", + ), + path.join( + process.env.LOCALAPPDATA ?? path.join(app.getPath("home"), "AppData", "Local"), + "Programs", + "CuaDriver", + "cua-driver.exe", + ), + path.join(process.env.PROGRAMFILES ?? "C:\\Program Files", "CuaDriver", "cua-driver.exe"), + ] + : ["/Applications/CuaDriver.app/Contents/MacOS/cua-driver"]; + +function driverOnPath() { + const find = IS_WIN ? "where" : "which"; + try { + const out = spawnSync(find, ["cua-driver"], { encoding: "utf8", timeout: 5000, windowsHide: true }); + if (out.status === 0 && out.stdout) return out.stdout.split(/\r?\n/)[0].trim(); + } catch { + /* no PATH lookup available */ + } + return null; +} let embeddedHost = null; // EmbeddedCuaDriverHost | null let connection = null; // descriptor exposed to harness + renderer @@ -36,8 +70,10 @@ export function resolveDriverBinary() { const bundled = path.join(process.resourcesPath, "cua-driver"); if (fs.existsSync(bundled)) return bundled; } - if (fs.existsSync(INSTALLED_DRIVER)) return INSTALLED_DRIVER; - return null; + for (const candidate of INSTALLED_DRIVER_CANDIDATES) { + if (fs.existsSync(candidate)) return candidate; + } + return driverOnPath(); } function socketAlive(sockPath) { @@ -69,6 +105,42 @@ async function startEmbedded(binary) { }; } +async function daemonRunning() { + // On Windows the daemon listens on a named pipe that a unix-style socket + // connect can't probe; `cua-driver status` reports liveness cross-platform. + if (IS_WIN) { + const binary = resolveDriverBinary(); + if (!binary) return false; + // async child β€” never block the main process on a dead daemon + return new Promise((resolveProbe) => { + let out = ""; + let settled = false; + const done = (ok) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolveProbe(ok); + }; + let child; + try { + child = spawn(binary, ["status"], { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); + } catch { + return done(false); + } + const timer = setTimeout(() => { + try { + child.kill(); + } catch {} + done(false); + }, 5000); + child.stdout.on("data", (d) => (out += d)); + child.on("error", () => done(false)); + child.on("close", (code) => done(code === 0 && /daemon is running/i.test(out))); + }); + } + return socketAlive(STANDALONE_SOCKET); +} + export async function startCua() { const binary = resolveDriverBinary(); if (!binary) { @@ -88,11 +160,13 @@ export async function startCua() { reason: `embedded host failed: ${err?.message ?? err}`, }; } - } else if (await socketAlive(STANDALONE_SOCKET)) { - // Dev machine with CuaDriver.app's daemon already running. + } else if (await daemonRunning()) { + // Dev machine with a cua-driver daemon already running (CuaDriver.app on + // macOS, the trycua Cua app / `cua-driver serve` on Windows). The MCP + // proxy discovers the daemon itself, so no socket path is needed. connection = { mode: "standalone", - socketPath: STANDALONE_SOCKET, + ...(IS_WIN ? {} : { socketPath: STANDALONE_SOCKET }), mcpCommand: binary, mcpArgs: ["mcp"], mcpEnv: {}, @@ -118,6 +192,7 @@ export function cuaPermissionsStatus() { const out = spawnSync(binary, ["permissions", "status", "--json"], { encoding: "utf8", timeout: 5000, + windowsHide: true, }); try { return { available: true, ...JSON.parse(out.stdout) }; diff --git a/electron/main.mjs b/electron/main.mjs index ae68ee3219..dc8fb6ea1b 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -11,6 +11,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DEV_URL = process.env.ELECTRON_START_URL ?? "http://127.0.0.1:5199"; let SERVER_PORT = 8799; const APP_ICON = path.join(__dirname, "resources/app-icon.png"); +const IS_MAC = process.platform === "darwin"; // Packaged: the harness server ships in Resources (compiled JS, zero deps) // and runs on Electron's own Node via utilityProcess. It serves the built @@ -27,6 +28,8 @@ async function startServerOn(port) { ...process.env, OMB_STATIC_DIR: path.join(process.resourcesPath, "ui"), OMB_PORT: String(port), + // exact userData so the harness can read cua-connection.json on any OS + OMB_USER_DATA: app.getPath("userData"), }, stdio: "inherit", }); @@ -78,7 +81,7 @@ async function startServerPackaged() { const ERROR_PAGE = "data:text/html;charset=utf-8," + encodeURIComponent( - `
🐭

Couldn't start the bot server

Something else is using its ports. Quit and reopen OpenMausBot β€” if it keeps happening, restart your Mac.

`, + `
🐭

Couldn't start the bot server

Something else is using its ports. Quit and reopen OpenMausBot β€” if it keeps happening, restart your computer.

`, ); function createWindow() { @@ -89,8 +92,9 @@ function createWindow() { minHeight: 600, icon: APP_ICON, backgroundColor: "#070707", - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 16, y: 16 }, + // hiddenInset + traffic lights are macOS-only window chrome; other + // platforms get the default frame + ...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 16 } } : {}), webPreferences: { contextIsolation: true, preload: path.join(__dirname, "preload.cjs"), @@ -109,8 +113,8 @@ function createWindow() { } } -// "This Mac" screen preview β€” served from the main process so the Screen -// Recording permission prompt attributes to the app, never the server +// "This computer" screen preview β€” served from the main process so the +// Screen Recording permission prompt attributes to the app, never the server ipcMain.handle("screen:frame", async () => { const sources = await desktopCapturer.getSources({ types: ["screen"], @@ -119,14 +123,15 @@ ipcMain.handle("screen:frame", async () => { return sources[0]?.thumbnail.toDataURL() ?? null; }); -// Onboarding permission checks. Status reads are free; the mic request -// pops the real TCC prompt attributed to the app. Screen Recording has no -// programmatic request β€” the first desktopCapturer call prompts. +// Onboarding permission checks. macOS: TCC prompts attributed to the app. +// Windows has no TCC β€” devices are gated by Windows itself at first use, so +// the checks report the OS state and the request handlers are no-ops. ipcMain.handle("perm:status", () => ({ mic: systemPreferences.getMediaAccessStatus?.("microphone") ?? "unknown", screen: systemPreferences.getMediaAccessStatus?.("screen") ?? "unknown", })); ipcMain.handle("perm:request-mic", async () => { + if (!IS_MAC) return true; // no programmatic prompt on Windows/Linux try { return await systemPreferences.askForMediaAccess("microphone"); } catch { @@ -137,37 +142,51 @@ ipcMain.handle("perm:request-mic", async () => { // registers a capture attempt, and Electron's thumbnail API doesn't always // register one on newer macOS. A child `screencapture` probe inherits the // app's TCC identity β€” it registers OpenMausBot in the pane and triggers -// the system dialog on first use. +// the system dialog on first use. macOS-only (CGRequestScreenCaptureAccess). const PERM_HELPER = app.isPackaged ? path.join(process.resourcesPath, "perm-helper") : path.join(__dirname, "resources", "perm-helper"); ipcMain.handle("perm:request-screen", async () => { - // CGRequestScreenCaptureAccess via the helper β€” registers the app in the - // pane and shows the system dialog; child inherits the app's TCC identity - await new Promise((resolve) => { - execFile(PERM_HELPER, ["request"], { timeout: 15_000 }, () => resolve()); - }); + if (IS_MAC) { + await new Promise((resolve) => { + execFile(PERM_HELPER, ["request"], { timeout: 15_000 }, () => resolve()); + }); + } return systemPreferences.getMediaAccessStatus?.("screen") ?? "unknown"; }); // macOS never re-prompts a denied permission β€” the only path is System -// Settings; deep-link straight to the right privacy pane. +// Settings; deep-link straight to the right privacy pane. Windows deep-links +// into the corresponding Settings page instead. ipcMain.handle("perm:open-settings", (_event, pane) => { - const panes = { - mic: "Privacy_Microphone", - screen: "Privacy_ScreenCapture", - speech: "Privacy_SpeechRecognition", + if (IS_MAC) { + const panes = { + mic: "Privacy_Microphone", + screen: "Privacy_ScreenCapture", + speech: "Privacy_SpeechRecognition", + }; + return shell.openExternal( + `x-apple.systempreferences:com.apple.preference.security?${panes[pane] ?? "Privacy"}`, + ); + } + const winPanes = { + mic: "ms-settings:privacy-microphone", + screen: "ms-settings:privacy-broadcasting", + speech: "ms-settings:privacy-speech", }; - return shell.openExternal( - `x-apple.systempreferences:com.apple.preference.security?${panes[pane] ?? "Privacy"}`, - ); + return shell.openExternal(winPanes[pane] ?? "ms-settings:privacy"); }); +// Native dictation is macOS-only (Swift + SFSpeechRecognizer); the IPC +// surface stays registered so the renderer's platform flag can rely on it. ipcMain.handle("speech:start", (event) => { + if (!IS_MAC) return; const win = BrowserWindow.fromWebContents(event.sender); if (win) startSpeech(win); }); -ipcMain.handle("speech:stop", () => stopSpeech()); +ipcMain.handle("speech:stop", () => { + if (IS_MAC) stopSpeech(); +}); app.whenReady().then(async () => { if (process.platform === "darwin") app.dock.setIcon(APP_ICON); diff --git a/electron/preload.cjs b/electron/preload.cjs index 0821e8a9b1..6a74648e1f 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -3,7 +3,9 @@ const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("ogb", { - /** One frame of this Mac's screen as a data: URL (Screen Recording TCC). */ + /** process.platform of the desktop host: darwin | win32 | linux. */ + platform: process.platform, + /** One frame of this computer's screen as a data: URL (macOS Screen Recording TCC / Windows desktop capture). */ screenFrame: () => ipcRenderer.invoke("screen:frame"), speechStart: () => ipcRenderer.invoke("speech:start"), speechStop: () => ipcRenderer.invoke("speech:stop"), diff --git a/electron/speech.mjs b/electron/speech.mjs index e71d19084d..f50c505d7d 100644 --- a/electron/speech.mjs +++ b/electron/speech.mjs @@ -27,6 +27,7 @@ function ensureBuilt() { } export function startSpeech(win) { + if (process.platform !== "darwin") return; // Swift/SFSpeechRecognizer helper is macOS-only stopSpeech(); ensureBuilt(); const proc = spawn(BIN, [], { stdio: ["ignore", "pipe", "pipe"] }); diff --git a/package.json b/package.json index 8b68dbe6fa..1344a9d8c2 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,14 @@ "build:server": "tsc -p tsconfig.server.build.json", "build:speech": "swiftc -O electron/resources/speech-helper.swift -o electron/resources/speech-helper", "package": "pnpm build && pnpm build:server && pnpm build:speech && pnpm build:perm && electron-builder --mac --publish never", - "build:perm": "swiftc -O electron/resources/perm-helper.swift -o electron/resources/perm-helper" + "build:perm": "swiftc -O electron/resources/perm-helper.swift -o electron/resources/perm-helper", + "package:win": "pnpm build && pnpm build:server && electron-builder --win --publish never" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "electron", + "esbuild" + ] }, "dependencies": { "@trycua/cua-driver": "^0.19.3", diff --git a/server/drivers/claude.ts b/server/drivers/claude.ts index b1f213d6d7..4b4017a122 100644 --- a/server/drivers/claude.ts +++ b/server/drivers/claude.ts @@ -8,8 +8,6 @@ // - Composio Connect (connected apps β†’ tools) over streamable HTTP // - the bot's cloud computer (box.ascii.dev) via server/computer-proxy.ts // β€” screenshot/exec/open_url, the CUA-on-the-box bridge -import { spawn } from "node:child_process"; -import { execFile } from "node:child_process"; import { existsSync, unlinkSync } from "node:fs"; import { createServer as createNetServer } from "node:net"; import { homedir } from "node:os"; @@ -28,6 +26,7 @@ import type { SendTurnInput, } from "../contracts.ts"; import { newEventId, newId } from "../contracts.ts"; +import { cliExec, cliVersion, killProcessTree, spawnCliHidden } from "./cli.ts"; import { appendNative } from "./native.ts"; const DRIVER_KIND = "claudeAgent"; @@ -91,6 +90,10 @@ function askSummary(ask: Ask): string { function permissionSocketPath(threadId: string) { const tag = threadId.replace(/[^\w-]/g, "").slice(0, 8); + // Windows has no unix sockets; Node maps a listen() path to a named pipe, + // and drive-letter paths (with ':') are invalid pipe names (EACCES). + // Use an explicit \\.\pipe\ name β€” both sides get the same string via argv. + if (process.platform === "win32") return `\\\\.\\pipe\\ogb-perm-${tag}`; return join(DATA_DIR, `perm-${tag}.sock`); } @@ -310,7 +313,7 @@ export const ClaudeDriver: ProviderDriver = { delete env.CLAUDECODE; delete env.CLAUDE_CODE_ENTRYPOINT; - const child = spawn(config.cli, args, { + const child = spawnCliHidden(config.cli, args, { cwd: turn.cwd ?? homedir(), env, stdio: ["pipe", "pipe", "pipe"], @@ -410,15 +413,7 @@ export const ClaudeDriver: ProviderDriver = { } }); - const stop = () => { - try { - process.kill(-child.pid!, "SIGTERM"); - } catch { - try { - child.kill("SIGTERM"); - } catch {} - } - }; + const stop = () => killProcessTree(child.pid); active.set(threadId, { stop, turnId, broker }); emit({ ...base(threadId, turnId), type: "turn.started" }); @@ -432,11 +427,7 @@ export const ClaudeDriver: ProviderDriver = { }; const snapshot = async (): Promise => { - const version = await new Promise((resolve) => { - execFile(config.cli, ["--version"], { timeout: 8000 }, (err, stdout) => - resolve(err ? null : stdout.trim()), - ); - }); + const version = await cliVersion(config.cli); if (!version) return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` }; const authenticated = existsSync(join(homedir(), ".claude", ".credentials.json")); return { state: "available", version, authenticated }; @@ -471,15 +462,15 @@ export const ClaudeDriver: ProviderDriver = { return () => listeners.delete(listener); }, }, - generateText: (prompt: string) => - new Promise((resolve, reject) => { - execFile( - config.cli, - ["-p", prompt, "--model", "claude-haiku-4-5", "--output-format", "text"], - { timeout: 60_000, env: { ...process.env } }, - (err, stdout) => (err ? reject(err) : resolve(stdout.trim())), - ); - }), + generateText: async (prompt: string) => { + const res = await cliExec( + config.cli, + ["-p", prompt, "--model", "claude-haiku-4-5", "--output-format", "text"], + { timeout: 60_000, env: { ...process.env } }, + ); + if (!res.ok) throw new Error(res.stderr || `\`${config.cli}\` failed`); + return res.stdout.trim(); + }, dispose: async () => { for (const { stop } of active.values()) stop(); listeners.clear(); diff --git a/server/drivers/cli.ts b/server/drivers/cli.ts new file mode 100644 index 0000000000..9f585ce652 --- /dev/null +++ b/server/drivers/cli.ts @@ -0,0 +1,245 @@ +// Windows CLI helpers. +// +// CLIs installed via npm/yarn/pnpm on Windows ship as .cmd batch shims +// (e.g. `codex.cmd` in %APPDATA%\npm). child_process cannot execute .cmd +// files directly β€” spawn/execFile fail with ENOENT unless the command runs +// through cmd.exe, and going through cmd.exe re-opens argv to its quoting +// and %VAR% expansion rules. Instead we resolve the shim to the real JS +// entry and run it with process.execPath β€” no shell at all. Native +// installers (the claude installer β†’ claude.exe) resolve to their .exe. +// +// All helpers are async (or spawn async children) β€” nothing here blocks +// the harness event loop. +import { execFile, spawn, spawnSync, type ChildProcessWithoutNullStreams, type SpawnOptions } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; + +const IS_WIN = process.platform === "win32"; +const SHIM_RE = /\.(cmd|bat)$/i; + +// `where` results don't change often; cache per CLI so per-turn spawns +// don't pay a fresh `where` process each time. +const whereCache = new Map(); +const WHERE_TTL = 60_000; + +/** Resolve a CLI name to a path that can actually be spawned. */ +export function resolveCli(cli: string): string { + if (!IS_WIN) return cli; + const hit = whereCache.get(cli); + if (hit && Date.now() - hit.at < WHERE_TTL) return hit.path; + let resolved = cli; + try { + const out = spawnSync("where", [cli], { encoding: "utf8", timeout: 5000, windowsHide: true }); + if (out.status === 0 && out.stdout) { + const candidates = out.stdout + .split(/\r?\n/) + .map((c) => c.trim()) + .filter(Boolean); + const exe = candidates.find((c) => /\.exe$/i.test(c)); + const shim = candidates.find((c) => SHIM_RE.test(c)); + resolved = exe ?? shim ?? cli; + } + } catch { + /* keep the raw name */ + } + whereCache.set(cli, { path: resolved, at: Date.now() }); + return resolved; +} + +// npm/pnpm/yarn .cmd shims are thin wrappers that exec node on a JS entry +// (e.g. `"%dp0%\node_modules\@openai\codex\bin\codex.js" %*`). Extract that +// entry so we can spawn process.execPath directly and skip cmd.exe. +function shimScriptTarget(shim: string): string | null { + try { + const text = readFileSync(shim, "utf8"); + const m = text.match(/"([^"]+\.(?:[cm]?js))"/); + if (!m) return null; + const raw = m[1].replace(/%(?:~)?dp0%/gi, dirname(shim) + "\\"); + if (raw.includes("%")) return null; // unknown var token β€” give up + return isAbsolute(raw) ? raw : resolve(dirname(shim), raw); + } catch { + return null; + } +} + +/** + * How to spawn a CLI on this platform: + * - POSIX: the raw name (resolved via PATH by the shell-less spawn). + * - Windows: the resolved .exe, or β€” for .cmd shims β€” node running the + * shim's real JS entry, which needs no cmd.exe at all. + * Falls back to the raw name when nothing better can be resolved. + */ +export function resolveCliCommand( + cli: string, +): { command: string; args: string[]; env?: Record } { + if (!IS_WIN) return { command: cli, args: [] }; + const resolved = resolveCli(cli); + if (SHIM_RE.test(resolved)) { + const script = shimScriptTarget(resolved); + if (script) { + return { + command: process.execPath, + args: [script], + // packaged: process.execPath is the Electron binary; run as plain node + env: { ELECTRON_RUN_AS_NODE: "1" }, + }; + } + } + return { command: resolved, args: [] }; +} + +/** execFile equivalent that also works for .cmd shims on Windows. */ +export function cliExec( + cli: string, + args: string[], + opts: { timeout?: number; env?: NodeJS.ProcessEnv } = {}, +): Promise<{ ok: boolean; stdout: string; stderr: string }> { + const { command, args: prefix, env: runEnv } = resolveCliCommand(cli); + return new Promise((resolvePromise) => { + const execOpts = { + timeout: opts.timeout, + env: { ...opts.env, ...runEnv }, + windowsHide: true, + }; + const cb = (err: Error | null, stdout: string, stderr: string) => + resolvePromise({ ok: !err, stdout, stderr: stderr ?? "" }); + // last-resort: a .cmd shim we couldn't unwrap runs through cmd.exe + if (IS_WIN && SHIM_RE.test(command)) { + execFile(command, [...prefix, ...args], { ...execOpts, shell: true }, cb); + } else { + execFile(command, [...prefix, ...args], execOpts, cb); + } + }); +} + +/** `cli --version` probe; null when the CLI is missing or errors. */ +export function cliVersion(cli: string, timeoutMs = 8000): Promise { + return cliExec(cli, ["--version"], { timeout: timeoutMs }).then((r) => + r.ok && r.stdout.trim() ? r.stdout.trim() : null, + ); +} + +/** + * Kill a spawned CLI and its whole process tree (child MCP servers, the + * codex app-server worker, etc.). POSIX uses the process group + * (children are spawned detached); Windows uses taskkill /T /F because + * process.kill(-pid) throws ESRCH and plain kill() leaves orphans. + */ +export function killProcessTree(pid: number | undefined): void { + if (!pid) return; + if (IS_WIN) { + try { + spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { + windowsHide: true, + timeout: 5000, + }); + } catch { + /* already gone */ + } + return; + } + try { + process.kill(-pid, "SIGTERM"); + } catch { + try { + process.kill(pid, "SIGTERM"); + } catch { + /* already gone */ + } + } +} + +// PowerShell wrapper that runs a CLI with a hidden console. windowsHide on +// the direct spawn would give the CLI NO console β€” then every console-app +// it spawns (cmd.exe for device-id probes, MCP servers like cua-driver.exe) +// would create its own VISIBLE console window. A hidden console instead is +// inherited by the whole subtree, so nothing ever flashes. Args travel in a +// JSON file, so there is no cmd/PowerShell quoting hazard. +const PS_HIDDEN_WRAPPER = `param([string]$Cli, [string]$ArgsFile) +$ErrorActionPreference = 'Stop' +$ArgList = @(Get-Content -Raw -LiteralPath $ArgsFile | ConvertFrom-Json) +& $Cli @ArgList +exit $LASTEXITCODE +`; + +// PowerShell 5.1 (built into Windows) mangles native args that contain +// embedded quotes β€” fatal for --mcp-config JSON. PowerShell 7 (pwsh) +// passes argv correctly, so prefer it and fall back to a plain +// windowsHide spawn (direct child hidden; grandchildren may flash). +function resolvePwsh(): string | null { + const candidates = [ + join(process.env.ProgramFiles ?? "C:\\Program Files", "PowerShell", "7", "pwsh.exe"), + ...(process.env.ProgramW6432 ? [join(process.env.ProgramW6432, "PowerShell", "7", "pwsh.exe")] : []), + ]; + for (const c of candidates) { + if (existsSync(c)) return c; + } + return null; +} + +/** + * Spawn a CLI so that no console window ever appears on Windows β€” including + * for anything the CLI itself spawns. POSIX: plain spawn (no-op). + * Callers pass stdio pipes (["pipe","pipe","pipe"]) and get a child with + * live stdout/stderr streams. + */ +export function spawnCliHidden( + cli: string, + args: string[], + opts: SpawnOptions & { env?: NodeJS.ProcessEnv }, +): ChildProcessWithoutNullStreams { + const { command, args: prefix, env: runEnv } = resolveCliCommand(cli); + if (!IS_WIN) { + return spawn(command, [...prefix, ...args], opts) as ChildProcessWithoutNullStreams; + } + // `detached` is a POSIX-only need here (killProcessTree uses the process + // group). On Windows it maps to DETACHED_PROCESS β€” the child gets NO + // console, and pwsh then exits 0 immediately without running the script + // or writing a byte, which surfaces as "cli exited 0 before result". + // Windows reaps the tree with taskkill /T /F, so drop the flag. + const { detached: _detached, ...winOpts } = opts; + const pwsh = resolvePwsh(); + if (!pwsh) { + // no pwsh: plain spawn with the direct child hidden (grandchildren may + // flash their own consoles β€” acceptable degradation) + return spawn(command, [...prefix, ...args], { + ...winOpts, + env: { ...opts.env, ...runEnv }, + windowsHide: true, + }) as ChildProcessWithoutNullStreams; + } + // NOTE: no windowsHide here β€” PowerShell must keep a (hidden) console so + // the CLI and its console descendants attach to it instead of flashing. + const dir = mkdtempSync(join(tmpdir(), "omb-spawn-")); + const argsFile = join(dir, "args.json"); + const wrapper = join(dir, "wrap.ps1"); + writeFileSync(argsFile, JSON.stringify([...prefix, ...args])); + writeFileSync(wrapper, PS_HIDDEN_WRAPPER); + const child = spawn( + pwsh, + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-WindowStyle", + "Hidden", + "-File", + wrapper, + "-Cli", + command, + "-ArgsFile", + argsFile, + ], + { ...winOpts, env: { ...opts.env, ...runEnv } }, + ) as ChildProcessWithoutNullStreams; + child.once("close", () => { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + /* best-effort cleanup */ + } + }); + return child; +} diff --git a/server/drivers/codex.ts b/server/drivers/codex.ts index 4165958933..6ca260dddc 100644 --- a/server/drivers/codex.ts +++ b/server/drivers/codex.ts @@ -9,7 +9,6 @@ // // resumeCursor is the codex thread id; a later turn tries thread/resume // and falls back to a fresh thread/start. -import { spawn, execFile } from "node:child_process"; import { homedir } from "node:os"; import type { @@ -23,6 +22,7 @@ import type { } from "../contracts.ts"; import { newEventId, newId } from "../contracts.ts"; import { appendNative } from "./native.ts"; +import { cliVersion, killProcessTree, spawnCliHidden } from "./cli.ts"; const DRIVER_KIND = "codex"; @@ -91,7 +91,7 @@ export const CodexDriver: ProviderDriver = { // billing to pay-as-you-go (agentcal) delete env.OPENAI_API_KEY; - const child = spawn(config.cli, ["app-server"], { + const child = spawnCliHidden(config.cli, ["app-server"], { cwd: turn.cwd ?? homedir(), env, stdio: ["pipe", "pipe", "pipe"], @@ -116,15 +116,7 @@ export const CodexDriver: ProviderDriver = { send({ jsonrpc: "2.0", id, method, params }); }); - const stop = () => { - try { - process.kill(-child.pid!, "SIGTERM"); - } catch { - try { - child.kill("SIGTERM"); - } catch {} - } - }; + const stop = () => killProcessTree(child.pid); const settle = (ok: boolean, stopReason: string | null) => { if (state.settled) return; @@ -357,11 +349,7 @@ export const CodexDriver: ProviderDriver = { }; const snapshot = async (): Promise => { - const version = await new Promise((resolve) => { - execFile(config.cli, ["--version"], { timeout: 8000 }, (err, stdout) => - resolve(err ? null : stdout.trim()), - ); - }); + const version = await cliVersion(config.cli); if (!version) return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` }; return { state: "available", version }; }; diff --git a/server/index.ts b/server/index.ts index 3bd92f5d15..f08eb3bae1 100644 --- a/server/index.ts +++ b/server/index.ts @@ -200,13 +200,27 @@ function stopScreenPoller(botId: string): Frame | null { } // Local computer-use contract written by Electron main on startup -// (~/Library/Application Support/OpenMausBot/cua-connection.json). Read -// fresh each turn β€” Electron may restart or permissions may change. -function readCuaConnection(): { command: string; args: string[]; env: Record } | null { +// (app.getPath("userData")/cua-connection.json β€” Electron main passes the +// exact path via OMB_USER_DATA; a standalone dev server falls back to +// per-platform userData locations). Read fresh each turn β€” Electron may +// restart or permissions may change. +function cuaConnectionCandidates(): string[] { + const explicit = process.env.OMB_USER_DATA; + if (explicit) return [join(explicit, "cua-connection.json")]; + const roots = + process.platform === "win32" + ? [process.env.APPDATA ?? join(homedir(), "AppData", "Roaming")] + : process.platform === "darwin" + ? [join(homedir(), "Library", "Application Support")] + : [join(homedir(), ".config")]; // new name first; pre-rename desktop builds used the old directory - for (const dir of ["OpenMausBot", "openmausbot", "OpenGrokBot", "opengrokbot"]) { + const dirs = ["OpenMausBot", "openmausbot", "OpenGrokBot", "opengrokbot"]; + return roots.flatMap((root) => dirs.map((dir) => join(root, dir, "cua-connection.json"))); +} + +function readCuaConnection(): { command: string; args: string[]; env: Record } | null { + for (const p of cuaConnectionCandidates()) { try { - const p = join(homedir(), "Library", "Application Support", dir, "cua-connection.json"); const conn = JSON.parse(readFileSync(p, "utf8")); if (!conn || conn.mode === "unavailable" || !conn.mcpCommand) continue; return { command: conn.mcpCommand, args: conn.mcpArgs ?? ["mcp"], env: conn.mcpEnv ?? {} }; diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index d0a61b4065..ef96696d9a 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -9,6 +9,9 @@ export function Composer({ bot }: { bot: Bot }) { const [text, setText] = useState(""); const [recording, setRecording] = useState(false); const [speechError, setSpeechError] = useState(null); + // native dictation (Swift/SFSpeechRecognizer) is macOS-only; hide the mic + // button elsewhere (browser dev keeps it β€” it explains how to run the app) + const showMic = !window.ogb || window.ogb.platform === "darwin"; // what was typed before the mic went on β€” partials append after it const baseText = useRef(""); @@ -94,7 +97,7 @@ export function Composer({ bot }: { bot: Bot }) { > - ) : ( + ) : showMic && ( + ) : ( + + )} - {perms?.mic === "granted" ? ( - - ) : perms?.mic === "denied" || perms?.mic === "restricted" ? ( - - ) : ( - - )} - -
-
- -
-
Screen preview
-
- Shows this Mac’s screen in the Computer panel when a bot works locally. +
+
+ +
+
Screen preview
+
+ Shows this computer’s screen in the Computer panel when a bot works locally. +
+ {perms?.screen === "granted" ? ( + + ) : perms?.screen === "denied" || perms?.screen === "restricted" ? ( + + ) : ( + + )}
- {perms?.screen === "granted" ? ( - - ) : perms?.screen === "denied" || perms?.screen === "restricted" ? ( - - ) : ( - - )}
-
+ ) : ( +
+ On this OS, permissions are handled by the system β€” you’ll be + prompted the first time a bot needs the microphone or your screen. +
+ )} diff --git a/src/types/ogb.d.ts b/src/types/ogb.d.ts index 50374db078..b1d74a5377 100644 --- a/src/types/ogb.d.ts +++ b/src/types/ogb.d.ts @@ -4,6 +4,8 @@ export {}; declare global { interface Window { ogb?: { + /** process.platform of the desktop host: darwin | win32 | linux. */ + platform: string; screenFrame(): Promise; speechStart(): Promise; speechStop(): Promise;