diff --git a/.gitignore b/.gitignore index b4493f2b6..06fbc4481 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ dist-electron .DS_Store *.tsbuildinfo electron/resources/speech-helper +electron/resources/perm-helper +build/icon.ico release diff --git a/README.md b/README.md index 41e339461..25aae5b9d 100644 --- a/README.md +++ b/README.md @@ -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): @@ -190,11 +191,25 @@ pnpm typecheck # app + server pnpm build # typecheck + production build ``` +## Packaging + +```sh +pnpm package # macOS → release/OpenMausBot-.dmg (requires Swift + Xcode) +pnpm package:win # Windows → release/OpenMausBot--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. diff --git a/build/make-ico.ps1 b/build/make-ico.ps1 new file mode 100644 index 000000000..093c7ca79 --- /dev/null +++ b/build/make-ico.ps1 @@ -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)" diff --git a/dist-server/cli-util.js b/dist-server/cli-util.js new file mode 100644 index 000000000..b9e12218f --- /dev/null +++ b/dist-server/cli-util.js @@ -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 { } + } +} diff --git a/dist-server/drivers/claude.js b/dist-server/drivers/claude.js index ee4c5c9c0..9ac830206 100644 --- a/dist-server/drivers/claude.js +++ b/dist-server/drivers/claude.js @@ -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"; @@ -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; @@ -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) => { @@ -372,7 +379,8 @@ export const ClaudeDriver = { }); const stop = () => { try { - process.kill(-child.pid, "SIGTERM"); + if (child.pid) + killProcessTree(child.pid); } catch { try { @@ -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` }; @@ -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()) diff --git a/dist-server/drivers/codex.js b/dist-server/drivers/codex.js index 2963d78b6..00a2f3991 100644 --- a/dist-server/drivers/codex.js +++ b/dist-server/drivers/codex.js @@ -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"; @@ -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(); @@ -86,7 +88,8 @@ export const CodexDriver = { }); const stop = () => { try { - process.kill(-child.pid, "SIGTERM"); + if (child.pid) + killProcessTree(child.pid); } catch { try { @@ -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` }; diff --git a/dist-server/index.js b/dist-server/index.js index e15f26d80..da602222f 100644 --- a/dist-server/index.js +++ b/dist-server/index.js @@ -11,6 +11,8 @@ import { ensureDirs, instanceConfigs, loadConfig, saveConfig, EVENTS_DIR, NATIVE import { BUILT_IN_DRIVERS } from "./drivers/builtIn.js"; import { EventBus } from "./harness/bus.js"; import { ProviderRegistry } from "./harness/registry.js"; +import { MacroStore } from "./macros.js"; +import { RoutineStore } from "./routines.js"; import { Store } from "./store.js"; const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799); const STATIC_DIR = process.env.OMB_STATIC_DIR || null; @@ -41,6 +43,8 @@ let bootSelection = { instanceId: "claude", model: "claude-sonnet-5" }; const store = new Store(() => bootSelection); bootSelection = await defaultSelection(); store.seedIfEmpty(); +const routines = new RoutineStore(); +const macros = new MacroStore(); // ── SSE fan-out to clients ───────────────────────────────────────────── const sseClients = new Set(); function broadcast(payload) { @@ -190,13 +194,21 @@ 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. +// (macOS: ~/Library/Application Support/OpenMausBot/cua-connection.json; +// Windows: %APPDATA%\OpenMausBot\cua-connection.json). Read fresh each +// turn — Electron may restart or permissions may change. +function cuaConnectionRoots() { + const names = ["OpenMausBot", "openmausbot", "OpenGrokBot", "opengrokbot"]; + if (process.platform === "win32") { + const base = process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"); + return names.map((dir) => join(base, dir)); + } + return names.map((dir) => join(homedir(), "Library", "Application Support", dir)); +} function readCuaConnection() { - // new name first; pre-rename desktop builds used the old directory - for (const dir of ["OpenMausBot", "openmausbot", "OpenGrokBot", "opengrokbot"]) { + for (const dir of cuaConnectionRoots()) { try { - const p = join(homedir(), "Library", "Application Support", dir, "cua-connection.json"); + const p = join(dir, "cua-connection.json"); const conn = JSON.parse(readFileSync(p, "utf8")); if (!conn || conn.mode === "unavailable" || !conn.mcpCommand) continue; @@ -310,6 +322,30 @@ async function reloadProviders() { await registry.load(instanceConfigs(cfg)); bus.attach(registry.instances()); } +// ── routine scheduler ───────────────────────────────────────────────── +// One central tick walks every due routine and dispatches its prompt to +// the owning bot exactly like a user message (startTurn handles busy, +// computer wiring, screen polling, SSE). Runs are debounced/backed off so +// a failing routine retries rather than hot-looping the bot. +setInterval(async () => { + for (const routine of routines.due()) { + const bot = store.bot(routine.botId); + if (!bot) { + routines.remove(routine.id); + continue; + } + if (bot.busy) { + routines.postpone(routine.id); + continue; + } + routines.markRun(routine.id); + broadcast({ + kind: "routine", + routine: { _id: routine.id, botId: routine.botId, name: routine.name, lastRunAt: routine.lastRunAt }, + }); + await startTurn(routine.botId, routine.prompt).catch(() => routines.postpone(routine.id)); + } +}, 30_000); // ── HTTP plumbing ───────────────────────────────────────────────────── function json(res, status, body) { const data = JSON.stringify(body); @@ -463,6 +499,84 @@ const server = createServer(async (req, res) => { if (method === "GET" && path === "/api/health") { return json(res, 200, { app: "openmausbot", pid: process.pid, static: Boolean(STATIC_DIR) }); } + // ── routines ── + if (method === "GET" && path === "/api/routines") { + return json(res, 200, { routines: routines.all() }); + } + if (method === "POST" && path === "/api/routines") { + const body = await readBody(req); + if (!body.botId || !store.bot(body.botId)) + return json(res, 404, { error: "no such bot" }); + const routine = routines.create({ + botId: body.botId, + name: body.name, + prompt: body.prompt, + everyMinutes: body.everyMinutes, + enabled: body.enabled !== false, + }); + broadcast({ kind: "routine", routine }); + return json(res, 201, { routine }); + } + m = path.match(/^\/api\/routines\/([\w-]+)$/); + if (m && method === "PATCH") { + const body = await readBody(req); + if (body.botId && !store.bot(body.botId)) + return json(res, 404, { error: "no such bot" }); + const routine = routines.patch(m[1], { + ...(body.name !== undefined ? { name: body.name } : {}), + ...(body.prompt !== undefined ? { prompt: body.prompt } : {}), + ...(body.everyMinutes !== undefined ? { everyMinutes: body.everyMinutes } : {}), + ...(body.enabled !== undefined ? { enabled: body.enabled } : {}), + }); + if (!routine) + return json(res, 404, { error: "no such routine" }); + broadcast({ kind: "routine", routine }); + return json(res, 200, { routine }); + } + m = path.match(/^\/api\/routines\/([\w-]+)$/); + if (m && method === "DELETE") { + if (!routines.remove(m[1])) + return json(res, 404, { error: "no such routine" }); + broadcast({ kind: "routine.deleted", id: m[1] }); + return json(res, 200, { ok: true }); + } + m = path.match(/^\/api\/routines\/([\w-]+)\/run$/); + if (m && method === "POST") { + const routine = routines.get(m[1]); + if (!routine) + return json(res, 404, { error: "no such routine" }); + const bot = store.bot(routine.botId); + if (!bot) + return json(res, 404, { error: "no such bot" }); + if (bot.busy) + return json(res, 409, { error: "the bot is already working — interrupt it first" }); + routines.markRun(routine.id); + broadcast({ kind: "routine", routine: { ...routine, lastRunAt: Date.now() } }); + await startTurn(routine.botId, routine.prompt).catch((e) => { + routines.postpone(routine.id); + return json(res, e?.status ?? 500, { error: e?.message ?? String(e) }); + }); + return json(res, 202, { ok: true }); + } + // ── macros (recorded input sequences; replay runs in Electron main) ── + if (method === "GET" && path === "/api/macros") { + return json(res, 200, { macros: macros.all() }); + } + if (method === "POST" && path === "/api/macros") { + const body = await readBody(req); + if (!body.botId || !store.bot(body.botId)) + return json(res, 404, { error: "no such bot" }); + const macro = macros.create(body.botId, body.name, body.actions); + broadcast({ kind: "macro", macro }); + return json(res, 201, { macro }); + } + m = path.match(/^\/api\/macros\/([\w-]+)$/); + if (m && method === "DELETE") { + if (!macros.remove(m[1])) + return json(res, 404, { error: "no such macro" }); + broadcast({ kind: "macro.deleted", id: m[1] }); + return json(res, 200, { ok: true }); + } // ── provider instances (model picker) ── if (method === "GET" && path === "/api/instances") { return json(res, 200, { instances: await registry.describe() }); diff --git a/dist-server/macros.js b/dist-server/macros.js new file mode 100644 index 000000000..7a6369764 --- /dev/null +++ b/dist-server/macros.js @@ -0,0 +1,58 @@ +// Macros — recorded input sequences, persisted to ~/.openmausbot/macros.json. +// A macro is an array of {t, type, ...} actions exactly as emitted by the +// Electron recorder; replay happens in Electron main (SendInput) and is +// triggered from the renderer. The server owns storage + listing only. +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { DATA_DIR } from "./config.js"; +import { newId } from "./contracts.js"; +const MACROS_FILE = join(DATA_DIR, "macros.json"); +export class MacroStore { + macros = []; + constructor() { + mkdirSync(DATA_DIR, { recursive: true }); + try { + const raw = JSON.parse(readFileSync(MACROS_FILE, "utf8")); + this.macros = Array.isArray(raw) ? raw : []; + } + catch { + this.macros = []; + } + } + save() { + writeFileSync(MACROS_FILE, JSON.stringify(this.macros, null, 2)); + } + all() { + return this.macros; + } + forBot(botId) { + return this.macros.filter((m) => m.botId === botId); + } + get(id) { + return this.macros.find((m) => m.id === id) ?? null; + } + create(botId, name, actions) { + if (!Array.isArray(actions) || !actions.length) { + throw Object.assign(new Error("no recorded actions"), { status: 400 }); + } + const macro = { + id: newId(), + botId, + name: String(name ?? "").trim() || "Untitled macro", + actions, + durationMs: Math.max(0, actions[actions.length - 1]?.t ?? 0), + createdAt: Date.now(), + }; + this.macros.unshift(macro); + this.save(); + return macro; + } + remove(id) { + const before = this.macros.length; + this.macros = this.macros.filter((m) => m.id !== id); + const removed = this.macros.length !== before; + if (removed) + this.save(); + return removed; + } +} diff --git a/dist-server/routines.js b/dist-server/routines.js new file mode 100644 index 000000000..629a1b873 --- /dev/null +++ b/dist-server/routines.js @@ -0,0 +1,114 @@ +// Routines — recurring tasks an agent runs on a schedule. Persisted to +// ~/.openmausbot/routines.json; the scheduler ticks from server/index.ts +// and hands each due routine to startTurn() like a normal user message. +// A routine is scoped to one bot and is just a prompt + an interval: +// every N minutes the bot runs that prompt on its configured computer. +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { DATA_DIR } from "./config.js"; +import { newId } from "./contracts.js"; +const ROUTINES_FILE = join(DATA_DIR, "routines.json"); +export class RoutineStore { + routines = []; + constructor() { + mkdirSync(DATA_DIR, { recursive: true }); + try { + const raw = JSON.parse(readFileSync(ROUTINES_FILE, "utf8")); + this.routines = Array.isArray(raw) ? raw : []; + } + catch { + this.routines = []; + } + for (const r of this.routines) { + // normalize + seed nextDueAt (now) so a fresh routine doesn't fire + // immediately after a restart unless its interval has truly elapsed + r.everyMinutes = Math.max(1, Math.floor(Number(r.everyMinutes) || 60)); + r.nextDueAt = r.lastRunAt ? r.lastRunAt + r.everyMinutes * 60_000 : Date.now(); + } + } + save() { + writeFileSync(ROUTINES_FILE, JSON.stringify(this.routines, null, 2)); + } + all() { + return this.routines.map(({ nextDueAt, ...r }) => ({ ...r, lastRunAt: r.lastRunAt ?? null })); + } + get(id) { + const r = this.routines.find((r) => r.id === id); + if (!r) + return null; + const { nextDueAt: _remove, ...rest } = r; + return rest; + } + forBot(botId) { + return this.routines.filter((r) => r.botId === botId).map(({ nextDueAt: _remove, ...r }) => r); + } + create(input) { + const routine = { + id: newId(), + botId: input.botId, + name: String(input.name ?? "").trim() || "Untitled routine", + prompt: String(input.prompt ?? "").trim(), + everyMinutes: Math.max(1, Math.floor(Number(input.everyMinutes) || 60)), + enabled: input.enabled !== false, + createdAt: Date.now(), + lastRunAt: null, + nextDueAt: Date.now(), + }; + if (!routine.prompt) + throw Object.assign(new Error("prompt is required"), { status: 400 }); + this.routines.push(routine); + this.save(); + return this.get(routine.id); + } + patch(id, patch) { + const r = this.routines.find((r) => r.id === id); + if (!r) + return null; + if (patch.name !== undefined) + r.name = String(patch.name).trim() || r.name; + if (patch.prompt !== undefined) { + const p = String(patch.prompt).trim(); + if (!p) + throw Object.assign(new Error("prompt is required"), { status: 400 }); + r.prompt = p; + } + if (patch.everyMinutes !== undefined) + r.everyMinutes = Math.max(1, Math.floor(Number(patch.everyMinutes) || r.everyMinutes)); + if (patch.enabled !== undefined) + r.enabled = Boolean(patch.enabled); + if (patch.botId !== undefined) + r.botId = patch.botId; + this.save(); + return this.get(id); + } + remove(id) { + const before = this.routines.length; + this.routines = this.routines.filter((r) => r.id !== id); + const removed = this.routines.length !== before; + if (removed) + this.save(); + return removed; + } + /** Hand the scheduler the routines due right now. Resets each one's + * next-due timestamp only when it is actually executed. */ + due() { + const now = Date.now(); + return this.routines.filter((r) => r.enabled && r.nextDueAt <= now); + } + markRun(id) { + const r = this.routines.find((r) => r.id === id); + if (!r) + return; + r.lastRunAt = Date.now(); + r.nextDueAt = r.lastRunAt + r.everyMinutes * 60_000; + this.save(); + } + /** Called by the app when a run was attempted but failed — back off and + * retry next tick so a flaky failure doesn't hot-loop the bot. */ + postpone(id) { + const r = this.routines.find((r) => r.id === id); + if (!r) + return; + r.nextDueAt = Date.now() + Math.max(30_000, r.everyMinutes * 60_000 * 0.1); + } +} diff --git a/electron-builder.yml b/electron-builder.yml index 78361dc4e..466b4d9dc 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -25,10 +25,6 @@ extraResources: to: ui - from: dist-server to: server - - from: electron/resources/speech-helper - to: speech-helper - - from: electron/resources/perm-helper - to: perm-helper mac: target: @@ -45,10 +41,39 @@ mac: extendInfo: NSMicrophoneUsageDescription: OpenMausBot uses the microphone for voice dictation into the composer. NSSpeechRecognitionUsageDescription: OpenMausBot transcribes your voice on-device to type messages for you. + # The Swift speech + screen-permission helpers are macOS-only — ship them + # only on macOS, where they were compiled (never a signed bundle written + # into, so they're pre-built at package time). + extraResources: + - from: electron/resources/speech-helper + to: speech-helper + - from: electron/resources/perm-helper + to: perm-helper # Notarization runs manually after the build (notarytool + staple), # mirroring the BlueyLite ship_release.sh flow. notarize: false +win: + target: + - target: nsis + arch: + - x64 + - target: zip + arch: + - x64 + icon: build/icon.ico + # Windows dictation runs from the bundled PowerShell helper (no compile). + extraResources: + - from: electron/resources/speech-helper.ps1 + to: speech-helper.ps1 + +nsis: + oneClick: false + allowToChangeInstallationDirectory: true + createDesktopShortcut: true + createStartMenuShortcut: true + shortcutName: OpenMausBot + dmg: sign: true artifactName: OpenMausBot-${version}.dmg diff --git a/electron/cua.mjs b/electron/cua.mjs index 360c51816..c3df2bdaa 100644 --- a/electron/cua.mjs +++ b/electron/cua.mjs @@ -20,11 +20,13 @@ 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 STANDALONE_SOCKET = path.join( - app.getPath("home"), - "Library/Caches/cua-driver/cua-driver.sock", -); +const IS_MAC = process.platform === "darwin"; +const INSTALLED_DRIVER = IS_MAC + ? "/Applications/CuaDriver.app/Contents/MacOS/cua-driver" + : process.env.CUA_DRIVER_PATH || ""; +const STANDALONE_SOCKET = IS_MAC + ? path.join(app.getPath("home"), "Library/Caches/cua-driver/cua-driver.sock") + : `\\\\.\\pipe\\openmausbot-cua`; const HOST_BUNDLE_ID = "com.openmausbot.app"; let embeddedHost = null; // EmbeddedCuaDriverHost | null @@ -36,12 +38,24 @@ 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; + if (IS_MAC && fs.existsSync(INSTALLED_DRIVER)) return INSTALLED_DRIVER; + // Windows/Linux: try the CLI on PATH (`where cua-driver` / `which`) + if (!IS_MAC) { + const probe = spawnSync(process.platform === "win32" ? "where" : "which", ["cua-driver"], { + encoding: "utf8", + windowsHide: true, + }); + if (probe.status === 0 && probe.stdout.trim()) { + const first = probe.stdout.trim().split(/\r?\n/)[0].trim(); + return first; + } + } return null; } function socketAlive(sockPath) { return new Promise((resolve) => { + if (!IS_MAC) return resolve(false); // named-pipe daemon support on win is niche if (!fs.existsSync(sockPath)) return resolve(false); const s = net.createConnection(sockPath); const done = (ok) => { @@ -118,6 +132,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/macros.mjs b/electron/macros.mjs new file mode 100644 index 000000000..b62ca9159 --- /dev/null +++ b/electron/macros.mjs @@ -0,0 +1,135 @@ +// Macro record/replay, main-process side. +// - record: spawn macro-record.ps1 (Win32 low-level hooks via Add-Type), +// buffer the NDJSON stream, stop → return the parsed actions. +// - replay: write the actions to a temp file, spawn macro-replay.ps1 +// (SendInput) and wait for it to finish. +// Windows-only; the recorder needs win32 — on macOS this resolves to +// nothing (the renderer hides the buttons). +import { spawn } from "node:child_process"; +import { writeFileSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { app } from "electron"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const IS_WIN = process.platform === "win32"; + +let recorder = null; // { proc, buf, lines[] } +let replaying = false; + +function scriptPath(name) { + return app.isPackaged + ? path.join(process.resourcesPath, name) + : path.join(__dirname, "resources", name); +} + +export function startRecording() { + if (!IS_WIN) return { ok: false, error: "macro recording is Windows-only" }; + stopRecording(); + const script = scriptPath("macro-record.ps1"); + const proc = spawn( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script], + { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }, + ); + const rec = { proc, buf: "", lines: [] }; + proc.stdout.on("data", (chunk) => { + rec.buf += chunk; + let nl; + while ((nl = rec.buf.indexOf("\n")) !== -1) { + const line = rec.buf.slice(0, nl).trim(); + rec.buf = rec.buf.slice(nl + 1); + if (line) rec.lines.push(line); + } + }); + proc.stderr.on("data", () => {}); + proc.on("error", () => { + if (recorder === rec) recorder = null; + }); + proc.on("close", () => { + if (recorder === rec) recorder = null; + }); + recorder = rec; + return { ok: true }; +} + +export function stopRecording() { + const rec = recorder; + recorder = null; + if (!rec) return { ok: false, error: "not recording" }; + try { + rec.proc.kill(); + } catch {} + const actions = []; + for (const line of rec.lines) { + try { + const a = JSON.parse(line); + if (a.error) return { ok: false, error: a.error }; + actions.push(a); + } catch { + /* skip noise */ + } + } + return { ok: true, actions }; +} + +export async function replayMacro(actions) { + if (!IS_WIN) return { ok: false, error: "macro replay is Windows-only" }; + if (!Array.isArray(actions) || !actions.length) return { ok: false, error: "empty macro" }; + if (replaying) return { ok: false, error: "a macro is already replaying" }; + replaying = true; + const file = path.join(tmpdir(), `omb-macro-${Date.now()}.json`); + try { + writeFileSync(file, JSON.stringify(actions), "utf8"); + const script = scriptPath("macro-replay.ps1"); + const result = await new Promise((resolve) => { + const proc = spawn( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + script, + "-File", + file, + ], + { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }, + ); + let out = ""; + let err = ""; + proc.stdout.on("data", (c) => (out += c)); + proc.stderr.on("data", (c) => (err += c)); + const done = (code) => { + const last = out + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .pop(); + try { + resolve({ code, ...(last ? JSON.parse(last) : {}) }); + } catch { + resolve({ code, error: last || err.trim().slice(-300) || "replay failed" }); + } + }; + proc.on("error", (e) => resolve({ code: -1, error: e.message })); + proc.on("close", done); + setTimeout(() => { + try { + proc.kill(); + } catch {} + done(124); + }, 300_000).unref(); + }); + return result.error ? { ok: false, error: result.error } : { ok: true, events: result.events }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } finally { + try { + unlinkSync(file); + } catch {} + replaying = false; + } +} diff --git a/electron/main.mjs b/electron/main.mjs index ae68ee321..ee57d8b2e 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -3,9 +3,12 @@ import { execFile } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { startCua, stopCua, registerCuaIpc } from "./cua.mjs"; +import { replayMacro, startRecording, stopRecording } from "./macros.mjs"; import { startSpeech, stopSpeech } from "./speech.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const IS_MAC = process.platform === "darwin"; +const IS_WIN = process.platform === "win32"; // 127.0.0.1 explicitly — vite binds IPv4; a bare "localhost" here can // resolve to ::1 and paint a black window const DEV_URL = process.env.ELECTRON_START_URL ?? "http://127.0.0.1:5199"; @@ -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,13 @@ function createWindow() { minHeight: 600, icon: APP_ICON, backgroundColor: "#070707", - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 16, y: 16 }, + // macOS traffic lights / inset title bar are macOS-only concepts; + // Windows/Linux get the standard frame (or hidden + custom controls) + ...(IS_MAC + ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 16 } } + : IS_WIN + ? { titleBarStyle: "hidden", titleBarOverlay: { color: "#070707", symbolColor: "#fcfcfc", height: 40 } } + : {}), webPreferences: { contextIsolation: true, preload: path.join(__dirname, "preload.cjs"), @@ -120,13 +128,20 @@ ipcMain.handle("screen:frame", async () => { }); // 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. -ipcMain.handle("perm:status", () => ({ - mic: systemPreferences.getMediaAccessStatus?.("microphone") ?? "unknown", - screen: systemPreferences.getMediaAccessStatus?.("screen") ?? "unknown", -})); +// pops the real TCC prompt attributed to the app (macOS). Screen Recording +// has no programmatic request — the first desktopCapturer call prompts. +// On Windows there is no TCC — these resolve to "unknown"/no-op so the +// renderer's permission step simply collapses to "not applicable". +ipcMain.handle("perm:status", () => { + if (!IS_MAC) return { mic: "unknown", screen: "unknown", platform: process.platform }; + return { + mic: systemPreferences.getMediaAccessStatus?.("microphone") ?? "unknown", + screen: systemPreferences.getMediaAccessStatus?.("screen") ?? "unknown", + platform: process.platform, + }; +}); ipcMain.handle("perm:request-mic", async () => { + if (!IS_MAC) return true; // Windows: mic is granted implicitly at getUserMedia time try { return await systemPreferences.askForMediaAccess("microphone"); } catch { @@ -142,6 +157,7 @@ const PERM_HELPER = app.isPackaged ? path.join(process.resourcesPath, "perm-helper") : path.join(__dirname, "resources", "perm-helper"); ipcMain.handle("perm:request-screen", async () => { + if (!IS_MAC) return "unknown"; // desktopCapturer on Windows needs no grant // 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) => { @@ -151,16 +167,28 @@ ipcMain.handle("perm:request-screen", async () => { }); // 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. On Windows we +// open the equivalent Settings page (or no-op — there's no TCC). ipcMain.handle("perm:open-settings", (_event, pane) => { - const panes = { - mic: "Privacy_Microphone", - screen: "Privacy_ScreenCapture", - speech: "Privacy_SpeechRecognition", - }; - return shell.openExternal( - `x-apple.systempreferences:com.apple.preference.security?${panes[pane] ?? "Privacy"}`, - ); + 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"}`, + ); + } + if (IS_WIN) { + const pages = { + mic: "ms-settings:privacy-microphone", + screen: "ms-settings:privacy-broadcasting", + speech: "ms-settings:privacy-speechtyping", + }; + return shell.openExternal(pages[pane] ?? "ms-settings:privacy"); + } + return null; }); ipcMain.handle("speech:start", (event) => { @@ -169,12 +197,19 @@ ipcMain.handle("speech:start", (event) => { }); ipcMain.handle("speech:stop", () => stopSpeech()); +// Macro record/replay — Windows-only; the renderer hides these on other +// platforms, so the handlers just resolve to an error there. +ipcMain.handle("macro:record-start", () => startRecording()); +ipcMain.handle("macro:record-stop", () => stopRecording()); +ipcMain.handle("macro:replay", (_event, actions) => replayMacro(actions)); + app.whenReady().then(async () => { if (process.platform === "darwin") app.dock.setIcon(APP_ICON); - // getDisplayMedia in the renderer → this handler → ScreenCaptureKit, all - // inside the app's own processes — the one capture path macOS reliably - // attributes to the app (registers it in the Screen Recording pane and - // prompts). Used by the onboarding "Enable screen preview" button. + // getDisplayMedia in the renderer → this handler → ScreenCaptureKit (mac) + // or the equivalent capture path (win), all inside the app's own + // processes — the capture path macOS reliably attributes to the app + // (registers it in the Screen Recording pane and prompts). Used by the + // onboarding "Enable screen preview" button. session.defaultSession.setDisplayMediaRequestHandler( (_request, callback) => { desktopCapturer diff --git a/electron/preload.cjs b/electron/preload.cjs index 0821e8a9b..f4cb513cd 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). */ + /** Renderer platform hints (darwin | win32 | linux | browser). */ + platform: process.platform, + /** One frame of this screen as a data: URL (macOS Screen Recording TCC). */ screenFrame: () => ipcRenderer.invoke("screen:frame"), speechStart: () => ipcRenderer.invoke("speech:start"), speechStop: () => ipcRenderer.invoke("speech:stop"), @@ -25,4 +27,10 @@ contextBridge.exposeInMainWorld("ogb", { permOpenSettings: (pane) => ipcRenderer.invoke("perm:open-settings", pane), /** Registers a screen-capture attempt (adds the app to the TCC pane). */ permRequestScreen: () => ipcRenderer.invoke("perm:request-screen"), + /** Begin recording keyboard/mouse input (Windows). */ + macroRecordStart: () => ipcRenderer.invoke("macro:record-start"), + /** Stop recording; resolves the captured action list. */ + macroRecordStop: () => ipcRenderer.invoke("macro:record-stop"), + /** Replay a recorded action list through SendInput (Windows). */ + macroReplay: (actions) => ipcRenderer.invoke("macro:replay", actions), }); diff --git a/electron/resources/macro-record.ps1 b/electron/resources/macro-record.ps1 new file mode 100644 index 000000000..2efad9ec5 --- /dev/null +++ b/electron/resources/macro-record.ps1 @@ -0,0 +1,139 @@ +# Windows input recorder (macro capture). Emits one NDJSON line per input +# event on stdout with a relative timestamp so the replay helper can +# reproduce timing. Spawned by electron/main.mjs; killed to stop. +# Protocol: +# {"t":123,"type":"move","x":42,"y":100} mouse moved +# {"t":123,"type":"down","button":"left"} button press +# {"t":124,"type":"up","button":"left"} button release +# {"t":200,"type":"key","vk":65,"ext":false,"down":true} key down/up +# {"t":300,"type":"wheel","delta":120} mouse wheel +# {"error":"..."} fatal, exit 1 +$ErrorActionPreference = "Stop" + +$src = @' +using System; +using System.Runtime.InteropServices; +using System.Collections.Concurrent; +using System.Diagnostics; + +public static class MacroHook +{ + public delegate IntPtr LowLevelHookProc(int nCode, IntPtr wParam, IntPtr lParam); + + private const int WH_KEYBOARD_LL = 13; + private const int WH_MOUSE_LL = 14; + private const int WM_KEYDOWN = 0x0100, WM_KEYUP = 0x0101, WM_SYSKEYDOWN = 0x0104, WM_SYSKEYUP = 0x0105; + private const int WM_MOUSEMOVE = 0x0200; + private const int WM_LBUTTONDOWN = 0x0201, WM_LBUTTONUP = 0x0202; + private const int WM_RBUTTONDOWN = 0x0204, WM_RBUTTONUP = 0x0205; + private const int WM_MBUTTONDOWN = 0x0207, WM_MBUTTONUP = 0x0208; + private const int WM_MOUSEWHEEL = 0x020A; + + [StructLayout(LayoutKind.Sequential)] private struct POINT { public int x; public int y; } + [StructLayout(LayoutKind.Sequential)] private struct MSLLHOOKSTRUCT { public POINT pt; public uint mouseData; public uint flags; public uint time; public IntPtr dwExtraInfo; } + [StructLayout(LayoutKind.Sequential)] private struct KBDLLHOOKSTRUCT { public uint vkCode; public uint scanCode; public uint flags; public uint time; public IntPtr dwExtraInfo; } + + [DllImport("user32.dll")] private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelHookProc lpfn, IntPtr hMod, uint dwThreadId); + [DllImport("user32.dll")] private static extern bool UnhookWindowsHookEx(IntPtr hhk); + [DllImport("user32.dll")] private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam); + [DllImport("user32.dll")] private static extern bool GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax); + [DllImport("kernel32.dll")] private static extern IntPtr GetModuleHandle(string lpModuleName); + [StructLayout(LayoutKind.Sequential)] private struct MSG { public IntPtr hwnd; public uint message; public IntPtr wParam; public IntPtr lParam; public uint time; public int pt_x; public int pt_y; } + + private static LowLevelHookProc _kbProc, _mouseProc; + private static IntPtr _kbHook, _mouseHook; + private static Stopwatch _clock; + + public static ConcurrentQueue Events = new ConcurrentQueue(); + public static int LastError; + + private static string Button(uint message) { + if (message == WM_LBUTTONDOWN || message == WM_LBUTTONUP) return "left"; + if (message == WM_RBUTTONDOWN || message == WM_RBUTTONUP) return "right"; + if (message == WM_MBUTTONDOWN || message == WM_MBUTTONUP) return "middle"; + return "unknown"; + } + private static bool IsDown(uint message) { + return message == WM_LBUTTONDOWN || message == WM_RBUTTONDOWN || message == WM_MBUTTONDOWN; + } + + private static IntPtr KeyboardHook(int nCode, IntPtr wParam, IntPtr lParam) { + if (nCode >= 0) { + uint msg = (uint)wParam; + if (msg == WM_KEYDOWN || msg == WM_KEYUP || msg == WM_SYSKEYDOWN || msg == WM_SYSKEYUP) { + KBDLLHOOKSTRUCT kb = (KBDLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(KBDLLHOOKSTRUCT)); + bool down = (msg == WM_KEYDOWN || msg == WM_SYSKEYDOWN); + bool ext = (kb.flags & 0x1) != 0; + Events.Enqueue(string.Format("{{\"t\":{0},\"type\":\"key\",\"vk\":{1},\"ext\":{2},\"down\":{3}}}", + _clock.ElapsedMilliseconds, kb.vkCode, ext ? "true" : "false", down ? "true" : "false")); + } + } + return CallNextHookEx(_kbHook, nCode, wParam, lParam); + } + + private static IntPtr MouseHook(int nCode, IntPtr wParam, IntPtr lParam) { + if (nCode >= 0) { + uint msg = (uint)wParam; + MSLLHOOKSTRUCT ms = (MSLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(MSLLHOOKSTRUCT)); + if (msg == WM_MOUSEMOVE) { + Events.Enqueue(string.Format("{{\"t\":{0},\"type\":\"move\",\"x\":{1},\"y\":{2}}}", + _clock.ElapsedMilliseconds, ms.pt.x, ms.pt.y)); + } else if (msg == WM_LBUTTONDOWN || msg == WM_LBUTTONUP || msg == WM_RBUTTONDOWN || msg == WM_RBUTTONUP || msg == WM_MBUTTONDOWN || msg == WM_MBUTTONUP) { + Events.Enqueue(string.Format("{{\"t\":{0},\"type\":\"{1}\",\"button\":\"{2}\"}}", + _clock.ElapsedMilliseconds, IsDown(msg) ? "down" : "up", Button(msg))); + } else if (msg == WM_MOUSEWHEEL) { + int delta = (short)((ms.mouseData >> 16) & 0xFFFF); + Events.Enqueue(string.Format("{{\"t\":{0},\"type\":\"wheel\",\"delta\":{1}}}", + _clock.ElapsedMilliseconds, delta)); + } + } + return CallNextHookEx(_mouseHook, nCode, wParam, lParam); + } + + public static bool Start() { + _clock = Stopwatch.StartNew(); + _kbProc = KeyboardHook; + _mouseProc = MouseHook; + IntPtr mod = GetModuleHandle(null); + _kbHook = SetWindowsHookEx(WH_KEYBOARD_LL, _kbProc, mod, 0); + _mouseHook = SetWindowsHookEx(WH_MOUSE_LL, _mouseProc, mod, 0); + if (_kbHook == IntPtr.Zero || _mouseHook == IntPtr.Zero) { + LastError = Marshal.GetLastWin32Error(); + return false; + } + // The pump MUST live on a dedicated thread: low-level hooks only + // deliver while a message loop runs on the thread that installed + // them. PowerShell's main thread then drains the event queue. + System.Threading.Thread t = new System.Threading.Thread(new System.Threading.ThreadStart(Pump)); + t.IsBackground = true; + t.Start(); + return true; + } + + private static void Pump() { + MSG m; + while (GetMessage(out m, IntPtr.Zero, 0, 0)) { } + } +} +'@ + +Add-Type -TypeDefinition $src -Language CSharp + +if (-not [MacroHook]::Start()) { + $err = [System.ComponentModel.Win32Exception][MacroHook]::LastError + $msg = ("could not install input hook: " + $err.Message) -replace '"', "'" + [Console]::Out.WriteLine(('{"error":"' + $msg + '"}')) + [Console]::Out.Flush() + exit 1 +} + +# Drain captured events to stdout until killed. Also emits a tiny periodic +# progress line so a reader can tell we're alive without injecting input. +while ($true) { + $line = $null + while ([MacroHook]::Events.TryDequeue([ref]$line)) { + [Console]::Out.WriteLine($line) + } + [Console]::Out.Flush() + Start-Sleep -Milliseconds 20 +} diff --git a/electron/resources/macro-replay.ps1 b/electron/resources/macro-replay.ps1 new file mode 100644 index 000000000..80d34067a --- /dev/null +++ b/electron/resources/macro-replay.ps1 @@ -0,0 +1,117 @@ +# Windows macro replay. Reads a JSON array of recorded actions (the format +# emitted by macro-record.ps1, plus each action carries the absolute ms +# offset `t`) and injects them via SendInput at the recorded speed. The +# actions file is passed as argv[1]. Exit 0 on success, 1 with {"error":...} +# on failure. +param([string]$File = "") + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +function Emit([hashtable]$obj) { + $json = $obj | ConvertTo-Json -Compress + [Console]::Out.WriteLine($json) + [Console]::Out.Flush() +} + +try { + if (-not $File -or -not (Test-Path $File)) { throw "no actions file: $File" } + $actions = Get-Content $File -Raw | ConvertFrom-Json + if (-not $actions -or $actions.Count -eq 0) { throw "empty macro" } + + $src = @' +using System; +using System.Runtime.InteropServices; + +public static class MacroReplay +{ + [StructLayout(LayoutKind.Sequential)] + public struct POINT { public int x; public int y; } + [StructLayout(LayoutKind.Sequential)] + public struct MOUSEINPUT { public int dx; public int dy; public uint mouseData; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; } + [StructLayout(LayoutKind.Sequential)] + public struct KEYBDINPUT { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; } + [StructLayout(LayoutKind.Sequential)] + public struct INPUT { public uint type; public INPUTUNION U; } + [StructLayout(LayoutKind.Explicit)] + public struct INPUTUNION { [FieldOffset(0)] public MOUSEINPUT mi; [FieldOffset(0)] public KEYBDINPUT ki; } + + [DllImport("user32.dll")] private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize); + [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y); + [DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT p); + [DllImport("user32.dll")] public static extern int GetSystemMetrics(int nIndex); + + public const int SM_CXSCREEN = 0, SM_CYSCREEN = 1; + public const int INPUT_MOUSE = 0, INPUT_KEYBOARD = 1; + public const int MOUSEEVENTF_MOVE = 0x0001, MOUSEEVENTF_ABSOLUTE = 0x8000, MOUSEEVENTF_VIRTUALDESK = 0x4000; + public const int MOUSEEVENTF_LEFTDOWN = 0x0002, MOUSEEVENTF_LEFTUP = 0x0004; + public const int MOUSEEVENTF_RIGHTDOWN = 0x0008, MOUSEEVENTF_RIGHTUP = 0x0010; + public const int MOUSEEVENTF_MIDDLEDOWN = 0x0020, MOUSEEVENTF_MIDDLEUP = 0x0040; + public const int MOUSEEVENTF_WHEEL = 0x0800; + public const int KEYEVENTF_EXTENDEDKEY = 0x0001, KEYEVENTF_KEYUP = 0x0002, KEYEVENTF_SCANCODE = 0x0008; + + public static void Mouse(uint flags, int dx, int dy) { + INPUT i = new INPUT(); + i.type = INPUT_MOUSE; + i.U.mi.dwFlags = flags; + i.U.mi.dx = dx; i.U.mi.dy = dy; + SendInput(1, new INPUT[] { i }, Marshal.SizeOf(typeof(INPUT))); + } + public static void MoveAbs(int x, int y) { + int sw = GetSystemMetrics(SM_CXSCREEN), sh = GetSystemMetrics(SM_CYSCREEN); + int dx = (int)((x * 65535.0) / (sw - 1)); + int dy = (int)((y * 65535.0) / (sh - 1)); + Mouse(MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK, dx, dy); + } + public static void Wheel(int delta) { + INPUT i = new INPUT(); + i.type = INPUT_MOUSE; + i.U.mi.dwFlags = MOUSEEVENTF_WHEEL; + i.U.mi.mouseData = (uint)delta; + SendInput(1, new INPUT[] { i }, Marshal.SizeOf(typeof(INPUT))); + } + public static void Key(ushort vk, bool down, bool extended) { + INPUT i = new INPUT(); + i.type = INPUT_KEYBOARD; + i.U.ki.wVk = vk; + i.U.ki.dwFlags = down ? 0u : KEYEVENTF_KEYUP; + if (extended) i.U.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY; + SendInput(1, new INPUT[] { i }, Marshal.SizeOf(typeof(INPUT))); + } +} +'@ + Add-Type -TypeDefinition $src -Language CSharp + + $prevT = [double]($actions[0].t) + $started = Get-Date + foreach ($a in $actions) { + # wait out the recorded delay (clamped to a sane ceiling) + $now = (Get-Date) - $started + $target = [double]($a.t) + $gap = ($target - $prevT) / 1000.0 + if ($gap -gt 0) { Start-Sleep -Milliseconds ([math]::Min([math]::Max([int]($gap * 1000), 0), 30000)) } + $prevT = $target + + switch ($a.type) { + "move" { [MacroReplay]::MoveAbs([int]$a.x, [int]$a.y) } + "down" { + $m = @{ left = 2; right = 8; middle = 32 } + [MacroReplay]::Mouse([uint32]$m[$a.button], 0, 0) + } + "up" { + $m = @{ left = 4; right = 16; middle = 64 } + [MacroReplay]::Mouse([uint32]$m[$a.button], 0, 0) + } + "wheel" { [MacroReplay]::Wheel([int]$a.delta) } + "key" { + [MacroReplay]::Key([uint16]$a.vk, [bool]$a.down, [bool]$a.ext) + Start-Sleep -Milliseconds 10 + } + } + } + Emit @{ ok = $true; events = $actions.Count } + exit 0 +} catch { + Emit @{ error = $_.Exception.Message } + exit 1 +} diff --git a/electron/resources/speech-helper.ps1 b/electron/resources/speech-helper.ps1 new file mode 100644 index 000000000..75019e768 --- /dev/null +++ b/electron/resources/speech-helper.ps1 @@ -0,0 +1,51 @@ +# Native Windows speech-to-text helper (Windows 10/11, on-device). +# Mirrors the macOS speech-helper.swift NDJSON protocol on stdout: +# {"partial":true,"text":"…"} while recognizing +# {"partial":false,"text":"…"} final result, then exit 0 +# {"error":"…"} then exit 1 +# Runs until the final result or killed. Spawned by electron/speech.mjs +# from the MAIN process so the mic permission prompt attributes to the app. +$ErrorActionPreference = "Stop" + +function Emit([hashtable]$obj) { + $json = $obj | ConvertTo-Json -Compress + [Console]::Out.WriteLine($json) + [Console]::Out.Flush() +} + +try { + Add-Type -AssemblyName System.Speech + $recognizer = New-Object System.Speech.Recognition.SpeechRecognitionEngine + $recognizer.SetInputToDefaultAudioDevice() + + # English-US dictation. If the grammar is missing on this system, report + # a friendly error and exit 1 (the UI shows it in the composer). + try { + $grammar = New-Object System.Speech.Recognition.DictationGrammar + $recognizer.LoadGrammar($grammar) + } catch { + Emit @{ error = "dictation-grammar-unavailable" } + exit 1 + } + + $hypothesized = { + param($s, $e) + Emit @{ partial = $true; text = $e.Result.Text } + } + $recognized = { + param($s, $e) + Emit @{ partial = $false; text = $e.Result.Text } + exit 0 + } + + $recognizer.add_SpeechHypothesized($hypothesized) + $recognizer.add_SpeechRecognized($recognized) + + $recognizer.RecognizeAsync([System.Speech.Recognition.RecognizeMode]::Multiple) + + # Keep the process alive; kill() from speech.mjs terminates us. + while ($true) { Start-Sleep -Milliseconds 200 } +} catch { + Emit @{ error = "speech-unavailable" } + exit 1 +} diff --git a/electron/speech.mjs b/electron/speech.mjs index e71d19084..69d7cbfbb 100644 --- a/electron/speech.mjs +++ b/electron/speech.mjs @@ -1,7 +1,11 @@ -// Speech helper lifecycle, main-process side. The Swift helper is spawned -// from HERE (never the harness server) so the Microphone + Speech -// Recognition permission prompts attribute to the app. Compiled lazily on -// first use; each recording session is one helper process. +// Speech helper lifecycle, main-process side. +// - macOS: the Swift helper (SFSpeechRecognizer, on-device) is spawned +// from HERE (never the harness server) so the Microphone + Speech +// Recognition permission prompts attribute to the app. Compiled lazily +// on first use; each recording session is one helper process. +// - Windows: a PowerShell helper (System.Speech, on-device dictation) +// emits the same NDJSON protocol. No compile step; the .ps1 ships in +// Resources for the packaged app. import { execFileSync, spawn } from "node:child_process"; import { existsSync, statSync } from "node:fs"; import path from "node:path"; @@ -9,27 +13,60 @@ import { fileURLToPath } from "node:url"; import { app } from "electron"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const SRC = path.join(__dirname, "resources", "speech-helper.swift"); -// packaged: the helper ships pre-built + signed in Resources (a signed app -// bundle must never be written into — lazy compile would break the seal) -const BIN = app.isPackaged - ? path.join(process.resourcesPath, "speech-helper") - : path.join(__dirname, "resources", "speech-helper"); +const IS_MAC = process.platform === "darwin"; +const IS_WIN = process.platform === "win32"; let child = null; +function helperCommand() { + if (IS_MAC) { + // packaged: the helper ships pre-built + signed in Resources (a signed app + // bundle must never be written into — lazy compile would break the seal) + return { + bin: app.isPackaged + ? path.join(process.resourcesPath, "speech-helper") + : path.join(__dirname, "resources", "speech-helper"), + args: [], + }; + } + if (IS_WIN) { + const script = app.isPackaged + ? path.join(process.resourcesPath, "speech-helper.ps1") + : path.join(__dirname, "resources", "speech-helper.ps1"); + return { + bin: "powershell.exe", + args: [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + script, + ], + }; + } + return null; // unsupported platform — no dictation +} + function ensureBuilt() { - if (app.isPackaged) return; // pre-built at package time - const stale = !existsSync(BIN) || statSync(BIN).mtimeMs < statSync(SRC).mtimeMs; + if (!IS_MAC || app.isPackaged) return; // pre-built at package time (or not needed) + const src = path.join(__dirname, "resources", "speech-helper.swift"); + const bin = path.join(__dirname, "resources", "speech-helper"); + const stale = !existsSync(bin) || statSync(bin).mtimeMs < statSync(src).mtimeMs; if (!stale) return; // Xcode CLT required; ~2s once, then cached until the source changes - execFileSync("swiftc", ["-O", SRC, "-o", BIN], { stdio: "pipe", timeout: 120_000 }); + execFileSync("swiftc", ["-O", src, "-o", bin], { stdio: "pipe", timeout: 120_000 }); } export function startSpeech(win) { stopSpeech(); + const helper = helperCommand(); + if (!helper) { + if (!win.isDestroyed()) win.webContents.send("speech:end", { code: 1 }); + return; + } ensureBuilt(); - const proc = spawn(BIN, [], { stdio: ["ignore", "pipe", "pipe"] }); + const proc = spawn(helper.bin, helper.args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }); child = proc; let buf = ""; @@ -60,7 +97,7 @@ export function startSpeech(win) { export function stopSpeech() { if (!child) return; try { - child.kill("SIGTERM"); + child.kill(); } catch {} child = null; } diff --git a/package.json b/package.json index 200ae286c..b5a7bc7f4 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,11 @@ "test:watch": "vitest", "preview": "vite preview", "build:server": "tsc -p tsconfig.server.build.json", - "build:speech": "swiftc -O electron/resources/speech-helper.swift -o electron/resources/speech-helper", + "build:speech": "node scripts/build-native.mjs speech", + "build:perm": "node scripts/build-native.mjs perm", "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" + "package:win": "pnpm build && pnpm build:server && electron-builder --win --publish never", + "make:ico": "powershell -NoProfile -ExecutionPolicy Bypass -File build/make-ico.ps1" }, "dependencies": { "@trycua/cua-driver": "^0.19.3", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 000000000..297229e2f --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + core-js: true + electron-winstaller: true + esbuild: true diff --git a/scripts/build-native.mjs b/scripts/build-native.mjs new file mode 100644 index 000000000..bfc14568f --- /dev/null +++ b/scripts/build-native.mjs @@ -0,0 +1,39 @@ +// build-native.mjs — compile the platform-native helpers. +// speech / perm : macOS-only Swift helpers (compiled with swiftc). +// On non-macOS these are no-ops (Windows uses the bundled .ps1 helper, +// Linux has no dictation/perm helpers yet) — keeping `pnpm package` +// runnable from any platform without a Swift toolchain. +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const which = process.argv[2]; + +if (process.platform !== "darwin") { + console.log(`[build-native] skipping ${which ?? ""} (not macOS)`); + process.exit(0); +} + +const JOBS = { + speech: { + src: "electron/resources/speech-helper.swift", + out: "electron/resources/speech-helper", + }, + perm: { + src: "electron/resources/perm-helper.swift", + out: "electron/resources/perm-helper", + }, +}; + +const job = JOBS[which]; +if (!job) { + console.error(`unknown native helper: ${which}`); + process.exit(1); +} + +const src = path.resolve(__dirname, "..", job.src); +const out = path.resolve(__dirname, "..", job.out); +console.log(`[build-native] compiling ${path.basename(out)}…`); +execFileSync("swiftc", ["-O", src, "-o", out], { stdio: "inherit", timeout: 120_000 }); +console.log(`[build-native] done → ${out}`); diff --git a/server/cli-util.ts b/server/cli-util.ts new file mode 100644 index 000000000..ed63b7fd1 --- /dev/null +++ b/server/cli-util.ts @@ -0,0 +1,152 @@ +// 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, + type ChildProcess, + type ChildProcessByStdio, + type ChildProcessWithoutNullStreams, + type SpawnOptions, + type SpawnOptionsWithStdioTuple, + type SpawnOptionsWithoutStdio, + type StdioNull, + type StdioPipe, +} from "node:child_process"; +import { type Readable, type Writable } from "node:stream"; +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: string): string { + 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: string): string { + 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; +} + +/** + * Spawn a CLI the way its platform wants: direct on POSIX, through cmd.exe + * on Windows when the resolved target is a batch shim. Returns the child + * process in every case. + */ +export function spawnCli( + cli: string, + args: string[], + opts: SpawnOptionsWithStdioTuple, +): ChildProcessByStdio; +export function spawnCli( + cli: string, + args: string[], + opts: SpawnOptionsWithStdioTuple, +): ChildProcessByStdio; +export function spawnCli( + cli: string, + args: string[], + opts: SpawnOptionsWithoutStdio, +): ChildProcessWithoutNullStreams; +export function spawnCli(cli: string, args: string[], opts: SpawnOptions = {}): ChildProcess { + 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: string, + args: string[], + opts: { timeout?: number; env?: NodeJS.ProcessEnv } = {}, + cb: (err: Error | null, stdout: string, stderr: string) => void, +): void { + if (!isWindows) { + execFile(cli, args, opts as any, cb as any); + 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: Error | null) => { + if (done) return; + done = true; + clearTimeout(timer); + cb(e, out, err); + }; + child.stdout?.on("data", (d: Buffer) => (out += d)); + child.stderr?.on("data", (d: Buffer) => (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: number): void { + 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 {} + } +} diff --git a/server/drivers/claude.ts b/server/drivers/claude.ts index b1f213d6d..06b2aa310 100644 --- a/server/drivers/claude.ts +++ b/server/drivers/claude.ts @@ -8,14 +8,13 @@ // - 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.ts"; import { DATA_DIR } from "../config.ts"; import type { @@ -91,7 +90,10 @@ function askSummary(ask: Ask): string { function permissionSocketPath(threadId: string) { 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: { @@ -310,11 +312,16 @@ export const ClaudeDriver: ProviderDriver = { 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; @@ -412,7 +419,7 @@ export const ClaudeDriver: ProviderDriver = { const stop = () => { try { - process.kill(-child.pid!, "SIGTERM"); + if (child.pid) killProcessTree(child.pid); } catch { try { child.kill("SIGTERM"); @@ -433,7 +440,7 @@ export const ClaudeDriver: ProviderDriver = { const snapshot = async (): Promise => { const version = await new Promise((resolve) => { - execFile(config.cli, ["--version"], { timeout: 8000 }, (err, stdout) => + execFileCli(config.cli, ["--version"], { timeout: 8000 }, (err, stdout) => resolve(err ? null : stdout.trim()), ); }); @@ -473,10 +480,10 @@ export const ClaudeDriver: ProviderDriver = { }, generateText: (prompt: string) => new Promise((resolve, reject) => { - execFile( + execFileCli( config.cli, ["-p", prompt, "--model", "claude-haiku-4-5", "--output-format", "text"], - { timeout: 60_000, env: { ...process.env } }, + { timeout: 60_000 }, (err, stdout) => (err ? reject(err) : resolve(stdout.trim())), ); }), diff --git a/server/drivers/codex.ts b/server/drivers/codex.ts index 416595893..fa9028b2d 100644 --- a/server/drivers/codex.ts +++ b/server/drivers/codex.ts @@ -9,9 +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 { execFileCli, isWindows, killProcessTree, spawnCli } from "../cli-util.ts"; + import type { DriverCreateInput, ProviderDriver, @@ -91,11 +92,13 @@ 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 = 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: "" }; @@ -118,7 +121,7 @@ export const CodexDriver: ProviderDriver = { const stop = () => { try { - process.kill(-child.pid!, "SIGTERM"); + if (child.pid) killProcessTree(child.pid); } catch { try { child.kill("SIGTERM"); @@ -358,7 +361,7 @@ export const CodexDriver: ProviderDriver = { const snapshot = async (): Promise => { const version = await new Promise((resolve) => { - execFile(config.cli, ["--version"], { timeout: 8000 }, (err, stdout) => + execFileCli(config.cli, ["--version"], { timeout: 8000 }, (err, stdout) => resolve(err ? null : stdout.trim()), ); }); diff --git a/server/index.ts b/server/index.ts index 3bd92f5d1..9e0092179 100644 --- a/server/index.ts +++ b/server/index.ts @@ -14,6 +14,8 @@ import type { RuntimeEvent } from "./contracts.ts"; import { BUILT_IN_DRIVERS } from "./drivers/builtIn.ts"; import { EventBus } from "./harness/bus.ts"; import { ProviderRegistry } from "./harness/registry.ts"; +import { MacroStore } from "./macros.ts"; +import { RoutineStore } from "./routines.ts"; import { Store, type Message } from "./store.ts"; const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799); @@ -49,6 +51,9 @@ const store = new Store(() => bootSelection); bootSelection = await defaultSelection(); store.seedIfEmpty(); +const routines = new RoutineStore(); +const macros = new MacroStore(); + // ── SSE fan-out to clients ───────────────────────────────────────────── const sseClients = new Set(); function broadcast(payload: unknown) { @@ -200,13 +205,21 @@ 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. +// (macOS: ~/Library/Application Support/OpenMausBot/cua-connection.json; +// Windows: %APPDATA%\OpenMausBot\cua-connection.json). Read fresh each +// turn — Electron may restart or permissions may change. +function cuaConnectionRoots(): string[] { + const names = ["OpenMausBot", "openmausbot", "OpenGrokBot", "opengrokbot"]; + if (process.platform === "win32") { + const base = process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"); + return names.map((dir) => join(base, dir)); + } + return names.map((dir) => join(homedir(), "Library", "Application Support", dir)); +} function readCuaConnection(): { command: string; args: string[]; env: Record } | null { - // new name first; pre-rename desktop builds used the old directory - for (const dir of ["OpenMausBot", "openmausbot", "OpenGrokBot", "opengrokbot"]) { + for (const dir of cuaConnectionRoots()) { try { - const p = join(homedir(), "Library", "Application Support", dir, "cua-connection.json"); + const p = join(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 ?? {} }; @@ -326,6 +339,31 @@ async function reloadProviders() { bus.attach(registry.instances()); } +// ── routine scheduler ───────────────────────────────────────────────── +// One central tick walks every due routine and dispatches its prompt to +// the owning bot exactly like a user message (startTurn handles busy, +// computer wiring, screen polling, SSE). Runs are debounced/backed off so +// a failing routine retries rather than hot-looping the bot. +setInterval(async () => { + for (const routine of routines.due()) { + const bot = store.bot(routine.botId); + if (!bot) { + routines.remove(routine.id); + continue; + } + if (bot.busy) { + routines.postpone(routine.id); + continue; + } + routines.markRun(routine.id); + broadcast({ + kind: "routine", + routine: { _id: routine.id, botId: routine.botId, name: routine.name, lastRunAt: routine.lastRunAt }, + }); + await startTurn(routine.botId, routine.prompt).catch(() => routines.postpone(routine.id)); + } +}, 30_000); + // ── HTTP plumbing ───────────────────────────────────────────────────── function json(res: ServerResponse, status: number, body: unknown) { const data = JSON.stringify(body); @@ -472,6 +510,77 @@ const server = createServer(async (req, res) => { return json(res, 200, { app: "openmausbot", pid: process.pid, static: Boolean(STATIC_DIR) }); } + // ── routines ── + if (method === "GET" && path === "/api/routines") { + return json(res, 200, { routines: routines.all() }); + } + if (method === "POST" && path === "/api/routines") { + const body = await readBody(req); + if (!body.botId || !store.bot(body.botId)) return json(res, 404, { error: "no such bot" }); + const routine = routines.create({ + botId: body.botId, + name: body.name, + prompt: body.prompt, + everyMinutes: body.everyMinutes, + enabled: body.enabled !== false, + }); + broadcast({ kind: "routine", routine }); + return json(res, 201, { routine }); + } + m = path.match(/^\/api\/routines\/([\w-]+)$/); + if (m && method === "PATCH") { + const body = await readBody(req); + if (body.botId && !store.bot(body.botId)) return json(res, 404, { error: "no such bot" }); + const routine = routines.patch(m[1], { + ...(body.name !== undefined ? { name: body.name } : {}), + ...(body.prompt !== undefined ? { prompt: body.prompt } : {}), + ...(body.everyMinutes !== undefined ? { everyMinutes: body.everyMinutes } : {}), + ...(body.enabled !== undefined ? { enabled: body.enabled } : {}), + }); + if (!routine) return json(res, 404, { error: "no such routine" }); + broadcast({ kind: "routine", routine }); + return json(res, 200, { routine }); + } + m = path.match(/^\/api\/routines\/([\w-]+)$/); + if (m && method === "DELETE") { + if (!routines.remove(m[1])) return json(res, 404, { error: "no such routine" }); + broadcast({ kind: "routine.deleted", id: m[1] }); + return json(res, 200, { ok: true }); + } + m = path.match(/^\/api\/routines\/([\w-]+)\/run$/); + if (m && method === "POST") { + const routine = routines.get(m[1]); + if (!routine) return json(res, 404, { error: "no such routine" }); + const bot = store.bot(routine.botId); + if (!bot) return json(res, 404, { error: "no such bot" }); + if (bot.busy) return json(res, 409, { error: "the bot is already working — interrupt it first" }); + routines.markRun(routine.id); + broadcast({ kind: "routine", routine: { ...routine, lastRunAt: Date.now() } }); + await startTurn(routine.botId, routine.prompt).catch((e) => { + routines.postpone(routine.id); + return json(res, e?.status ?? 500, { error: e?.message ?? String(e) }); + }); + return json(res, 202, { ok: true }); + } + + // ── macros (recorded input sequences; replay runs in Electron main) ── + if (method === "GET" && path === "/api/macros") { + return json(res, 200, { macros: macros.all() }); + } + if (method === "POST" && path === "/api/macros") { + const body = await readBody(req); + if (!body.botId || !store.bot(body.botId)) return json(res, 404, { error: "no such bot" }); + const macro = macros.create(body.botId, body.name, body.actions); + broadcast({ kind: "macro", macro }); + return json(res, 201, { macro }); + } + m = path.match(/^\/api\/macros\/([\w-]+)$/); + if (m && method === "DELETE") { + if (!macros.remove(m[1])) return json(res, 404, { error: "no such macro" }); + broadcast({ kind: "macro.deleted", id: m[1] }); + return json(res, 200, { ok: true }); + } + // ── provider instances (model picker) ── if (method === "GET" && path === "/api/instances") { return json(res, 200, { instances: await registry.describe() }); diff --git a/server/macros.ts b/server/macros.ts new file mode 100644 index 000000000..a4cfac936 --- /dev/null +++ b/server/macros.ts @@ -0,0 +1,87 @@ +// Macros — recorded input sequences, persisted to ~/.openmausbot/macros.json. +// A macro is an array of {t, type, ...} actions exactly as emitted by the +// Electron recorder; replay happens in Electron main (SendInput) and is +// triggered from the renderer. The server owns storage + listing only. +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { DATA_DIR } from "./config.ts"; +import { newId } from "./contracts.ts"; + +export interface MacroAction { + t: number; + type: "move" | "down" | "up" | "wheel" | "key"; + x?: number; + y?: number; + button?: string; + delta?: number; + vk?: number; + ext?: boolean; + down?: boolean; +} + +export interface Macro { + id: string; + botId: string; + name: string; + actions: MacroAction[]; + durationMs: number; + createdAt: number; +} + +const MACROS_FILE = join(DATA_DIR, "macros.json"); + +export class MacroStore { + private macros: Macro[] = []; + + constructor() { + mkdirSync(DATA_DIR, { recursive: true }); + try { + const raw = JSON.parse(readFileSync(MACROS_FILE, "utf8")); + this.macros = Array.isArray(raw) ? raw : []; + } catch { + this.macros = []; + } + } + + private save() { + writeFileSync(MACROS_FILE, JSON.stringify(this.macros, null, 2)); + } + + all(): Macro[] { + return this.macros; + } + + forBot(botId: string): Macro[] { + return this.macros.filter((m) => m.botId === botId); + } + + get(id: string): Macro | null { + return this.macros.find((m) => m.id === id) ?? null; + } + + create(botId: string, name: string, actions: MacroAction[]): Macro { + if (!Array.isArray(actions) || !actions.length) { + throw Object.assign(new Error("no recorded actions"), { status: 400 }); + } + const macro: Macro = { + id: newId(), + botId, + name: String(name ?? "").trim() || "Untitled macro", + actions, + durationMs: Math.max(0, actions[actions.length - 1]?.t ?? 0), + createdAt: Date.now(), + }; + this.macros.unshift(macro); + this.save(); + return macro; + } + + remove(id: string): boolean { + const before = this.macros.length; + this.macros = this.macros.filter((m) => m.id !== id); + const removed = this.macros.length !== before; + if (removed) this.save(); + return removed; + } +} diff --git a/server/routines.ts b/server/routines.ts new file mode 100644 index 000000000..70e64dc80 --- /dev/null +++ b/server/routines.ts @@ -0,0 +1,136 @@ +// Routines — recurring tasks an agent runs on a schedule. Persisted to +// ~/.openmausbot/routines.json; the scheduler ticks from server/index.ts +// and hands each due routine to startTurn() like a normal user message. +// A routine is scoped to one bot and is just a prompt + an interval: +// every N minutes the bot runs that prompt on its configured computer. +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { DATA_DIR } from "./config.ts"; +import { newId } from "./contracts.ts"; + +export interface Routine { + id: string; + botId: string; + /** Short label shown in the Routines panel. */ + name: string; + /** The prompt dispatched to the bot each time it runs. */ + prompt: string; + /** Interval in minutes between runs (min 1). */ + everyMinutes: number; + /** Persisted schedule state; the in-memory `nextDueAt` drives the tick. */ + enabled: boolean; + createdAt: number; + /** Set when the routine actually fires; shown in the UI. */ + lastRunAt: number | null; +} + +interface RoutineRecord extends Routine { + nextDueAt: number; +} + +const ROUTINES_FILE = join(DATA_DIR, "routines.json"); + +export class RoutineStore { + private routines: RoutineRecord[] = []; + + constructor() { + mkdirSync(DATA_DIR, { recursive: true }); + try { + const raw = JSON.parse(readFileSync(ROUTINES_FILE, "utf8")); + this.routines = Array.isArray(raw) ? raw : []; + } catch { + this.routines = []; + } + for (const r of this.routines) { + // normalize + seed nextDueAt (now) so a fresh routine doesn't fire + // immediately after a restart unless its interval has truly elapsed + r.everyMinutes = Math.max(1, Math.floor(Number(r.everyMinutes) || 60)); + r.nextDueAt = r.lastRunAt ? r.lastRunAt + r.everyMinutes * 60_000 : Date.now(); + } + } + + private save() { + writeFileSync(ROUTINES_FILE, JSON.stringify(this.routines, null, 2)); + } + + all(): Routine[] { + return this.routines.map(({ nextDueAt, ...r }) => ({ ...r, lastRunAt: r.lastRunAt ?? null })); + } + + get(id: string): Routine | null { + const r = this.routines.find((r) => r.id === id); + if (!r) return null; + const { nextDueAt: _remove, ...rest } = r; + return rest; + } + + forBot(botId: string): Routine[] { + return this.routines.filter((r) => r.botId === botId).map(({ nextDueAt: _remove, ...r }) => r); + } + + create(input: Omit): Routine { + const routine: RoutineRecord = { + id: newId(), + botId: input.botId, + name: String(input.name ?? "").trim() || "Untitled routine", + prompt: String(input.prompt ?? "").trim(), + everyMinutes: Math.max(1, Math.floor(Number(input.everyMinutes) || 60)), + enabled: input.enabled !== false, + createdAt: Date.now(), + lastRunAt: null, + nextDueAt: Date.now(), + }; + if (!routine.prompt) throw Object.assign(new Error("prompt is required"), { status: 400 }); + this.routines.push(routine); + this.save(); + return this.get(routine.id)!; + } + + patch(id: string, patch: Partial>): Routine | null { + const r = this.routines.find((r) => r.id === id); + if (!r) return null; + if (patch.name !== undefined) r.name = String(patch.name).trim() || r.name; + if (patch.prompt !== undefined) { + const p = String(patch.prompt).trim(); + if (!p) throw Object.assign(new Error("prompt is required"), { status: 400 }); + r.prompt = p; + } + if (patch.everyMinutes !== undefined) r.everyMinutes = Math.max(1, Math.floor(Number(patch.everyMinutes) || r.everyMinutes)); + if (patch.enabled !== undefined) r.enabled = Boolean(patch.enabled); + if (patch.botId !== undefined) r.botId = patch.botId; + this.save(); + return this.get(id)!; + } + + remove(id: string): boolean { + const before = this.routines.length; + this.routines = this.routines.filter((r) => r.id !== id); + const removed = this.routines.length !== before; + if (removed) this.save(); + return removed; + } + + /** Hand the scheduler the routines due right now. Resets each one's + * next-due timestamp only when it is actually executed. */ + due(): RoutineRecord[] { + const now = Date.now(); + return this.routines.filter((r) => r.enabled && r.nextDueAt <= now); + } + + markRun(id: string) { + const r = this.routines.find((r) => r.id === id); + if (!r) return; + r.lastRunAt = Date.now(); + r.nextDueAt = r.lastRunAt + r.everyMinutes * 60_000; + this.save(); + } + + /** Called by the app when a run was attempted but failed — back off and + * retry next tick so a flaky failure doesn't hot-loop the bot. */ + postpone(id: string) { + const r = this.routines.find((r) => r.id === id); + if (!r) return; + r.nextDueAt = Date.now() + Math.max(30_000, r.everyMinutes * 60_000 * 0.1); + } +} diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index d0a61b406..9272db928 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -39,7 +39,9 @@ export function Composer({ bot }: { bot: Bot }) { setRecording(false); if (code === 1) { setSpeechError( - "Dictation needs Microphone + Speech Recognition access — System Settings → Privacy & Security.", + bridge.platform === "win32" + ? "Dictation isn't available here — enable Windows Speech Recognition in Settings → Privacy & speech." + : "Dictation needs Microphone + Speech Recognition access — System Settings → Privacy & Security.", ); } }); diff --git a/src/components/ComputerPanel.tsx b/src/components/ComputerPanel.tsx index 3273a00e1..5d06008cc 100644 --- a/src/components/ComputerPanel.tsx +++ b/src/components/ComputerPanel.tsx @@ -6,13 +6,20 @@ // prefers the cloud box when one exists, else local inside the app. import { useEffect, useRef, useState } from "react"; import { + Activity, CalendarClock, + Check, + Circle, ExternalLink, Loader2, Monitor, Moon, + Pause, + Play, Power, Settings, + Square, + Trash2, X, } from "lucide-react"; import { useStore, type Bot } from "@/state/store"; @@ -36,6 +43,42 @@ type Phase = | "off" | "error"; +interface Routine { + id: string; + botId: string; + name: string; + prompt: string; + everyMinutes: number; + enabled: boolean; + lastRunAt: number | null; + createdAt: number; +} + +interface MacroAction { + t: number; + type: "move" | "down" | "up" | "wheel" | "key"; + x?: number; + y?: number; + button?: string; + delta?: number; + vk?: number; + ext?: boolean; + down?: boolean; +} + +interface Macro { + id: string; + botId: string; + name: string; + actions: MacroAction[]; + durationMs: number; + createdAt: number; +} + +function formatRoutineTime(at: number) { + return new Date(at).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); +} + export function ComputerPanel({ bot }: { bot: Bot }) { const { state, dispatch } = useStore(); const [phase, setPhase] = useState("checking"); @@ -47,6 +90,157 @@ export function ComputerPanel({ bot }: { bot: Bot }) { // bumped when a Box token is saved inline, to re-run the spin-up flow const [retry, setRetry] = useState(0); + // ── routines ───────────────────────────────────────────────────────── + const [routines, setRoutines] = useState([]); + const [showRoutineForm, setShowRoutineForm] = useState(false); + const [routineName, setRoutineName] = useState(""); + const [routinePrompt, setRoutinePrompt] = useState(""); + const [routineInterval, setRoutineInterval] = useState("60"); + const [savingRoutine, setSavingRoutine] = useState(false); + const [pendingRoutine, setPendingRoutine] = useState(null); + const [routineError, setRoutineError] = useState(null); + + const loadRoutines = () => { + api("/api/routines") + .then(({ routines }) => setRoutines(routines.filter((r: Routine) => r.botId === bot.id))) + .catch((e) => setRoutineError(e.message)); + }; + useEffect(() => { + loadRoutines(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bot.id]); + + const saveRoutine = () => { + if (!routinePrompt.trim()) return; + setSavingRoutine(true); + setRoutineError(null); + api("/api/routines", { + method: "POST", + body: JSON.stringify({ + botId: bot.id, + name: routineName.trim() || "Untitled routine", + prompt: routinePrompt.trim(), + everyMinutes: Number(routineInterval) || 60, + }), + }) + .then(() => { + setShowRoutineForm(false); + setRoutineName(""); + setRoutinePrompt(""); + loadRoutines(); + }) + .catch((e) => setRoutineError(e.message)) + .finally(() => setSavingRoutine(false)); + }; + + const runRoutine = (id: string) => { + setPendingRoutine(id); + setRoutineError(null); + api(`/api/routines/${id}/run`, { method: "POST" }) + .then(loadRoutines) + .catch((e) => setRoutineError(e.message)) + .finally(() => setPendingRoutine(null)); + }; + + const toggleRoutine = (r: Routine) => { + setRoutineError(null); + api(`/api/routines/${r.id}`, { + method: "PATCH", + body: JSON.stringify({ enabled: !r.enabled }), + }) + .then(loadRoutines) + .catch((e) => setRoutineError(e.message)); + }; + + const deleteRoutine = (id: string) => { + setRoutineError(null); + api(`/api/routines/${id}`, { method: "DELETE" }) + .then(loadRoutines) + .catch((e) => setRoutineError(e.message)); + }; + + // ── macros (record/replay this computer's input) ──────────────────── + const canMacro = Boolean(window.ogb?.macroRecordStart && window.ogb?.macroReplay); + const [macros, setMacros] = useState([]); + const [recording, setRecording] = useState(false); + const [macroName, setMacroName] = useState(""); + const [savingMacro, setSavingMacro] = useState(false); + const [replayingId, setReplayingId] = useState(null); + const [macroError, setMacroError] = useState(null); + const [macroInfo, setMacroInfo] = useState(null); + const [pendingActions, setPendingActions] = useState(null); + + const loadMacros = () => { + api("/api/macros") + .then(({ macros }) => setMacros(macros.filter((m: Macro) => m.botId === bot.id))) + .catch((e) => setMacroError(e.message)); + }; + useEffect(() => { + loadMacros(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bot.id]); + + const toggleRecording = async () => { + setMacroError(null); + setMacroInfo(null); + if (!recording) { + const res = await window.ogb!.macroRecordStart(); + if (!res.ok) return setMacroError(res.error ?? "couldn't start recording"); + setRecording(true); + setMacroInfo("Recording… do your thing, then stop. Your input is captured on this computer."); + } else { + const res = await window.ogb!.macroRecordStop(); + setRecording(false); + if (!res.ok || !res.actions?.length) { + setMacroError(res.error ?? "nothing was recorded"); + setMacroInfo(null); + return; + } + setPendingActions(res.actions); + setMacroName(""); + setMacroInfo(`Recorded ${res.actions.length} events — name it and save, or discard.`); + } + }; + + const saveMacro = () => { + if (!pendingActions) return; + setSavingMacro(true); + setMacroError(null); + api("/api/macros", { + method: "POST", + body: JSON.stringify({ botId: bot.id, name: macroName.trim() || "Untitled macro", actions: pendingActions }), + }) + .then(() => { + setPendingActions(null); + setMacroInfo(null); + loadMacros(); + }) + .catch((e) => setMacroError(e.message)) + .finally(() => setSavingMacro(false)); + }; + + const replayMacro = async (m: Macro) => { + setReplayingId(m.id); + setMacroError(null); + setMacroInfo(null); + try { + const res = await window.ogb!.macroReplay(m.actions); + if (!res.ok) setMacroError(res.error ?? "replay failed"); + else setMacroInfo(`Replayed ${res.events ?? m.actions.length} events.`); + } catch (e) { + setMacroError(e instanceof Error ? e.message : String(e)); + } finally { + setReplayingId(null); + } + }; + + const deleteMacro = (id: string) => { + setMacroError(null); + api(`/api/macros/${id}`, { method: "DELETE" }) + .then(loadMacros) + .catch((e) => setMacroError(e.message)); + }; + // resolve the mode on open; box endpoints are only ever hit on the // cloud path, so local/off can never render a JSON error as an image useEffect(() => { @@ -199,7 +393,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { {/* Screen preview */}
{bot.name}'s screen - {phase === "local" && this Mac} + {phase === "local" && this computer}
{frameSrc ? ( @@ -270,19 +464,19 @@ export function ComputerPanel({ bot }: { bot: Bot }) { {/* Computer source */}
-
Runs on
-
- {bot.computer ? "" : "Auto: the cloud box when one exists, else this Mac. "}Pick where this bot's - computer lives. -
-
- {( - [ - ["cloud", "Cloud box"], - ["local", "This Mac"], - ["off", "Off"], - ] as const - ).map(([mode, label], i) => ( +
Runs on
+
+ {bot.computer ? "" : "Auto: the cloud box when one exists, else this computer. "}Pick where this + bot's computer lives. +
+
+ {( + [ + ["cloud", "Cloud box"], + ["local", "This computer"], + ["off", "Off"], + ] as const + ).map(([mode, label], i) => (
- Routines are recurring tasks this agent runs on a schedule. + Recurring tasks this agent runs on a schedule — like you asked it yourself.
- + + {routineError &&
{routineError}
} + + {routines.length > 0 && ( +
    + {routines.map((r) => ( +
  • +
    +
    +
    {r.name}
    +
    + every {r.everyMinutes} min{r.lastRunAt ? ` · last ran ${formatRoutineTime(r.lastRunAt)}` : " · never ran"} +
    +
    +
    + + + +
    +
    +
  • + ))} +
+ )} + + {showRoutineForm ? ( +
+ setRoutineName(e.target.value)} + placeholder="Name (e.g. Daily report)" + className="w-full rounded-lg border border-hairline/40 bg-inset px-3 py-2 text-[13px] text-ink placeholder:text-ink-secondary focus:border-hairline focus:outline-none" + /> +