diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..8dc3f87cd --- /dev/null +++ b/.github/copilot-instructions.md @@ -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-.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`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b1285e027..c76fdb8cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. | diff --git a/README.md b/README.md index 55c4209a3..86a53fdb9 100644 --- a/README.md +++ b/README.md @@ -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. Bot context menu @@ -120,9 +121,11 @@ Secrets are write-only: the UI only ever sees "configured" flags. -**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 @@ -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). | @@ -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. diff --git a/package.json b/package.json index cdf8570ef..7baf4c814 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openmausbot", "private": true, - "version": "0.1.13", + "version": "0.1.15", "type": "module", "main": "electron/main.mjs", "engines": { diff --git a/server/index.test.ts b/server/index.test.ts index bcc802270..76faa7f25 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -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); diff --git a/server/index.ts b/server/index.ts index 0236c5964..72eb0b961 100644 --- a/server/index.ts +++ b/server/index.ts @@ -16,6 +16,7 @@ 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 { decodePrompt, decodeSchedule, describeSchedule, RoutineStore } from "./routines.ts"; import { mentionedBots, Store, type Message } from "./store.ts"; const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799); @@ -112,6 +113,7 @@ let bootSelection = { instanceId: "claude", model: "claude-sonnet-5" }; const store = new Store(() => bootSelection); bootSelection = await defaultSelection(); store.seedIfEmpty(); +const routines = new RoutineStore(); // ── SSE fan-out to clients ───────────────────────────────────────────── const sseClients = new Set(); @@ -450,6 +452,65 @@ async function startTurn( })(); } +// ── routines (scheduled turns) ──────────────────────────────────────── +// A routine firing is just a user-less startTurn, so it flows through the +// same permission broker, event bus, and transcript as anything typed. +const ROUTINE_TICK_MS = 30_000; + +function broadcastRoutines(botId: string) { + broadcast({ kind: "routines", botId, routines: routines.forBot(botId) }); +} + +async function runRoutine( + routineId: string, + { advanceSchedule = true }: { advanceSchedule?: boolean } = {}, +) { + const routine = routines.get(routineId); + if (!routine) return; + const bot = store.bot(routine.botId); + if (!bot) { + routines.deleteForBot(routine.botId); + return; + } + // Automatic runs advance BEFORE the turn so failures cannot hot-loop. + // Manual previews record the run but preserve the scheduled occurrence. + routines.markRan(routine.id, { advanceSchedule }); + broadcastRoutines(bot.id); + + const marker = store.appendMessage(bot.threadId, { + role: "bot", + kind: "activity", + tool: { name: `routine: ${routine.title} (${describeSchedule(routine.schedule)})`, ok: true }, + }); + broadcast({ kind: "message", threadId: bot.threadId, message: marker }); + + try { + await startTurn(bot.id, routine.prompt); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + const failure = store.appendMessage(bot.threadId, { + role: "bot", + kind: "activity", + tool: { name: `routine skipped: ${message.slice(0, 160)}`, ok: false }, + }); + broadcast({ kind: "message", threadId: bot.threadId, message: failure }); + } +} + +async function routineTick() { + for (const routine of routines.due()) { + const bot = store.bot(routine.botId); + // a bot mid-turn keeps its slot: the routine simply waits for the next + // tick rather than stacking a second turn on top of a live one + if (bot?.busy) continue; + await runRoutine(routine.id); + } +} + +const routineTimer = setInterval(() => void routineTick(), ROUTINE_TICK_MS); +routineTimer.unref?.(); +void routineTick(); + // ── config hot-reload ───────────────────────────────────────────────── function configStatus() { return { @@ -591,9 +652,14 @@ const server = createServer(async (req, res) => { if (m && method === "PATCH") { const body = await readBody(req); const patch: Record = {}; - for (const key of ["name", "title", "description", "notifications", "modelSelection", "unread", "computer", "color", "mascotExpression", "pinned", "hidden"] as const) { + for (const key of ["name", "title", "description", "notifications", "modelSelection", "unread", "computer", "color", "mascotExpression", "pinned", "hidden", "sectionId"] as const) { if (body[key] !== undefined) patch[key] = body[key]; } + // a bot may only be filed under a section that exists; anything else + // reads as ungrouped rather than stranding it in a phantom group + if (patch.sectionId !== undefined && patch.sectionId !== null && !store.section(String(patch.sectionId))) { + return json(res, 400, { error: "no such section" }); + } const bot = store.patchBot(m[1], patch); if (!bot) return json(res, 404, { error: "no such bot" }); broadcast({ kind: "bot", bot }); @@ -607,6 +673,7 @@ const server = createServer(async (req, res) => { await registry.get(bot.modelSelection.instanceId)?.adapter.interruptTurn(bot.threadId).catch(() => {}); stopScreenPoller(bot.id); store.deleteBot(bot.id); + routines.deleteForBot(bot.id); for (const dir of [EVENTS_DIR, NATIVE_DIR]) { try { unlinkSync(join(dir, `${bot.threadId}.ndjson`)); @@ -713,6 +780,116 @@ const server = createServer(async (req, res) => { return json(res, 200, { ok: true }); } + // ── sidebar sections ── + // Membership lives on the bot (bot.sectionId); a section is just a + // named, ordered, collapsible group, so deleting one never deletes bots. + if (method === "GET" && path === "/api/sections") { + return json(res, 200, { sections: store.sectionList() }); + } + if (method === "POST" && path === "/api/sections") { + const body = await readBody(req); + const name = String(body.name ?? "").trim(); + if (!name) return json(res, 400, { error: "name required" }); + if (name.length > 60) return json(res, 400, { error: "name must be under 60 characters" }); + const section = store.createSection(name); + broadcast({ kind: "sections", sections: store.sectionList() }); + return json(res, 201, { section }); + } + m = path.match(/^\/api\/sections\/([\w-]+)$/); + if (m && (method === "PATCH" || method === "DELETE")) { + if (!store.section(m[1])) return json(res, 404, { error: "no such section" }); + if (method === "DELETE") { + store.deleteSection(m[1]); + broadcast({ kind: "sections", sections: store.sectionList() }); + // the bots that fell back to ungrouped changed too + for (const bot of store.bots) broadcast({ kind: "bot", bot }); + return json(res, 200, { ok: true }); + } + const body = await readBody(req); + const patch: { name?: string; order?: number; collapsed?: boolean } = {}; + if (body.name !== undefined) { + if (typeof body.name !== "string") return json(res, 400, { error: "name must be a string" }); + const name = body.name.trim(); + if (!name) return json(res, 400, { error: "name required" }); + if (name.length > 60) return json(res, 400, { error: "name must be under 60 characters" }); + patch.name = name; + } + if (body.order !== undefined) { + if (typeof body.order !== "number" || !Number.isFinite(body.order)) { + return json(res, 400, { error: "order must be a number" }); + } + patch.order = body.order; + } + if (body.collapsed !== undefined) { + if (typeof body.collapsed !== "boolean") { + return json(res, 400, { error: "collapsed must be a boolean" }); + } + patch.collapsed = body.collapsed; + } + const section = store.patchSection(m[1], patch); + broadcast({ kind: "sections", sections: store.sectionList() }); + return json(res, 200, { section }); + } + + // ── routines (scheduled turns) ── + m = path.match(/^\/api\/bots\/([\w-]+)\/routines$/); + if (m && method === "GET") { + if (!store.bot(m[1])) return json(res, 404, { error: "no such bot" }); + return json(res, 200, { routines: routines.forBot(m[1]) }); + } + if (m && method === "POST") { + const botId = m[1]; + if (!store.bot(botId)) return json(res, 404, { error: "no such bot" }); + const body = await readBody(req); + let routine; + try { + routine = routines.create({ + botId, + prompt: decodePrompt(body.prompt), + schedule: decodeSchedule(body.schedule), + title: body.title, + }); + } catch (e) { + return json(res, 400, { error: e instanceof Error ? e.message : String(e) }); + } + broadcastRoutines(botId); + return json(res, 201, { routine }); + } + m = path.match(/^\/api\/routines\/([\w-]+)$/); + if (m && (method === "PATCH" || method === "DELETE")) { + const existing = routines.get(m[1]); + if (!existing) return json(res, 404, { error: "no such routine" }); + if (method === "DELETE") { + routines.delete(existing.id); + broadcastRoutines(existing.botId); + return json(res, 200, { ok: true }); + } + const body = await readBody(req); + let routine; + try { + routine = routines.patch(existing.id, { + ...(body.prompt !== undefined ? { prompt: decodePrompt(body.prompt) } : {}), + ...(body.schedule !== undefined ? { schedule: decodeSchedule(body.schedule) } : {}), + ...(body.title !== undefined ? { title: body.title } : {}), + ...(body.enabled !== undefined ? { enabled: Boolean(body.enabled) } : {}), + }); + } catch (e) { + return json(res, 400, { error: e instanceof Error ? e.message : String(e) }); + } + broadcastRoutines(existing.botId); + return json(res, 200, { routine }); + } + 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" }); + if (store.bot(routine.botId)?.busy) { + return json(res, 409, { error: "the bot is already working — interrupt it first" }); + } + void runRoutine(routine.id, { advanceSchedule: false }); + return json(res, 202, { ok: true }); + } + // identity handshake for the packaged app's port fallback: the forked // child proves it is OURS by echoing its pid (a stray dev server has // the same API shape but a different pid) diff --git a/server/routines.test.ts b/server/routines.test.ts new file mode 100644 index 000000000..83e64779b --- /dev/null +++ b/server/routines.test.ts @@ -0,0 +1,268 @@ +// Routine scheduling is pure arithmetic plus a JSON file, so it tests +// without a clock or a server: every case pins a fixed `now` and asserts +// the next firing. Local-time cases build their expectations with local +// Date math so the suite passes in any timezone. +import { readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { DATA_DIR, ensureDirs } from "./config.ts"; +import { + CATCHUP_WINDOW_MS, + MAX_ROUTINE_TITLE_LENGTH, + RoutineStore, + decodePrompt, + decodeSchedule, + describeSchedule, + nextRunAfter, + type Schedule, +} from "./routines.ts"; + +const ROUTINES_FILE = join(DATA_DIR, "routines.json"); + +/** Local wall-clock helper: today at hh:mm, offset by `days`. */ +const localAt = (base: number, hour: number, minute: number, days = 0) => { + const d = new Date(base); + d.setHours(hour, minute, 0, 0); + d.setDate(d.getDate() + days); + return d.getTime(); +}; + +const freshStore = (now?: number) => { + ensureDirs(); + rmSync(join(DATA_DIR, "routines.json"), { force: true }); + return new RoutineStore(now); +}; + +describe("decodeSchedule", () => { + it("accepts the three kinds", () => { + expect(decodeSchedule({ kind: "interval", minutes: 30 })).toEqual({ kind: "interval", minutes: 30 }); + expect(decodeSchedule({ kind: "daily", hour: 9, minute: 5 })).toEqual({ kind: "daily", hour: 9, minute: 5 }); + expect(decodeSchedule({ kind: "weekly", day: 1, hour: 9, minute: 0 })).toEqual({ + kind: "weekly", + day: 1, + hour: 9, + minute: 0, + }); + }); + + it("throws on an unknown kind (the route turns this into a 400)", () => { + expect(() => decodeSchedule({ kind: "hourly" })).toThrow(/schedule.kind/); + expect(() => decodeSchedule(undefined)).toThrow(/schedule.kind/); + expect(() => decodeSchedule(null)).toThrow(/schedule.kind/); + }); + + it("rejects out-of-range and non-integer fields", () => { + expect(() => decodeSchedule({ kind: "interval", minutes: 0 })).toThrow(/minutes/); + expect(() => decodeSchedule({ kind: "interval", minutes: 10_081 })).toThrow(/minutes/); + expect(() => decodeSchedule({ kind: "interval", minutes: 1.5 })).toThrow(/minutes/); + expect(() => decodeSchedule({ kind: "daily", hour: 24, minute: 0 })).toThrow(/hour/); + expect(() => decodeSchedule({ kind: "daily", hour: 9, minute: 60 })).toThrow(/minute/); + expect(() => decodeSchedule({ kind: "weekly", day: 7, hour: 9, minute: 0 })).toThrow(/day/); + }); +}); + +describe("decodePrompt", () => { + it("trims and requires text", () => { + expect(decodePrompt(" summarize my inbox ")).toBe("summarize my inbox"); + expect(() => decodePrompt(" ")).toThrow(/required/); + expect(() => decodePrompt(undefined)).toThrow(/required/); + }); + + it("caps the length", () => { + expect(() => decodePrompt("x".repeat(4_001))).toThrow(/4000/); + }); +}); + +describe("nextRunAfter", () => { + const now = new Date(2026, 7, 13, 10, 30, 0, 0).getTime(); // Thursday + + it("adds the interval", () => { + expect(nextRunAfter({ kind: "interval", minutes: 15 }, now)).toBe(now + 15 * 60_000); + }); + + it("takes today's slot when it is still ahead", () => { + expect(nextRunAfter({ kind: "daily", hour: 18, minute: 0 }, now)).toBe(localAt(now, 18, 0)); + }); + + it("rolls to tomorrow once today's slot has passed", () => { + expect(nextRunAfter({ kind: "daily", hour: 9, minute: 0 }, now)).toBe(localAt(now, 9, 0, 1)); + }); + + it("is strictly after `from` — the current minute never re-fires", () => { + const exact = localAt(now, 10, 30); + expect(nextRunAfter({ kind: "daily", hour: 10, minute: 30 }, exact)).toBe(localAt(exact, 10, 30, 1)); + }); + + it("finds the next weekday, this week or next", () => { + // Thursday (4) 10:30 → Friday (5) 09:00 is this week + expect(nextRunAfter({ kind: "weekly", day: 5, hour: 9, minute: 0 }, now)).toBe(localAt(now, 9, 0, 1)); + // …Thursday 09:00 has passed, so it lands a full week out + expect(nextRunAfter({ kind: "weekly", day: 4, hour: 9, minute: 0 }, now)).toBe(localAt(now, 9, 0, 7)); + // …Monday (1) is three days ahead + expect(nextRunAfter({ kind: "weekly", day: 1, hour: 9, minute: 0 }, now)).toBe(localAt(now, 9, 0, 4)); + }); +}); + +describe("describeSchedule", () => { + it("says hours and days rather than raw minutes", () => { + expect(describeSchedule({ kind: "interval", minutes: 1 })).toBe("every minute"); + expect(describeSchedule({ kind: "interval", minutes: 45 })).toBe("every 45 minutes"); + expect(describeSchedule({ kind: "interval", minutes: 60 })).toBe("every hour"); + expect(describeSchedule({ kind: "interval", minutes: 180 })).toBe("every 3 hours"); + expect(describeSchedule({ kind: "interval", minutes: 1440 })).toBe("every day"); + expect(describeSchedule({ kind: "daily", hour: 9, minute: 5 })).toBe("every day at 9:05"); + expect(describeSchedule({ kind: "weekly", day: 1, hour: 17, minute: 0 })).toBe("every Monday at 17:00"); + }); +}); + +describe("RoutineStore", () => { + const every15: Schedule = { kind: "interval", minutes: 15 }; + let now: number; + + beforeEach(() => { + now = Date.now(); + }); + + it("creates with a derived title and the first firing scheduled", () => { + const store = freshStore(); + const routine = store.create({ botId: "bot-1", prompt: "check the build", schedule: every15 }, now); + expect(routine.title).toBe("check the build"); + expect(routine.enabled).toBe(true); + expect(routine.nextRunAt).toBe(now + 15 * 60_000); + expect(routine.lastRunAt).toBeUndefined(); + }); + + it("persists across instances", () => { + const store = freshStore(); + store.create({ botId: "bot-1", prompt: "standup", schedule: every15, title: "Standup" }, now); + expect(new RoutineStore(now).forBot("bot-1").map((r) => r.title)).toEqual(["Standup"]); + }); + + it("repairs and saves an invalid persisted next-run timestamp", () => { + freshStore(now); + writeFileSync( + ROUTINES_FILE, + JSON.stringify([ + { + id: "broken-time", + botId: "bot-1", + title: "Repair me", + prompt: "Run this", + schedule: every15, + enabled: true, + createdAt: now, + nextRunAt: null, + }, + ]), + ); + + const repaired = new RoutineStore(now).get("broken-time"); + expect(repaired?.nextRunAt).toBe(now + 15 * 60_000); + const saved = JSON.parse(readFileSync(ROUTINES_FILE, "utf8")) as Array<{ nextRunAt: number }>; + expect(saved[0].nextRunAt).toBe(now + 15 * 60_000); + }); + + it("normalizes and limits titles consistently on create and patch", () => { + const store = freshStore(); + const routine = store.create( + { botId: "bot-1", prompt: "check the build", schedule: every15, title: " Build check " }, + now, + ); + expect(routine.title).toBe("Build check"); + expect(store.patch(routine.id, { title: " " }, now)?.title).toBe("Build check"); + expect(() => + store.patch(routine.id, { title: "x".repeat(MAX_ROUTINE_TITLE_LENGTH + 1) }, now), + ).toThrow(/48 characters or fewer/); + }); + + it("lists a bot's routines soonest-first and ignores other bots", () => { + const store = freshStore(); + store.create({ botId: "bot-1", prompt: "later", schedule: { kind: "interval", minutes: 60 } }, now); + store.create({ botId: "bot-1", prompt: "sooner", schedule: every15 }, now); + store.create({ botId: "bot-2", prompt: "elsewhere", schedule: every15 }, now); + expect(store.forBot("bot-1").map((r) => r.prompt)).toEqual(["sooner", "later"]); + }); + + it("only returns enabled, past-due routines from due()", () => { + const store = freshStore(); + const ready = store.create({ botId: "bot-1", prompt: "ready", schedule: every15 }, now - 20 * 60_000); + store.create({ botId: "bot-1", prompt: "waiting", schedule: every15 }, now); + const off = store.create({ botId: "bot-1", prompt: "off", schedule: every15 }, now - 20 * 60_000); + store.patch(off.id, { enabled: false }, now); + + expect(store.due(now).map((r) => r.id)).toEqual([ready.id]); + }); + + it("schedules the next run from now, so a late turn cannot build a backlog", () => { + const store = freshStore(); + const routine = store.create({ botId: "bot-1", prompt: "poll", schedule: every15 }, now - 60 * 60_000); + const ran = store.markRan(routine.id, { now })!; + expect(ran.lastRunAt).toBe(now); + expect(ran.nextRunAt).toBe(now + 15 * 60_000); + expect(store.due(now)).toEqual([]); + }); + + it("records a manual run without consuming its scheduled occurrence", () => { + const store = freshStore(); + const routine = store.create({ botId: "bot-1", prompt: "poll", schedule: every15 }, now); + const nextRunAt = routine.nextRunAt; + const ran = store.markRan(routine.id, { now: now + 60_000, advanceSchedule: false })!; + expect(ran.lastRunAt).toBe(now + 60_000); + expect(ran.nextRunAt).toBe(nextRunAt); + }); + + it("disabling holds the slot; re-enabling restarts the countdown", () => { + const store = freshStore(); + const routine = store.create({ botId: "bot-1", prompt: "poll", schedule: every15 }, now); + const paused = store.patch(routine.id, { enabled: false }, now + 60_000)!; + expect(paused.nextRunAt).toBe(routine.nextRunAt); + + const resumed = store.patch(routine.id, { enabled: true }, now + 60_000)!; + expect(resumed.nextRunAt).toBe(now + 60_000 + 15 * 60_000); + }); + + it("re-scheduling recomputes the next firing", () => { + const store = freshStore(); + const routine = store.create({ botId: "bot-1", prompt: "poll", schedule: every15 }, now); + const patched = store.patch(routine.id, { schedule: { kind: "interval", minutes: 60 } }, now)!; + expect(patched.nextRunAt).toBe(now + 60 * 60_000); + }); + + it("deletes one routine, and all of a bot's routines with the bot", () => { + const store = freshStore(); + const a = store.create({ botId: "bot-1", prompt: "a", schedule: every15 }, now); + store.create({ botId: "bot-1", prompt: "b", schedule: every15 }, now); + store.create({ botId: "bot-2", prompt: "c", schedule: every15 }, now); + + expect(store.delete(a.id)).toBe(true); + expect(store.delete(a.id)).toBe(false); + expect(store.deleteForBot("bot-1")).toBe(1); + expect(store.forBot("bot-1")).toEqual([]); + expect(store.forBot("bot-2")).toHaveLength(1); + }); + + it("fires a recently-missed run on boot but rolls a stale one forward", () => { + const store = freshStore(); + const missed = store.create({ botId: "bot-1", prompt: "missed", schedule: every15 }, now - 30 * 60_000); + const stale = store.create({ botId: "bot-1", prompt: "stale", schedule: every15 }, now - 5 * 60 * 60_000); + expect(stale.nextRunAt).toBeLessThan(now - CATCHUP_WINDOW_MS); + + // reboot: the laptop was closed, both slots are in the past + const rebooted = new RoutineStore(now); + expect(rebooted.due(now).map((r) => r.id)).toEqual([missed.id]); + expect(rebooted.get(stale.id)!.nextRunAt).toBe(now + 15 * 60_000); + }); + + it("drops a routine whose schedule this build cannot decode", () => { + const store = freshStore(); + const keep = store.create({ botId: "bot-1", prompt: "keep", schedule: every15 }, now); + // simulate a config written by a newer build + const raw = store.all(); + raw.push({ ...keep, id: "from-the-future", schedule: { kind: "lunar" } as unknown as Schedule }); + store.patch(keep.id, {}, now); // force a save including the bogus entry + + const rebooted = new RoutineStore(now); + expect(rebooted.all().map((r) => r.id)).toEqual([keep.id]); + }); +}); diff --git a/server/routines.ts b/server/routines.ts new file mode 100644 index 000000000..13f8f28b1 --- /dev/null +++ b/server/routines.ts @@ -0,0 +1,259 @@ +// Routines — recurring tasks a bot runs on a schedule. The scheduler lives +// in the harness (server/index.ts) because it owns turns: a routine firing +// is just a user-less startTurn, so it flows through the same permission +// broker, event bus, and transcript as anything the human types. +// +// Persisted to ~/.openmausbot/routines.json alongside bots.json. Schedule +// math is pure and lives here so it can be tested without a clock. +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { DATA_DIR } from "./config.ts"; +import { newId } from "./contracts.ts"; + +/** Daily/weekly fire in the user's LOCAL time — a "9:00 standup" routine + * means 9am where the Mac is, not UTC. */ +export type Schedule = + | { kind: "interval"; minutes: number } + | { kind: "daily"; hour: number; minute: number } + | { kind: "weekly"; day: number; hour: number; minute: number }; + +export interface Routine { + id: string; + botId: string; + title: string; + prompt: string; + schedule: Schedule; + enabled: boolean; + createdAt: number; + lastRunAt?: number; + nextRunAt: number; +} + +const ROUTINES_FILE = join(DATA_DIR, "routines.json"); +export const MAX_ROUTINE_TITLE_LENGTH = 48; + +/** A routine that came due while the app was closed fires once if it was + * missed within this window; anything staler rolls forward silently, so a + * laptop shut for a week never wakes up to seven stacked runs. */ +export const CATCHUP_WINDOW_MS = 60 * 60_000; + +const MAX_INTERVAL_MINUTES = 7 * 24 * 60; +const MAX_PROMPT = 4_000; + +function intIn(value: unknown, min: number, max: number, field: string): number { + const n = typeof value === "number" ? value : Number(value); + if (!Number.isInteger(n) || n < min || n > max) { + throw new Error(`routine: ${field} must be an integer between ${min} and ${max}`); + } + return n; +} + +/** Decode an untrusted schedule; throws on invalid (same contract as a + * driver's decodeConfig — callers turn the throw into a 400). */ +export function decodeSchedule(raw: unknown): Schedule { + const s = raw as { kind?: unknown } | null | undefined; + switch (s?.kind) { + case "interval": + return { kind: "interval", minutes: intIn((s as any).minutes, 1, MAX_INTERVAL_MINUTES, "minutes") }; + case "daily": + return { + kind: "daily", + hour: intIn((s as any).hour, 0, 23, "hour"), + minute: intIn((s as any).minute, 0, 59, "minute"), + }; + case "weekly": + return { + kind: "weekly", + day: intIn((s as any).day, 0, 6, "day"), + hour: intIn((s as any).hour, 0, 23, "hour"), + minute: intIn((s as any).minute, 0, 59, "minute"), + }; + default: + throw new Error('routine: schedule.kind must be "interval", "daily", or "weekly"'); + } +} + +export function decodePrompt(raw: unknown): string { + const text = String(raw ?? "").trim(); + if (!text) throw new Error("routine: prompt required"); + if (text.length > MAX_PROMPT) throw new Error(`routine: prompt must be under ${MAX_PROMPT} characters`); + return text; +} + +export function decodeTitle(raw: unknown, fallback: string): string { + const text = String(raw ?? "").trim(); + if (text.length > MAX_ROUTINE_TITLE_LENGTH) { + throw new Error(`routine: title must be ${MAX_ROUTINE_TITLE_LENGTH} characters or fewer`); + } + return text || fallback.trim().slice(0, MAX_ROUTINE_TITLE_LENGTH); +} + +/** The next firing strictly after `from`. Pure — no Date.now() inside. */ +export function nextRunAfter(schedule: Schedule, from: number): number { + if (schedule.kind === "interval") return from + schedule.minutes * 60_000; + + const at = new Date(from); + at.setHours(schedule.hour, schedule.minute, 0, 0); + if (schedule.kind === "weekly") { + at.setDate(at.getDate() + ((schedule.day - at.getDay() + 7) % 7)); + if (at.getTime() <= from) at.setDate(at.getDate() + 7); + return at.getTime(); + } + if (at.getTime() <= from) at.setDate(at.getDate() + 1); + return at.getTime(); +} + +/** A short human label for the schedule — shared by the UI and the + * activity line the bot posts when a routine fires. */ +export function describeSchedule(schedule: Schedule): string { + const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + const clock = (h: number, m: number) => `${h}:${String(m).padStart(2, "0")}`; + switch (schedule.kind) { + case "interval": { + const { minutes } = schedule; + if (minutes % (24 * 60) === 0) { + const days = minutes / (24 * 60); + return days === 1 ? "every day" : `every ${days} days`; + } + if (minutes % 60 === 0) { + const hours = minutes / 60; + return hours === 1 ? "every hour" : `every ${hours} hours`; + } + return minutes === 1 ? "every minute" : `every ${minutes} minutes`; + } + case "daily": + return `every day at ${clock(schedule.hour, schedule.minute)}`; + case "weekly": + return `every ${DAYS[schedule.day]} at ${clock(schedule.hour, schedule.minute)}`; + } +} + +export class RoutineStore { + private routines: Routine[] = []; + + constructor(now = Date.now()) { + let repaired = false; + mkdirSync(DATA_DIR, { recursive: true }); + try { + const raw = JSON.parse(readFileSync(ROUTINES_FILE, "utf8")); + this.routines = Array.isArray(raw) ? raw : []; + } catch { + this.routines = []; + } + // a routine written by a newer build (unknown schedule kind) is dropped + // rather than crashing the boot — same spirit as a shadow instance + this.routines = this.routines.filter((r) => { + try { + r.schedule = decodeSchedule(r.schedule); + if (!Number.isFinite(r.nextRunAt)) { + r.nextRunAt = nextRunAfter(r.schedule, now); + repaired = true; + } + return true; + } catch { + repaired = true; + return false; + } + }); + if (this.rollForwardStale(now) || repaired) this.save(); + } + + /** Missed-run policy on boot: anything staler than the catch-up window + * jumps to its next future firing. Returns true when something moved. */ + private rollForwardStale(now: number): boolean { + let moved = false; + for (const r of this.routines) { + if (r.nextRunAt < now - CATCHUP_WINDOW_MS) { + r.nextRunAt = nextRunAfter(r.schedule, now); + moved = true; + } + } + return moved; + } + + private save() { + writeFileSync(ROUTINES_FILE, JSON.stringify(this.routines, null, 2)); + } + + all(): Routine[] { + return this.routines; + } + + forBot(botId: string): Routine[] { + return this.routines.filter((r) => r.botId === botId).sort((a, b) => a.nextRunAt - b.nextRunAt); + } + + get(id: string): Routine | null { + return this.routines.find((r) => r.id === id) ?? null; + } + + create(input: { botId: string; prompt: string; schedule: Schedule; title?: unknown }, now = Date.now()): Routine { + const routine: Routine = { + id: newId(), + botId: input.botId, + title: decodeTitle(input.title, input.prompt), + prompt: input.prompt, + schedule: input.schedule, + enabled: true, + createdAt: now, + nextRunAt: nextRunAfter(input.schedule, now), + }; + this.routines.push(routine); + this.save(); + return routine; + } + + patch( + id: string, + patch: { title?: unknown; prompt?: string; schedule?: Schedule; enabled?: boolean }, + now = Date.now(), + ) { + const routine = this.get(id); + if (!routine) return null; + if (patch.title !== undefined) routine.title = decodeTitle(patch.title, routine.title); + if (patch.prompt !== undefined) routine.prompt = patch.prompt; + if (patch.schedule !== undefined) routine.schedule = patch.schedule; + if (patch.enabled !== undefined) routine.enabled = patch.enabled; + // a re-scheduled or re-enabled routine restarts its countdown from now + if (patch.schedule || patch.enabled === true) routine.nextRunAt = nextRunAfter(routine.schedule, now); + this.save(); + return routine; + } + + /** Routines that should fire at `now`, soonest first. */ + due(now = Date.now()): Routine[] { + return this.routines.filter((r) => r.enabled && r.nextRunAt <= now).sort((a, b) => a.nextRunAt - b.nextRunAt); + } + + /** Stamp a firing and schedule the next one from `now` — never from the + * missed slot, so a slow turn can't build a backlog. */ + markRan( + id: string, + { now = Date.now(), advanceSchedule = true }: { now?: number; advanceSchedule?: boolean } = {}, + ): Routine | null { + const routine = this.get(id); + if (!routine) return null; + routine.lastRunAt = now; + if (advanceSchedule) routine.nextRunAt = nextRunAfter(routine.schedule, now); + this.save(); + return routine; + } + + delete(id: string): boolean { + const before = this.routines.length; + this.routines = this.routines.filter((r) => r.id !== id); + if (this.routines.length === before) return false; + this.save(); + return true; + } + + /** Routines die with their bot. */ + deleteForBot(botId: string): number { + const before = this.routines.length; + this.routines = this.routines.filter((r) => r.botId !== botId); + const removed = before - this.routines.length; + if (removed) this.save(); + return removed; + } +} diff --git a/server/search.test.ts b/server/search.test.ts new file mode 100644 index 000000000..6b253b8f5 --- /dev/null +++ b/server/search.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { score } from "../src/lib/search.ts"; + +describe("score", () => { + it("keeps literal matches positive even at large offsets", () => { + expect(score(`${"x".repeat(1_000)}needle`, "needle")).toBeGreaterThan(0); + expect(score(`${"word ".repeat(1_000)}needle`, "needle")).toBeGreaterThan(0); + }); + + it("preserves match-kind ordering after bounding positional penalties", () => { + const exact = score("needle", "needle"); + const prefix = score(`needle${"x".repeat(1_000)}`, "needle"); + const wordStart = score(`${"word ".repeat(1_000)}needle`, "needle"); + const substring = score(`${"x".repeat(1_000)}needle`, "needle"); + const subsequence = score("n-x-e-x-e-x-d-x-l-x-e", "needle"); + + expect(exact).toBeGreaterThan(prefix); + expect(prefix).toBeGreaterThan(wordStart); + expect(wordStart).toBeGreaterThan(substring); + expect(substring).toBeGreaterThan(subsequence); + }); +}); diff --git a/server/store.test.ts b/server/store.test.ts index e2de098aa..97b4cc255 100644 --- a/server/store.test.ts +++ b/server/store.test.ts @@ -191,3 +191,76 @@ describe("Store", () => { expect(reloaded.bot(bot.id)?.busy).toBe(false); }); }); + +describe("Store sections", () => { + beforeEach(() => { + rmSync(DATA_DIR, { recursive: true, force: true }); + }); + + it("appends new sections to the end of the order and persists them", () => { + const store = new Store(selection); + const work = store.createSection("Work"); + const life = store.createSection("Life"); + + expect(work.order).toBeLessThan(life.order); + expect(work.collapsed).toBe(false); + expect(new Store(selection).sectionList().map((s) => s.name)).toEqual(["Work", "Life"]); + }); + + it("orders by `order`, breaking ties on creation so the list is always total", () => { + const store = new Store(selection); + const a = store.createSection("A"); + const b = store.createSection("B"); + store.patchSection(b.id, { order: a.order }); + + // same order value: the older section still sorts first + expect(store.sectionList().map((s) => s.id)).toEqual([a.id, b.id]); + }); + + it("renames and collapses without touching membership", () => { + const store = new Store(selection); + const section = store.createSection("Work"); + const bot = store.createBot(); + store.patchBot(bot.id, { sectionId: section.id }); + + store.patchSection(section.id, { name: "Deep work", collapsed: true }); + + const reloaded = new Store(selection); + expect(reloaded.section(section.id)).toMatchObject({ name: "Deep work", collapsed: true }); + expect(reloaded.bot(bot.id)?.sectionId).toBe(section.id); + }); + + it("deleting a section returns its bots to ungrouped instead of deleting them", () => { + const store = new Store(selection); + const section = store.createSection("Work"); + const filed = store.createBot(); + const loose = store.createBot(); + store.patchBot(filed.id, { sectionId: section.id }); + + expect(store.deleteSection(section.id)).toBe(true); + expect(store.deleteSection(section.id)).toBe(false); + + const reloaded = new Store(selection); + expect(reloaded.sectionList()).toEqual([]); + expect(reloaded.bots.map((b) => b.id).sort()).toEqual([filed.id, loose.id].sort()); + expect(reloaded.bot(filed.id)?.sectionId).toBeNull(); + }); + + it("tolerates a corrupt sections.json by starting with no sections", () => { + const store = new Store(selection); + store.createSection("Work"); + writeFileSync(join(DATA_DIR, "sections.json"), "{not json"); + + expect(new Store(selection).sectionList()).toEqual([]); + }); + + it("keeps a bot whose section no longer exists — it just reads as ungrouped", () => { + const store = new Store(selection); + const bot = store.createBot(); + store.patchBot(bot.id, { sectionId: "section-from-another-machine" }); + + const reloaded = new Store(selection); + expect(reloaded.bot(bot.id)).not.toBeNull(); + expect(reloaded.section("section-from-another-machine")).toBeNull(); + }); +}); diff --git a/server/store.ts b/server/store.ts index 263c366aa..7b72d2eaf 100644 --- a/server/store.ts +++ b/server/store.ts @@ -70,6 +70,10 @@ export interface BotRecord { /** which computer the bot acts on: its cloud box, this Mac (local CUA), * or none. Unset = auto (box when it exists, else local when available). */ computer?: "cloud" | "local" | "off"; + /** the sidebar section this bot sits in; unset/unknown = ungrouped. A + * section id that no longer exists reads as ungrouped rather than + * hiding the bot. */ + sectionId?: string | null; /** true after an edit/branch-switch rewound the visible conversation: * provider sessions still hold the abandoned branch, so the next turn * must start fresh (drop cursors) and replay the surviving path. */ @@ -80,7 +84,20 @@ export interface BotRecord { createdAt: number; } +/** A named, collapsible group in the sidebar. Sections own only their own + * identity — membership lives on the bot (bot.sectionId), so deleting a + * section can never take a bot with it. */ +export interface SectionRecord { + id: string; + name: string; + /** ascending; ties break on createdAt so ordering is always total */ + order: number; + collapsed: boolean; + createdAt: number; +} + const BOTS_FILE = join(DATA_DIR, "bots.json"); +const SECTIONS_FILE = join(DATA_DIR, "sections.json"); const messagesFile = (threadId: string) => join(DATA_DIR, `messages-${threadId}.json`); const COLORS: MausColor[] = [ @@ -131,6 +148,7 @@ interface ThreadState { export class Store { bots: BotRecord[] = []; + sections: SectionRecord[] = []; private threads = new Map(); private defaultSelection: () => ModelSelection; @@ -142,6 +160,12 @@ export class Store { } catch { this.bots = []; } + try { + const raw = JSON.parse(readFileSync(SECTIONS_FILE, "utf8")); + this.sections = Array.isArray(raw) ? raw : []; + } catch { + this.sections = []; + } // busy never survives a restart — no turn does either for (const b of this.bots) b.busy = false; } @@ -150,6 +174,58 @@ export class Store { writeFileSync(BOTS_FILE, JSON.stringify(this.bots, null, 2)); } + private saveSections() { + writeFileSync(SECTIONS_FILE, JSON.stringify(this.sections, null, 2)); + } + + /** Sections in display order. */ + sectionList(): SectionRecord[] { + return [...this.sections].sort((a, b) => a.order - b.order || a.createdAt - b.createdAt); + } + + section(id: string): SectionRecord | null { + return this.sections.find((s) => s.id === id) ?? null; + } + + createSection(name: string): SectionRecord { + const section: SectionRecord = { + id: newId(), + name, + // new sections land at the end of the current order + order: this.sections.reduce((max, s) => Math.max(max, s.order), -1) + 1, + collapsed: false, + createdAt: Date.now(), + }; + this.sections.push(section); + this.saveSections(); + return section; + } + + patchSection(id: string, patch: Partial>) { + const section = this.section(id); + if (!section) return null; + Object.assign(section, patch); + this.saveSections(); + return section; + } + + /** Removing a section never removes its bots — they fall back to + * ungrouped, which is also what an unknown sectionId already reads as. */ + deleteSection(id: string): boolean { + if (!this.section(id)) return false; + this.sections = this.sections.filter((s) => s.id !== id); + let movedBots = false; + for (const bot of this.bots) { + if (bot.sectionId === id) { + bot.sectionId = null; + movedBots = true; + } + } + this.saveSections(); + if (movedBots) this.saveBots(); + return true; + } + private thread(threadId: string): ThreadState { let t = this.threads.get(threadId); if (t) return t; diff --git a/src/App.tsx b/src/App.tsx index a8a1508cc..a76f0b006 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { SettingsPanel } from "@/components/SettingsPanel"; import { PluginsPanel } from "@/components/PluginsPanel"; import { ComputerPanel } from "@/components/ComputerPanel"; import { AppSettingsPanel } from "@/components/AppSettingsPanel"; +import { CommandPalette } from "@/components/CommandPalette"; import { UpdateBanner } from "@/components/UpdateBanner"; function Shell() { @@ -70,6 +71,7 @@ function Shell() { {state.appSettingsOpen && } {state.pluginsOpen && } + ); } diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 231121e63..119401825 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -357,18 +357,20 @@ function ActivityChip({ message }: { message: Message }) {
{tool.ok === undefined ? ( ) : failed ? ( - + ) : ( - + )} - {tool.name} + {tool.name}
); diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx new file mode 100644 index 000000000..3fc8675aa --- /dev/null +++ b/src/components/CommandPalette.tsx @@ -0,0 +1,296 @@ +// Command palette (⌘K). Every entry is derived from live state — the bots +// in the store, the provider instances the harness reports, the sections +// that exist right now — so the palette can never offer a bot, model, or +// section that isn't really there. Nothing here is a fixed list except the +// app's own panels, which are structural. +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Bot as BotIcon, + CalendarClock, + Cpu, + FolderPlus, + Monitor, + Pin, + PinOff, + Puzzle, + Search, + Settings, + SlidersHorizontal, + Square, + Trash2, +} from "lucide-react"; +import { useStore, type Bot } from "@/state/store"; +import { scoreAny } from "@/lib/search"; +import { cn } from "@/lib/cn"; + +interface Command { + id: string; + label: string; + /** shown right-aligned: the group this command belongs to */ + group: string; + hint?: string; + icon: React.ReactNode; + /** extra text the query may match (bot titles, model ids, …) */ + keywords?: Array; + danger?: boolean; + run: () => void; +} + +export function CommandPalette() { + const { state, dispatch } = useStore(); + const [query, setQuery] = useState(""); + const [active, setActive] = useState(0); + const listRef = useRef(null); + + const open = state.paletteOpen; + const close = () => dispatch({ type: "togglePalette", open: false }); + const selected: Bot | undefined = state.bots.find((b) => b.id === state.selectedId); + + const commands = useMemo(() => { + const list: Command[] = []; + + // Jump to any bot that exists right now + for (const bot of state.bots.filter((b) => !b.hidden)) { + const section = state.sections.find((s) => s.id === bot.sectionId); + list.push({ + id: `bot:${bot.id}`, + label: bot.name, + group: section ? section.name : "Bots", + hint: bot.busy ? "working…" : bot.title || undefined, + icon: , + keywords: [bot.title, bot.description], + run: () => dispatch({ type: "select", id: bot.id }), + }); + } + + // Switch the selected bot's model — options come from the harness's + // live instance list, so unavailable providers simply aren't offered + if (selected) { + for (const instance of state.instances) { + if (instance.snapshot.state !== "available") continue; + for (const model of instance.models.options) { + list.push({ + id: `model:${instance.instanceId}:${model.id}`, + label: `${selected.name} → ${model.label}`, + group: "Model", + hint: instance.displayName, + icon: , + keywords: [model.id, instance.displayName, instance.driverKind], + run: () => + dispatch({ + type: "setModel", + botId: selected.id, + selection: { instanceId: instance.instanceId, model: model.id }, + }), + }); + } + } + + // File the selected bot into a section that exists + for (const section of state.sections) { + if (section.id === selected.sectionId) continue; + list.push({ + id: `section:${section.id}`, + label: `Move ${selected.name} to ${section.name}`, + group: "Sections", + icon: , + run: () => dispatch({ type: "updateBot", botId: selected.id, patch: { sectionId: section.id } }), + }); + } + + list.push( + { + id: "bot:pin", + label: selected.pinned ? `Unpin ${selected.name}` : `Pin ${selected.name}`, + group: "Bot", + icon: selected.pinned ? ( + + ) : ( + + ), + run: () => dispatch({ type: "updateBot", botId: selected.id, patch: { pinned: !selected.pinned } }), + }, + { + id: "bot:settings", + label: "Bot settings", + group: "Panels", + icon: , + run: () => dispatch({ type: "toggleSettings", open: true }), + }, + { + id: "bot:computer", + label: "Bot's computer", + group: "Panels", + icon: , + run: () => dispatch({ type: "toggleComputer", open: true }), + }, + { + id: "bot:routines", + label: "Routines", + group: "Panels", + hint: "schedule a recurring task", + icon: , + // routines live inside the computer panel + run: () => dispatch({ type: "toggleComputer", open: true }), + }, + ); + + if (selected.busy) { + list.push({ + id: "bot:interrupt", + label: `Stop ${selected.name}`, + group: "Bot", + icon: , + danger: true, + run: () => dispatch({ type: "interrupt", botId: selected.id }), + }); + } + + list.push({ + id: "bot:delete", + label: `Delete ${selected.name}`, + group: "Bot", + icon: , + danger: true, + run: () => dispatch({ type: "deleteBot", botId: selected.id }), + }); + } + + list.push( + { + id: "app:new-bot", + label: "New bot", + group: "App", + icon: , + run: () => dispatch({ type: "newBot" }), + }, + { + id: "app:plugins", + label: "Plugins", + group: "Panels", + icon: , + run: () => dispatch({ type: "togglePlugins", open: true }), + }, + { + id: "app:settings", + label: "App settings", + group: "Panels", + icon: , + run: () => dispatch({ type: "toggleAppSettings", open: true }), + }, + ); + + return list; + }, [state.bots, state.sections, state.instances, selected, dispatch]); + + const results = useMemo(() => { + const q = query.trim(); + if (!q) return commands.slice(0, 40); + return commands + .map((command) => ({ command, rank: scoreAny([command.label, command.group, ...(command.keywords ?? [])], q) })) + .filter((entry) => entry.rank > 0) + .sort((a, b) => b.rank - a.rank) + .slice(0, 40) + .map((entry) => entry.command); + }, [commands, query]); + + // reset each time it opens; keep the highlight inside the result list + useEffect(() => { + if (open) { + setQuery(""); + setActive(0); + } + }, [open]); + useEffect(() => setActive(0), [query]); + useEffect(() => { + listRef.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: "nearest" }); + }, [active, results]); + + // ⌘K / ctrl+K toggles from anywhere + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + dispatch({ type: "togglePalette" }); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [dispatch]); + + if (!open) return null; + + const runActive = () => { + const command = results[active]; + if (!command) return; + command.run(); + close(); + }; + + return ( +
e.target === e.currentTarget && close()} + > +
+
+ + setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") close(); + if (e.key === "Enter") (e.preventDefault(), runActive()); + if (e.key === "ArrowDown") (e.preventDefault(), setActive((i) => Math.min(i + 1, results.length - 1))); + if (e.key === "ArrowUp") (e.preventDefault(), setActive((i) => Math.max(i - 1, 0))); + }} + placeholder="Search bots, models, sections, panels…" + className="w-full min-w-0 bg-transparent text-[15px] text-ink placeholder:text-ink-secondary focus:outline-none" + /> + + esc + +
+ +
+ {results.map((command, i) => ( + + ))} + {results.length === 0 && ( +
+ Nothing matches “{query.trim()}”. +
+ )} +
+
+
+ ); +} diff --git a/src/components/ComputerPanel.tsx b/src/components/ComputerPanel.tsx index 407ae27bd..0beae5437 100644 --- a/src/components/ComputerPanel.tsx +++ b/src/components/ComputerPanel.tsx @@ -6,7 +6,6 @@ // prefers the cloud box when one exists, else local inside the app. import { useEffect, useRef, useState } from "react"; import { - CalendarClock, ExternalLink, Loader2, Monitor, @@ -17,6 +16,7 @@ import { } from "lucide-react"; import { useStore, type Bot } from "@/state/store"; import { ApiKeyRow } from "./ApiKeys"; +import { Routines } from "./Routines"; import { cn } from "@/lib/cn"; async function api(path: string, init?: RequestInit): Promise { @@ -320,22 +320,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { {/* Routines */} -
-
- - Routines -
-
- Routines are recurring tasks this agent runs on a schedule. -
- -
+ ); diff --git a/src/components/Routines.tsx b/src/components/Routines.tsx new file mode 100644 index 000000000..804ab9e40 --- /dev/null +++ b/src/components/Routines.tsx @@ -0,0 +1,338 @@ +// Routines — the recurring tasks a bot runs on a schedule. The harness owns +// the clock (server/routines.ts fires them as user-less turns), so this is a +// thin CRUD view over /api/bots/:id/routines that re-reads while it's open, +// letting a firing move the countdown without a manual refresh. +import { useCallback, useEffect, useRef, useState } from "react"; +import { CalendarClock, Loader2, Play, Plus, Trash2 } from "lucide-react"; +import { api } from "@/state/store"; +import { cn } from "@/lib/cn"; + +type Schedule = + | { kind: "interval"; minutes: number } + | { kind: "daily"; hour: number; minute: number } + | { kind: "weekly"; day: number; hour: number; minute: number }; + +interface Routine { + id: string; + botId: string; + title: string; + prompt: string; + schedule: Schedule; + enabled: boolean; + createdAt: number; + lastRunAt?: number; + nextRunAt: number; +} + +const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; +const INTERVALS: Array<[number, string]> = [ + [15, "15 minutes"], + [30, "30 minutes"], + [60, "hour"], + [180, "3 hours"], + [360, "6 hours"], + [720, "12 hours"], +]; + +/** Mirror of the server's describeSchedule — a shared label is not worth a + * package boundary between the app and the harness. */ +function describeSchedule(s: Schedule): string { + const clock = (h: number, m: number) => `${h}:${String(m).padStart(2, "0")}`; + if (s.kind === "daily") return `Every day at ${clock(s.hour, s.minute)}`; + if (s.kind === "weekly") return `Every ${DAYS[s.day]} at ${clock(s.hour, s.minute)}`; + if (s.minutes % 1440 === 0) return s.minutes === 1440 ? "Every day" : `Every ${s.minutes / 1440} days`; + if (s.minutes % 60 === 0) return s.minutes === 60 ? "Every hour" : `Every ${s.minutes / 60} hours`; + return `Every ${s.minutes} minutes`; +} + +function countdown(nextRunAt: number): string { + const ms = nextRunAt - Date.now(); + if (ms <= 0) return "any moment now"; + const minutes = Math.round(ms / 60_000); + if (minutes < 60) return `in ${Math.max(1, minutes)}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `in ${hours}h ${minutes % 60}m`; + return `in ${Math.floor(hours / 24)}d ${hours % 24}h`; +} + +export function Routines({ botId }: { botId: string }) { + const [loaded, setLoaded] = useState<{ botId: string; routines: Routine[] } | null>(null); + const [error, setError] = useState(null); + const [busyId, setBusyId] = useState(null); + const [composing, setComposing] = useState(false); + const [saving, setSaving] = useState(false); + const loadRequest = useRef(0); + const activeBotId = useRef(botId); + activeBotId.current = botId; + const routines = loaded?.botId === botId ? loaded.routines : null; + + const [prompt, setPrompt] = useState(""); + const [kind, setKind] = useState("daily"); + const [minutes, setMinutes] = useState(60); + const [time, setTime] = useState("09:00"); + const [day, setDay] = useState(1); + + const load = useCallback(() => { + if (activeBotId.current !== botId) return; + const request = ++loadRequest.current; + api(`/api/bots/${botId}/routines`) + .then((body) => { + if (request !== loadRequest.current || activeBotId.current !== botId) return; + if (!Array.isArray(body.routines)) throw new Error("Invalid routines response"); + setLoaded({ botId, routines: body.routines }); + setError(null); + }) + .catch((e) => { + if (request !== loadRequest.current || activeBotId.current !== botId) return; + setError(e instanceof Error ? e.message : String(e)); + }); + }, [botId]); + + // re-read while the panel is open: the countdown ticks and a routine that + // fires server-side should move on its own + useEffect(() => { + setLoaded(null); + setError(null); + setComposing(false); + load(); + const timer = setInterval(load, 20_000); + return () => { + loadRequest.current += 1; + clearInterval(timer); + }; + }, [load]); + + const act = async (id: string, run: () => Promise) => { + setBusyId(id); + setError(null); + try { + await run(); + load(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusyId(null); + } + }; + + const create = async () => { + const [hour, minute] = time.split(":").map(Number); + if ( + kind !== "interval" && + (!Number.isInteger(hour) || + !Number.isInteger(minute) || + hour < 0 || + hour > 23 || + minute < 0 || + minute > 59) + ) { + setError("Enter a valid time between 00:00 and 23:59."); + return; + } + const schedule: Schedule = + kind === "interval" + ? { kind: "interval", minutes } + : kind === "daily" + ? { kind: "daily", hour, minute } + : { kind: "weekly", day, hour, minute }; + setSaving(true); + setError(null); + try { + await api(`/api/bots/${botId}/routines`, { method: "POST", body: JSON.stringify({ prompt, schedule }) }); + setPrompt(""); + setComposing(false); + load(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + }; + + const fieldClass = + "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"; + + return ( +
+
+ + Routines +
+
+ Routines are recurring tasks this agent runs on a schedule. +
+ + {routines === null ? ( +
+ Loading… +
+ ) : ( + routines.length > 0 && ( +
+ {routines.map((routine, i) => ( +
0 && "border-t border-hairline/40")}> +
+
+
{routine.title}
+
+ {describeSchedule(routine.schedule)} + {routine.enabled ? ` · ${countdown(routine.nextRunAt)}` : " · paused"} +
+
+ + + +
+
+ ))} +
+ ) + )} + + {error &&
{error}
} + + {composing ? ( +
+