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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# OpenMausBot — Copilot instructions

A macOS Electron chat app where every "bot" in the sidebar is a real agent (the `claude`,
`codex`, `grok`, or `gemini` CLI installed on the user's machine) driven by a local harness
server. Read [`server/contracts.ts`](../server/contracts.ts) first — it is the whole
architecture in one file.

## Commands

Requires **Node 24+** and **pnpm** (`packageManager: pnpm@10.33.0`). There is no linter —
`tsc` with `strict` + `noUnusedLocals` + `noUnusedParameters` is the lint gate.

```sh
pnpm install # ELECTRON_SKIP_BINARY_DOWNLOAD=1 skips the Electron download (CI does this)
pnpm dev:server # harness server → 127.0.0.1:8799
pnpm dev # Vite app → 127.0.0.1:5199 (proxies /api to the harness)
pnpm dev:desktop # Electron shell (expects both of the above running)

pnpm typecheck # app (tsconfig.json) + server (tsconfig.server.json)
pnpm test # vitest, server/**/*.test.ts only
pnpm test server/harness/bus.test.ts # one file
pnpm exec vitest run server/drivers/claude.test.ts -t "decodeConfig" # one test by name
pnpm build # typecheck + vite build
pnpm package # full macOS .dmg via electron-builder (signing/notarization)
```

`electron-builder.yml` sets `notarize: false`, so `pnpm package` only *signs* with the Developer ID;
notarization is a separate step (`--config.mac.notarize=true` with `APPLE_ID` /
`APPLE_APP_SPECIFIC_PASSWORD` / `APPLE_TEAM_ID`, or `xcrun notarytool` afterwards). If the checkout
lives under an iCloud-managed folder such as `~/Documents`, signing fails with `resource fork, Finder
information, or similar detritus not allowed` — the file provider stamps `com.apple.FinderInfo` on the
output tree. Build to a path outside it:
`--config.directories.output=/tmp/omb-release`.

CI (`.github/workflows/ci.yml`) runs `pnpm typecheck && pnpm test` on macOS, Ubuntu, and
Windows — the Windows leg is the guardrail for portability, and POSIX-only tests self-skip
there. **`pnpm typecheck && pnpm test` must pass before any PR.**

## Architecture

Two processes, one canonical event stream.

- **Harness server** (`server/`, plain Node `http`, no framework) owns *every* agent process.
`ProviderRegistry` turns the config map into live `ProviderInstance`s; `EventBus` fans every
adapter's events into one stream.
- **App** (`src/`, React 19 + Tailwind 4) holds **no transports of its own**: it dispatches
typed commands over HTTP and folds one SSE stream (`GET /api/events`) into a single reducer
in `src/state/store.tsx`.
- **Electron** (`electron/`, plain `.mjs`) is the macOS shell: dictation, screen capture, the
`cua-driver` daemon, auto-updates. Packaged, it forks the compiled `dist-server` on Electron's
own Node and serves the built UI from one origin (no dev proxy).

Rules that hold the design together:

- **Drivers normalize, never invent.** Each `server/drivers/*.ts` flattens a provider's native
protocol (Claude stream-JSON, Codex JSON-RPC app-server, ACP) into the canonical
`RuntimeEvent` union in `contracts.ts`. The bus **drops any event whose `provider` doesn't
match the emitting instance's `driverKind`**.
- **The event stream is the source of truth.** The persisted transcript (`~/.openmausbot/messages-<threadId>.json`)
and every client view are projections folded from it in `server/index.ts`. Events are also
tee'd to per-thread NDJSON in `~/.openmausbot/events/` — read those when debugging a turn.
- **Unknown or broken configs degrade to a shadow, never a crash.** `decodeConfig` *throws* on
invalid config and `create` *rejects* (never throws synchronously); the registry turns both
into an `unavailable` shadow snapshot so a config written by a newer build round-trips
safely. Do not "fix" this by validating driver slugs up front.
- **`~/.openmausbot/`** holds everything: `config.json` (keys), `bots.json` (bot records,
thread→instance binding, per-instance `resumeCursors`), `routines.json` (scheduled turns),
`sections.json` (sidebar groups), transcripts, event logs.

## Conventions

**Imports.** Server code imports relative paths **with the `.ts` extension** (`from "./config.ts"`)
— it runs under Node type-stripping in dev and `rewriteRelativeImportExtensions` at build. App
code uses the `@/` alias for `src/`.

**Never build command strings for a shell.** No `shell: true`, no `cmd.exe` quoting — model
names, personas, and MCP config JSON travel through `argv`. Every agent-CLI spawn goes through
`augmentedPath()` (`server/env-path.ts`), which repairs the bare PATH a Finder-launched app
inherits.

**Platform gating.** `server/` must stay portable Node. macOS-only code (TCC, Swift helpers,
`~/Library`) belongs in `electron/` behind `process.platform === "darwin"`. POSIX-only calls
need a gated Windows equivalent, not a silent failure.

**Secrets are write-only.** Keys land in `config.json` via `PUT /api/config`; the API only ever
reports `configured` booleans. Never log, echo, or bake a key into argv.

**`dist-server/` is build output** — never hand-edit it and never include it in a PR.

**Adding a provider** = one file in `server/drivers/` implementing `ProviderDriver` + one line in
`builtIn.ts`, plus a contract test. A missing CLI must surface as
`snapshot() → { state: "unavailable", reason }` and a failed spawn as a failed turn — never a
hang, never a crash.

**Permissions and asks** reach the user as `request.opened` events rendered as inline cards.
Claude gets them via the MCP stdio broker in `server/permission-proxy.ts` (its **stdout is the
MCP channel — never `console.log` there**).

**Peer comms are depth-capped.** A user-initiated turn is depth 0 and may get the `agents` MCP
tools; a turn invoked through `ask_bot` runs at depth 1 with no agents tools, so A→B works but
B→C and A→B→A loops never start (`MAX_COMMS_DEPTH` in `server/index.ts`).

**Routines are turns, not a side channel.** A scheduled routine (`server/routines.ts`) fires through
the same `startTurn` as a typed message, so it inherits the permission broker, event bus, and
transcript. Schedule math is pure and lives in `routines.ts`; the ticking scheduler lives in
`index.ts` and advances the clock *before* running, so a slow or failing turn can't hot-loop.

**Sections own no bots.** A sidebar section (`sections.json`) is just a named, ordered, collapsible
group; membership lives on `bot.sectionId`. Deleting a section returns its bots to ungrouped — which
is also how an unknown `sectionId` reads — so a group can never take a bot with it.

**UI matching lives in `src/lib/search.ts`.** The sidebar filter and the ⌘K palette share one
ranking (exact > prefix > word-start > substring > subsequence). Palette entries are generated from
live state — bots, *available* instances, real sections — so it can never offer something that isn't
there.

**UI** uses Tailwind v4 with the palette defined as `@theme` tokens in `src/styles.css` (e.g.
`bg-panel`, `text-ink-secondary`, `text-accent`) — use the tokens, not raw hex. Compose classes
with `cn()` from `@/lib/cn`. UI PRs need before/after screenshots.

## Tests

Colocated as `server/**/*.test.ts`; `vite.config.ts` sets `fileParallelism: false` because the
suite spawns real processes. Three layers: unit (registry, bus, store — use
`server/testing/fake-driver.ts`), driver contract (spawns the scripted fake CLIs in
`server/testing/`, failure modes toggled by env var such as `FAKE_CLAUDE_MODE=exit-early`), and
API smoke (`server/index.test.ts` boots the real server).

- **No sleeps.** Wait on the event that proves the behavior via `recordEvents(adapter).until(...)`
from `server/testing/events.ts`. A test that needs a timeout to pass is wrong.
- **Never touch the real `~/.openmausbot`** — `server/testing/setup.ts` points `HOME`/`USERPROFILE`
at a temp dir; keep it that way.
- Tests that spawn a shebang script are gated `describe.skipIf(process.platform === "win32")`.
- Extend the fake CLIs rather than mocking `child_process`.
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pnpm test:watch # same, in watch mode
| `server/contracts.ts` | The driver SPI and canonical runtime event types. The whole architecture in one file — read it first. |
| `server/drivers/` | One file per provider (Claude, Codex, Grok, cloud computer). Adding a provider = one file + one registration line in `builtIn.ts`. |
| `server/harness/` | Registry (configs → live instances, unknown → shadow) and the fan-in event bus. |
| `server/routines.ts` | Scheduled turns: pure schedule math + a JSON store; the scheduler itself lives in `index.ts`. |
| `server/index.ts` | The HTTP + SSE API the app talks to. |
| `server/testing/` | Test fakes: an in-memory driver, plus scripted fake `claude` / `codex` CLIs. |
| `src/` | The React chat app. No transports of its own — HTTP commands out, one SSE stream in. |
Expand Down
20 changes: 11 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,9 @@ OAuth once, and every bot can use them as tools.

### 🗂 Manage bots like chats

Right-click any bot: pin, mark unread, edit profile, duplicate, copy conversation ID, hide, delete. It's a
messaging app — your agents behave like contacts.
Right-click any bot: pin, move to a section, mark unread, edit profile, duplicate, copy conversation
ID, hide, delete. Group them into collapsible sections, filter the roster by name, role, or anything
said in the thread, and jump anywhere with ⌘K.

<img src="docs/screenshots/context-menu.png" alt="Bot context menu" width="100%">

Expand All @@ -120,9 +121,11 @@ Secrets are write-only: the UI only ever sees "configured" flags.
</tr>
</table>

**Also in the box:** streaming replies with tool-run activity chips · native macOS dictation from the
composer mic (on-device Apple speech recognition — desktop app) · SupaMaus cursor mascots with role-aware
expressions · screenshots of the bot's work folded into the transcript.
**Also in the box:** routines — recurring tasks a bot runs on a schedule (hourly, daily, or weekly),
fired by the harness as ordinary turns so they hit the same approvals and transcript · streaming replies
with tool-run activity chips · native macOS dictation from the composer mic (on-device Apple speech
recognition — desktop app) · SupaMaus cursor mascots with role-aware expressions · screenshots of the
bot's work folded into the transcript.

## How it works

Expand Down Expand Up @@ -155,7 +158,7 @@ flowchart LR
|---|---|---|
| Drivers | `server/drivers/` | One per provider: Claude, Codex, and Grok Build over their local CLIs (stream-JSON / JSON-RPC / ACP), plus a cloud-computer agent. Unknown drivers degrade to "unavailable", never crash the fleet. |
| Harness | `server/harness/` | Registry (configs → live instances) and the fan-in event bus every client folds. |
| API | `server/index.ts` | Bots, turns, approvals, model catalog, computer lifecycle, connectors, config — HTTP + SSE. |
| API | `server/index.ts` | Bots, turns, approvals, model catalog, computer lifecycle, routines, sections, connectors, config — HTTP + SSE. |
| App | `src/` | The chat shell. Server-backed store, one reducer, zero client-side transports. |
| Desktop | `electron/` | macOS + Windows shells: dictation helper (SFSpeechRecognizer, macOS only), local screen capture, CUA bridge (macOS only). |

Expand Down Expand Up @@ -196,9 +199,8 @@ pnpm package:win # Windows installer + zip → release/
## 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 the Linux shell hasn't been attempted (macOS and Windows both run end to end; the harness
itself is portable Node).
computer use → routines on a schedule. Rough edges to expect: the Linux shell hasn't been attempted
(macOS and Windows both run end to end; the harness itself is portable Node).

