@@ -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 @@
// Something else is using its ports. Quit and reopen OpenMausBot β if it keeps happening, restart your Mac.
Something else is using its ports. Quit and reopen OpenMausBot β if it keeps happening, restart your computer.