Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ dist-electron
.DS_Store
*.tsbuildinfo
electron/resources/speech-helper
electron/resources/perm-helper
build/icon.ico
release
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,10 @@ 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),
[`codex`](https://github.com/openai/codex), or [`grok`](https://x.ai/cli) — installed and logged in. They appear
in the model picker automatically.
in the model picker automatically. On Windows, the CLIs are the npm
`.cmd` shims; the server resolves and spawns them automatically.

Optional, pasted once in **App Settings** (gear in the sidebar footer):

Expand All @@ -190,11 +191,25 @@ pnpm typecheck # app + server
pnpm build # typecheck + production build
```

## Packaging

```sh
pnpm package # macOS → release/OpenMausBot-<version>.dmg (requires Swift + Xcode)
pnpm package:win # Windows → release/OpenMausBot-<version>-x64.exe (NSIS installer)
```

Both produce a `win-unpacked`/`OpenMausBot.app` under `release/` for testing before the installer.
Windows builds use the generated `build/icon.ico` (`pnpm make:ico`) and bundle the PowerShell
speech helper — no native toolchain needed. The macOS helpers (Swift speech + screen permission) are
no-ops on Windows, and vice versa.

## 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).
built yet, and Linux shells haven't been attempted (the harness itself is portable Node). Windows is
supported; voice dictation uses on-device Windows speech recognition, and computer use requires the
`cua-driver` CLI on PATH.

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.
Expand Down
62 changes: 62 additions & 0 deletions build/make-ico.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Generates build/icon.ico (multi-size, PNG-compressed) from the 1024px PNG
# source so electron-builder can package the Windows app.
Add-Type -AssemblyName System.Drawing

$srcPath = Join-Path $PSScriptRoot "icon-1024.png"
$outPath = Join-Path $PSScriptRoot "icon.ico"

$src = [System.Drawing.Image]::FromFile($srcPath)
$sizes = @(16, 24, 32, 48, 64, 128, 256)

$stream = New-Object System.IO.MemoryStream
$writer = New-Object System.IO.BinaryWriter($stream)

# ICONDIR
$writer.Write([UInt16]0)
$writer.Write([UInt16]1)
$writer.Write([UInt16]$sizes.Count)

$images = @()
$offset = 6 + (16 * $sizes.Count)

foreach ($size in $sizes) {
$bmp = New-Object System.Drawing.Bitmap($size, $size)
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
$g.DrawImage($src, 0, 0, $size, $size)
$g.Dispose()

$pngMs = New-Object System.IO.MemoryStream
$bmp.Save($pngMs, [System.Drawing.Imaging.ImageFormat]::Png)
$bmp.Dispose()
$images += , @{ size = $size; data = $pngMs.ToArray() }
$pngMs.Dispose()
}

foreach ($img in $images) {
# ICONDIRENTRY — width/height 0 means 256
$w = if ($img.size -ge 256) { 0 } else { $img.size }
$writer.Write([Byte]$w)
$writer.Write([Byte]$w)
$writer.Write([Byte]0) # palette
$writer.Write([Byte]0) # reserved
$writer.Write([UInt16]1) # planes
$writer.Write([UInt16]32) # bpp
$writer.Write([UInt32]$img.data.Length)
$writer.Write([UInt32]$offset)
$offset += $img.data.Length
}

foreach ($img in $images) {
$writer.Write($img.data)
}

$writer.Flush()
[System.IO.File]::WriteAllBytes($outPath, $stream.ToArray())
$writer.Dispose()
$stream.Dispose()
$src.Dispose()

Write-Output "Wrote $outPath ($($images.Count) sizes)"
121 changes: 121 additions & 0 deletions dist-server/cli-util.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Cross-platform CLI launching. On Windows, npm-installed agent CLIs
// (`claude`, `codex`) are `.cmd` shims that CreateProcess can't run
// directly (Node throws EINVAL). This module resolves the shim to a real
// file and, when it's a batch file, spawns it through cmd.exe with proper
// command-line quoting so complex args (MCP JSON configs, prompts with
// spaces) survive intact.
import { execFile, spawn, spawnSync, } from "node:child_process";
import { existsSync } from "node:fs";
import { delimiter, isAbsolute, join } from "node:path";
export const isWindows = process.platform === "win32";
const WIN_PATHEXT = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
.split(";")
.filter(Boolean)
.map((e) => e.toLowerCase());
/** CommandLineToArgvW-style quoting, the rules CreateProcess/cmd expect. */
export function winQuote(arg) {
if (arg.length === 0)
return '""';
if (!/[ \t\n\v"]/.test(arg))
return arg;
let s = '"';
let bs = 0;
for (const ch of arg) {
if (ch === "\\")
bs++;
else if (ch === '"') {
s += "\\".repeat(bs * 2 + 1) + '"';
bs = 0;
}
else {
s += "\\".repeat(bs);
bs = 0;
s += ch;
}
}
s += "\\".repeat(bs * 2) + '"';
return s;
}
/** Resolve a bare CLI name (or relative/absolute path) to a real file. */
export function resolveCli(cli) {
if (!isWindows)
return cli;
if (isAbsolute(cli) || cli.includes("\\") || cli.includes("/"))
return cli;
for (const dir of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) {
for (const ext of WIN_PATHEXT) {
const p = join(dir, cli + ext);
if (existsSync(p))
return p;
}
}
return cli;
}
export function spawnCli(cli, args, opts = {}) {
if (!isWindows)
return spawn(cli, args, opts);
const file = resolveCli(cli);
const lower = file.toLowerCase();
if (lower.endsWith(".cmd") || lower.endsWith(".bat")) {
const inner = `${winQuote(file)} ${args.map(winQuote).join(" ")}`;
return spawn(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `"${inner}"`], {
...opts,
windowsVerbatimArguments: true,
});
}
return spawn(file, args, opts);
}
/** execFile equivalent that survives .cmd shims on Windows. */
export function execFileCli(cli, args, opts = {}, cb) {
if (!isWindows) {
execFile(cli, args, opts, cb);
return;
}
const child = spawnCli(cli, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: opts.env });
let out = "";
let err = "";
let done = false;
const finish = (e) => {
if (done)
return;
done = true;
clearTimeout(timer);
cb(e, out, err);
};
child.stdout?.on("data", (d) => (out += d));
child.stderr?.on("data", (d) => (err += d));
child.on("error", (e) => finish(e));
child.on("close", (code) => finish(code ? new Error(`exit ${code}`) : null));
const timer = setTimeout(() => {
try {
child.kill();
}
catch { }
finish(new Error("ETIMEDOUT"));
}, opts.timeout ?? 8000);
}
/** Kill a process and its descendants. POSIX: negative-PID group signal;
* Windows: taskkill tree. Falls back to a plain kill. */
export function killProcessTree(pid) {
if (isWindows) {
try {
spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { windowsHide: true });
return;
}
catch { }
try {
process.kill(pid, "SIGTERM");
}
catch { }
return;
}
try {
process.kill(-pid, "SIGTERM");
}
catch {
try {
process.kill(pid, "SIGTERM");
}
catch { }
}
}
24 changes: 16 additions & 8 deletions dist-server/drivers/claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,12 @@
// - 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";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { isWindows, execFileCli, killProcessTree, spawnCli } from "../cli-util.js";
import { DATA_DIR } from "../config.js";
import { newEventId, newId } from "../contracts.js";
import { appendNative } from "./native.js";
Expand Down Expand Up @@ -56,7 +55,10 @@ function askSummary(ask) {
}
function permissionSocketPath(threadId) {
const tag = threadId.replace(/[^\w-]/g, "").slice(0, 8);
return join(DATA_DIR, `perm-${tag}.sock`);
// Windows can't bind a unix socket at a filesystem path — named pipes it is
return isWindows
? `\\\\.\\pipe\\openmausbot-perm-${tag}`
: join(DATA_DIR, `perm-${tag}.sock`);
}
function createPermissionBroker(opts) {
const timeoutMs = opts.timeoutMs ?? 15 * 60_000;
Expand Down Expand Up @@ -272,11 +274,16 @@ export const ClaudeDriver = {
delete env.ANTHROPIC_API_KEY;
delete env.CLAUDECODE;
delete env.CLAUDE_CODE_ENTRYPOINT;
const child = spawn(config.cli, args, {
const child = spawnCli(config.cli, args, {
cwd: turn.cwd ?? homedir(),
env,
stdio: ["pipe", "pipe", "pipe"],
detached: true, // own process group: killing -pid reaps child MCP servers
// POSIX: own process group so killing -pid reaps child MCP servers.
// Windows: never detach — it gives the child its own console (a
// flashing window) and severs the piped stdout/stderr the driver
// reads (verified: claude exits 1 with no output). Tree-kill there
// is `taskkill /T`, which needs no process group.
detached: !isWindows,
});
let settled = false;
const settle = (ok, stopReason, cost = null) => {
Expand Down Expand Up @@ -372,7 +379,8 @@ export const ClaudeDriver = {
});
const stop = () => {
try {
process.kill(-child.pid, "SIGTERM");
if (child.pid)
killProcessTree(child.pid);
}
catch {
try {
Expand All @@ -392,7 +400,7 @@ export const ClaudeDriver = {
};
const snapshot = async () => {
const version = await new Promise((resolve) => {
execFile(config.cli, ["--version"], { timeout: 8000 }, (err, stdout) => resolve(err ? null : stdout.trim()));
execFileCli(config.cli, ["--version"], { timeout: 8000 }, (err, stdout) => resolve(err ? null : stdout.trim()));
});
if (!version)
return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` };
Expand Down Expand Up @@ -431,7 +439,7 @@ export const ClaudeDriver = {
},
},
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())));
execFileCli(config.cli, ["-p", prompt, "--model", "claude-haiku-4-5", "--output-format", "text"], { timeout: 60_000 }, (err, stdout) => (err ? reject(err) : resolve(stdout.trim())));
}),
dispose: async () => {
for (const { stop } of active.values())
Expand Down
13 changes: 8 additions & 5 deletions dist-server/drivers/codex.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
//
// 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 { execFileCli, isWindows, killProcessTree, spawnCli } from "../cli-util.js";
import { newEventId, newId } from "../contracts.js";
import { appendNative } from "./native.js";
const DRIVER_KIND = "codex";
Expand Down Expand Up @@ -62,11 +62,13 @@ 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 = spawnCli(config.cli, ["app-server"], {
cwd: turn.cwd ?? homedir(),
env,
stdio: ["pipe", "pipe", "pipe"],
detached: true,
// see claude.ts — detached on Windows spawns a console and severs
// the piped stdio this driver speaks RPC over
detached: !isWindows,
});
const state = { settled: false, lastText: "" };
const asks = new Map();
Expand All @@ -86,7 +88,8 @@ export const CodexDriver = {
});
const stop = () => {
try {
process.kill(-child.pid, "SIGTERM");
if (child.pid)
killProcessTree(child.pid);
}
catch {
try {
Expand Down Expand Up @@ -329,7 +332,7 @@ export const CodexDriver = {
};
const snapshot = async () => {
const version = await new Promise((resolve) => {
execFile(config.cli, ["--version"], { timeout: 8000 }, (err, stdout) => resolve(err ? null : stdout.trim()));
execFileCli(config.cli, ["--version"], { timeout: 8000 }, (err, stdout) => resolve(err ? null : stdout.trim()));
});
if (!version)
return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` };
Expand Down
Loading