Contributions welcome — the driver SPI in [`server/contracts.ts`](server/contracts.ts) is deliberately
small; adding a provider is one file in [`server/drivers/`](server/drivers/) plus a one-line registration.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "openmausbot",
"private": true,
"version": "0.1.13",
"version": "0.1.15",
"type": "module",
"main": "electron/main.mjs",
"engines": {
Expand Down
159 changes: 159 additions & 0 deletions server/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,165 @@ describe("harness HTTP API", () => {
expect(after.body.profile).toEqual({ name: "Ada Lovelace", email: "Ada@Example.com" });
});

it("creates, lists, re-schedules, and deletes a routine", async () => {
const { body } = await api("GET", "/api/bots");
const bot = body.bots[0];

const created = await api("POST", `/api/bots/${bot.id}/routines`, {
prompt: " summarize what changed today ",
schedule: { kind: "daily", hour: 9, minute: 30 },
});
expect(created.status).toBe(201);
expect(created.body.routine).toMatchObject({
botId: bot.id,
prompt: "summarize what changed today",
title: "summarize what changed today",
enabled: true,
schedule: { kind: "daily", hour: 9, minute: 30 },
});
expect(created.body.routine.nextRunAt).toBeGreaterThan(Date.now());
const routineId = created.body.routine.id;

const listed = await api("GET", `/api/bots/${bot.id}/routines`);
expect(listed.body.routines.map((r: { id: string }) => r.id)).toEqual([routineId]);

const paused = await api("PATCH", `/api/routines/${routineId}`, { enabled: false, title: "Daily digest" });
expect(paused.body.routine).toMatchObject({ enabled: false, title: "Daily digest" });

const rescheduled = await api("PATCH", `/api/routines/${routineId}`, {
schedule: { kind: "interval", minutes: 90 },
});
expect(rescheduled.body.routine.nextRunAt).toBeGreaterThan(Date.now() + 89 * 60_000);

expect((await api("DELETE", `/api/routines/${routineId}`)).status).toBe(200);
expect((await api("GET", `/api/bots/${bot.id}/routines`)).body.routines).toEqual([]);
});

it("rejects an invalid routine and 404s unknown ids", async () => {
const { body } = await api("GET", "/api/bots");
const bot = body.bots[0];

const noPrompt = await api("POST", `/api/bots/${bot.id}/routines`, {
prompt: " ",
schedule: { kind: "daily", hour: 9, minute: 0 },
});
expect(noPrompt.status).toBe(400);
expect(noPrompt.body.error).toContain("prompt");

const badSchedule = await api("POST", `/api/bots/${bot.id}/routines`, {
prompt: "do a thing",
schedule: { kind: "fortnightly" },
});
expect(badSchedule.status).toBe(400);
expect(badSchedule.body.error).toContain("schedule.kind");

expect((await api("GET", "/api/bots/nope/routines")).status).toBe(404);
expect((await api("PATCH", "/api/routines/nope", { enabled: false })).status).toBe(404);
expect((await api("POST", "/api/routines/nope/run")).status).toBe(404);
});

it("deletes a bot's routines along with the bot", async () => {
const bot = (await api("POST", "/api/bots")).body.bot;
const routine = (
await api("POST", `/api/bots/${bot.id}/routines`, {
prompt: "check in",
schedule: { kind: "interval", minutes: 30 },
})
).body.routine;

await api("DELETE", `/api/bots/${bot.id}`);
expect((await api("PATCH", `/api/routines/${routine.id}`, { enabled: false })).status).toBe(404);
});

it("fires a routine on demand without consuming the scheduled occurrence", async () => {
const { body } = await api("GET", "/api/bots");
const bot = body.bots[0];
const created = await api("POST", `/api/bots/${bot.id}/routines`, {
prompt: "run the morning check",
title: "Morning check",
schedule: { kind: "interval", minutes: 60 },
});
const routine = created.body.routine;

expect((await api("POST", `/api/routines/${routine.id}/run`)).status).toBe(202);

// the fire path is async — wait on the transcript that proves it ran
const deadline = Date.now() + 10_000;
let activity: Array<{ tool?: { name: string; ok?: boolean } }> = [];
for (;;) {
const bots = await api("GET", "/api/bots");
const messages = bots.body.bots.find((b: { id: string }) => b.id === bot.id).messages;
activity = messages.filter((msg: { kind: string }) => msg.kind === "activity");
if (activity.length >= 2 || Date.now() > deadline) break;
await new Promise((r) => setTimeout(r, 100));
}

// the marker says which routine fired…
expect(activity[0].tool!.name).toBe("routine: Morning check (every hour)");
// …and the seeded bot points at the ghost instance, so the turn fails
// loudly as an activity chip instead of hanging
expect(activity[1].tool!.name).toContain("routine skipped");
expect(activity[1].tool!.ok).toBe(false);

// a manual preview records the attempt without moving the automatic slot
const after = (await api("GET", `/api/bots/${bot.id}/routines`)).body.routines[0];
expect(after.lastRunAt).toBeGreaterThan(0);
expect(after.nextRunAt).toBe(routine.nextRunAt);

await api("DELETE", `/api/routines/${routine.id}`);
});

it("groups bots into sections and keeps them when a section is deleted", async () => {
const created = await api("POST", "/api/sections", { name: " Work " });
expect(created.status).toBe(201);
expect(created.body.section).toMatchObject({ name: "Work", collapsed: false });
const sectionId = created.body.section.id;

const bot = (await api("POST", "/api/bots")).body.bot;
const filed = await api("PATCH", `/api/bots/${bot.id}`, { sectionId });
expect(filed.body.bot.sectionId).toBe(sectionId);

const renamed = await api("PATCH", `/api/sections/${sectionId}`, { name: "Deep work", collapsed: true });
expect(renamed.body.section).toMatchObject({ name: "Deep work", collapsed: true });

expect((await api("GET", "/api/sections")).body.sections).toHaveLength(1);

// deleting the section must NOT take the bot with it
expect((await api("DELETE", `/api/sections/${sectionId}`)).status).toBe(200);
expect((await api("GET", "/api/sections")).body.sections).toEqual([]);
const after = (await api("GET", "/api/bots")).body.bots.find((b: { id: string }) => b.id === bot.id);
expect(after).toBeDefined();
expect(after.sectionId).toBeNull();

await api("DELETE", `/api/bots/${bot.id}`);
});

it("rejects an unnamed section and filing a bot under one that does not exist", async () => {
expect((await api("POST", "/api/sections", { name: " " })).status).toBe(400);
expect((await api("POST", "/api/sections", { name: "x".repeat(61) })).status).toBe(400);
expect((await api("PATCH", "/api/sections/nope", { name: "x" })).status).toBe(404);
expect((await api("DELETE", "/api/sections/nope")).status).toBe(404);

const { body } = await api("GET", "/api/bots");
const bad = await api("PATCH", `/api/bots/${body.bots[0].id}`, { sectionId: "not-a-section" });
expect(bad.status).toBe(400);
expect(bad.body.error).toContain("section");
});

it("rejects malformed section patch fields instead of coercing them", async () => {
const created = await api("POST", "/api/sections", { name: "Work" });
const sectionId = created.body.section.id;

for (const patch of [
{ name: null },
{ order: "2" },
{ order: null },
{ collapsed: 1 },
]) {
expect((await api("PATCH", `/api/sections/${sectionId}`, patch)).status).toBe(400);
}
});

it("404s unknown routes with the route in the error", async () => {
const res = await api("GET", "/api/definitely-not-a-route");
expect(res.status).toBe(404);
Expand Down
Loading