Skip to content

feat: add Cursor Agent CLI driver (agent acp) - #2

Closed
NocturnusCoder wants to merge 528 commits into
mainfrom
NCT-cursor-agent-acp-45eb
Closed

feat: add Cursor Agent CLI driver (agent acp)#2
NocturnusCoder wants to merge 528 commits into
mainfrom
NCT-cursor-agent-acp-45eb

Conversation

@NocturnusCoder

@NocturnusCoder NocturnusCoder commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a Cursor Agent CLI driver so OpenMausBot can run bots on a Cursor subscription via agent acp (ACP stdio), matching the pattern used for Grok/Kimi/Droid.

Changes

  • Driver (server/drivers/acp/cursor.ts): spawns agent [--force] [--model …] acp; auth via CURSOR_API_KEY / CURSOR_AUTH_TOKEN or agent status/whoami; model catalog from plain agent models text (slug - Label with (default) / (current) markers)
  • Tests: fake ACP CLI + 15 unit/integration tests
  • Fleet: default cursor instance in product configs
  • UI: Cursor provider icon
  • Docs: docs/cursor.md

Model discovery

The live CLI does not support agent models --format json. The driver calls agent models and parses lines like:

auto - Auto (default)
composer-2.5 - Composer 2.5 (current)

Static fallback uses auto / composer-2.5 when listing fails.

Testing

  • pnpm exec vitest run server/drivers/acp/cursor.test.ts — 15 passed
  • Full suite previously green on this branch (typecheck + vitest)

Follow-up

Live smoke on a machine with agent login: agent models, one OpenMausBot turn with Cursor engine.

Open in Web Open in Cursor 

milind-soni and others added 30 commits August 16, 2026 14:08
fix: loopback + Origin hardening, 0600 config; add dweb MCP integration
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(#129)

* Add peer comms approval gate and async delegate_bot handoff

Bots could already consult each other synchronously via ask_bot; this
adds the two missing pieces of peer collaboration:

- delegate_bot: fire-and-forget handoff — the target bot picks the work
  up after the source turn settles, no blocking, same mirrored channel
  and chips for visibility
- approvePeerComms: per-bot opt-in gate that turns any peer contact
  into an Allow/Deny/Always-allow card answered by the human first

Also fixes PATCH /api/bots/:id silently dropping approvePeerComms.

* Peer comms approval gate + async delegate_bot: fix the blockers, then merge

Merges #128 (thanks @stephenlzc) with the fixes it needs to be safe. The
design is right — the depth cap still holds, a denied ask_bot doesn't hang
the caller, and async handoff never regains agents tools — but three
defects would have bitten users, and two of them were found by
reproducing them, not by reading.

1. The approval card was never settled. `resolvePeerComms` took an
   ApprovalBus it never used (`_bus`), and a harness-native card emits no
   `request.resolved`, so `answered`/`dismissed` stayed unset forever. The
   client keeps rendering an unanswered card and the composer is disabled
   behind it — so turning the toggle on and using it once left that bot's
   chat permanently unusable, and the card is persisted, so a restart
   didn't clear it. Cards now settle on answer AND on timeout, stale cards
   from a dead process are dismissed at boot, and deleting a bot cancels
   approvals naming it instead of making the caller wait out 15 minutes.

2. The drained delegation ran as an uncaught `void startTurn(...)`.
   startTurn rejects on ordinary conditions — busy target, deleted bot,
   unavailable provider — and an unhandled rejection is fatal on Node 24,
   which in the packaged app kills the harness child. Failures now land as
   a chip on the source thread.

3. Async removed the backpressure ask_bot got for free by making the
   caller wait, with nothing in its place. Queues are capped per turn, a
   failed or interrupted turn drops its queue (with a chip) instead of
   firing it later on an unrelated turn, and the busy check is re-taken
   after the approval await — a 15-minute-old snapshot must not start a
   turn on a bot that is now mid-turn, or mirror a "Messaged @x" chip for
   an exchange that never happens.

Also: an unresolvable `fromBotId` was the cheapest way past the gate — it
skipped approval AND mirroring while still running the peer turn — so it
is now a 403; the channel and chips are created after the verdict, not
before, so a denial leaves no trace of an exchange that didn't happen;
and the tool's refusals say what to do instead of returning a bare enum.

Tests: new server/peer-approval.test.ts pins the lifecycle (verified it
fails without the settle fix). Suite 374 passed / 8 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Big Stephen <chicong.lian@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Two places handed live credentials to anyone who asked for them.

The native protocol tee wrote every provider message verbatim to
~/.openmausbot/native/<threadId>.ndjson at 0644. ACP's session/new
carries mcpServers env, so the comms token and the box token were
sitting in a world-readable file — the same file people attach to bug
reports. It now goes through redactSecrets(), which keeps the shape
(which server, which variable, how long the value was) and masks the
value, and the file is written 0600.

The claude driver passed the whole MCP config as an argv string, which
put the Composio consumer key and the box token in `ps` output for
every local user for the life of the turn. The CLI accepts a file for
--mcp-config, so it now writes a 0600 temp file and removes it in
settle() — including on the crash path, which is where a cleanup hung
off the happy-path result would leak.

The fake CLI now reads the config file back the way the real one does,
so the driver tests assert on what the CLI received rather than on
argv, and two of them additionally assert the secret is NOT in argv.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Add shareable team manifests

* Address team sharing review feedback
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three things silently produce an installer that builds fine and is broken
in the field: a missing harness server (the mouse error page), a missing
built UI (black window), and an app-update.yml that either points
somewhere else or carries a publisherName the unsigned build cannot
satisfy — which makes electron-updater reject every update as untrusted.

None of them are checkable from the Mac the release is cut on, because
the artifact carries only the .exe. Assert them on the runner, where the
unpacked tree exists, and fail the build rather than ship it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keep the Connected Apps dialog above sticky page content, constrain it to the viewport, and add complete keyboard focus management.
* Stop handing clients the provider session cursors

`resumeCursors` is the harness's own bookkeeping — the native session id to
resume, per instance, per task. It goes out on every bot payload and every
`bot` SSE frame, and no client has ever read it.

It is harmless noise only for as long as every client is this machine. It is
still worth not sending: it is internal provider state on the wire, it makes
`GET /api/bots` bigger for no reason, and anything that ever consumes this
API from somewhere else inherits it by default.

Stripped at one chokepoint rather than at each call site, because there are
nine of them and a new broadcast should not have to remember.

The test asserts on the SSE bytes as well as the HTTP bodies. That is
deliberate: this was found by capturing real wire output rather than by
reading the code, and the wire is where it has to stay fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Strip the cursors from the standalone task responses too

The bot payload went through wireBot, but POST /tasks and PATCH on a
task also answer with the task record on its own, straight from the
store. A renamed task carries live resume cursors, so that response
leaked exactly what the rest of this change stopped sending.

wireTask is now the one place a task is trimmed: wireBot's nested
tasks, both botWithThread lists, and the two standalone responses.
The test asserts on task.body.task and on the rename response, and
deletes the bot from an outer finally so a failed assertion earlier
in the test doesn't leave it in the store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* A webhook turn does not inherit auto mode

Auto mode is something a person switches on for turns they are present
for. A webhook turn starts with nobody at the keyboard, on a payload
somebody else wrote — so inheriting that switch silently converts "I
trust this bot while I'm watching" into "I trust this bot at 3am on
whatever GitHub posts".

Everything else about webhook triggers defends against a FORGED request,
and that part is solid: hashed secret, constant-time compare, untrusted
data framing, loopback by default, rate limit. This is about an
authentic one. The guard standing behind auto mode is a regex list its
own comment calls not a security boundary, and it must not stand in for
a human.

So the rule lives with the other policy in auto-approve.ts rather than
as a condition at the call site: autoDecision refuses when the turn is
unattended, before any allow-list is consulted, so an "always allow"
grant can't widen into it either. The approval still appears in the chat
and can be answered if someone is around.

The mark goes on the DETACHED task's thread, since a webhook runs in its
own task — marking the bot's active thread would gate the wrong
conversation — and is cleared when the turn settles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Carry the unattended gate across peer-comms hops

Review caught that the gate held on the bot that read the webhook payload
and released on the bot that acted on it. A webhook turn starts at depth
0, so the agents tools ARE mounted and the bot can hand work to a
teammate — whose turn then ran with full auto mode and every always-allow
grant, nobody at the keyboard. One hop was all it took, and the depth cap
does not help.

The mark is now keyed by BOT rather than thread. A bot runs one turn at a
time so the identity is exact, and the comms paths know who is asking but
not always from which thread — ask_bot had no source thread at all.
Both ask_bot and delegate_bot now pass the caller's state to the turn
they start.

It expires by time instead of being cleared on turn.completed. Bus
subscribers fire in registration order and the delegation drain runs
AFTER the main fold, so clearing there blanked the flag before the hop
that needed to read it — the obvious fix, and wrong. A stale mark only
ever means "ask a human", so the failure direction is safe, and the TTL
stops the map growing without bound.

Two wiring tests, because the existing ones exercise the rule and would
all still pass if the mark were never set or never read. Both were
confirmed to FAIL with the wiring removed: one deletes the gate in the
fold, the other breaks only the hop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Pin the ask_bot hop too

Review mutated the other comms path — index.ts:124, the ask_bot one — and
both existing tests stayed green. The propagation was written correctly,
but nothing held it there, so a refactor could have silently reopened the
hole this branch exists to close, with the suite passing.

It is also the likelier path in practice: a webhook-triggered bot pulling
a teammate in for an answer mid-turn is more ordinary than handing the
work off asynchronously.

Third test drives FAKE_ACP_MODE=ask-peer. The fake asks whichever peer
list_bots returns first, so the other bots are hidden to make the target
deterministic. Verified to fail with that one line mutated:
"the asked teammate auto-approved — ask_bot did not carry the gate".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Keep unattended approvals guarded

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(effort): declare per-driver reasoning-effort levels

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP

* feat(effort): pass the level to Claude Code as --effort

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code

* feat(effort): send the level on Codex turn/start

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP

* feat(effort): pass the level to Grok as --reasoning-effort

* feat(effort): persist the level per bot and pass it into the turn

PATCH /api/bots/:id now validates modelSelection.effort against the
target instance's declared capabilities.effortLevels before it's
copied through the allowlist, and startTurn hands bot.modelSelection.effort
to sendTurn (cleared for cloud routines, which already borrow the
instance's default model).

Neither Codex nor Grok rejects an unknown level at their own protocol
boundary, so this PATCH-time check is the only real gate — it's what
keeps "none" (reserved for future engines) from ever reaching Claude
or Codex, whose declared lists exclude it. An unavailable target
instance offers an empty allowed list, so any effort is rejected: an
engine that isn't there cannot promise to honour a level.

The happy-path round-trip lives in server/store.test.ts instead of the
HTTP suite: server/index.test.ts's fixture pins the harness to a single
unknown-driver shadow instance (deliberately, for CI determinism), so
no bot in that suite ever has a live target to validate an accepted
level against. The API suite therefore covers the reject and no-op
branches only; the accept branch is covered at the store layer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP

* fix(effort): forward effortLevels through the instance registry descriptor

Task 1 added capabilities.effortLevels to ProviderAdapter, but
ProviderRegistry.describe() — the method behind GET /api/instances, which
is what the client actually reads — builds its own capabilities object
from just computerMcp and agentsMcp and dropped the new field. Every
engine's effort levels therefore came back undefined over HTTP, so Task
6's control (gated on capabilities.effortLevels.length) could never
render for any bot regardless of what the client did. Forward the field
the same way its two neighbors already are, with registry-level coverage
via a new FakeDriverOptions.effortLevels knob.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP

* feat(effort): add the per-bot effort control to settings

Widens the client InstanceInfo.capabilities and ModelSelection types to
carry effortLevels/effort, and adds effort levels to the modelSelection
patch allowlist (both the local SettingsPanel patch() helper and the
updateBot action) so the new segmented-button row — copied from the
Computer block's pattern — can clear the field back to the engine
default by sending no effort key at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP

* fix(effort): gate the level only when the target engine resolves

PATCH /api/bots/:id is the app's general-purpose bot endpoint, and its
callers send merged multi-field bodies: duplicateBot re-sends the source
bot's whole modelSelection beside name, title and description, and
updateBot debounces unrelated edits into one request. Rejecting an
effort the registry could not verify therefore failed the entire
request — duplicating a bot whose engine was offline lost its name,
title and description with it.

The gate now fires only when registry.get() actually resolves the
target. An instance that isn't there promises nothing either way, and
startTurn already refuses to run a turn on an unavailable instance, so
an unverified level never reaches a CLI. A genuinely bad level against a
live engine is still a 400.

Also drops the `as EffortLevel` cast: this is the boundary that decides
whether the string is a level, so it must not assert that it already is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP

* test(effort): replace the vacuous gate assertion with real coverage

The API suite's only effort test asserted that "banana" was rejected,
against a fixture whose sole instance is an unknown-driver shadow. Every
level was rejected there, so the assertion passed whether the gate
worked or rejected unconditionally — it could not fail.

What this fixture can honestly prove is the pass-through and the store's
replace semantics: a level round-trips through PATCH and GET, and
re-sending the selection with the effort key dropped clears it, which is
exactly the shape the panel's "Default" button sends. A comment records
which branch of the gate this does not reach, so the next reader does
not mistake it for full coverage; the comparison against a live engine's
declared list needs a resolvable instance, which this fixture
deliberately does not have.

Also retitles the store's persistence test, which said "defaulting to
none" while asserting undefined — the exact confusion between the
explicit "none" level and no override that the level type warns about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP

* fix(effort): keep the level when the model changes within one engine

pick() dispatched a bare { instanceId, model }, and both the reducer and
the server's patchBot replace modelSelection wholesale, so switching
Sonnet to Opus silently reset a configured xhigh back to the engine
default with nothing said. The effort row just changed under the user.

The selection now carries the effort across when the instance is
unchanged, and drops it when the instance changes: effort vocabularies
are declared per driver, so a level that survived an engine switch could
easily be one the new engine never offered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP

* fix(effort): stop the panel promising a reversal Codex cannot make

The comment on codex.ts's turn/start claimed null would clear a level.
That was an assumption, and it is wrong. Probed against the real
codex-cli 0.146.0 app-server: with the config default at "low" and the
thread overridden to "high", sending effort: null emitted no
thread/settings/updated and thread/resume still read back "high" —
byte-identical to omitting the key. Setting a level on that same path
does commit, so the null legs are genuinely inert, not merely unapplied.
There is no clearing mechanism at all: "" is rejected with
"reasoning_effort must not be empty", and thread/start carries no effort
field.

So the omit stays, and the copy changes instead. "(currently: engine
default)" asserted a state we cannot deliver on a Codex thread that had
already been sent a level; "(Default: no level is sent)" describes what
the app actually does, which is true on all three drivers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP

* fix(effort): label X-High correctly and mark the pressed segment

The row leans on `capitalize`, which renders "xhigh" as "Xhigh". Only
that one level needs spelling out; the rest capitalize cleanly.

The segmented buttons also carried no pressed state for assistive
technology — selection was conveyed by background colour alone, while
the panel's other controls already set role="switch"/aria-checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP

* fix(effort): type the client's effort fields with EffortLevel

The app declared `effort?: string` and `effortLevels?: readonly string[]`,
so a typo in a dispatch reached the server and came back as a 400 rather
than failing to compile.

Importing a type across the boundary is already how `src/lib/notify.ts`
consumes `server/notify.ts`, so this follows the existing seam rather
than opening a new one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A52XvU63rgJypGD8U9cmNP

* Validate reasoning effort at runtime

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: milind-soni <milindsoni201@gmail.com>
* Run a new webhook first event

* Present webhook command as a real task
maxkongerskov and others added 27 commits August 20, 2026 06:01
* Put Grok -m after agent so ACP stdio uses the local slug.

Grok 1.0.6 only applies agent flags between `agent` and `stdio`.
`grok -m omlx-slug agent stdio` is accepted as a TUI option and then
ignored, so session/new keeps [models].default (grok-4.6) and oMLX
never loads. Qwen/Hermes/Codex/Claude already bind the host in env
or settings; Grok's only bind is this -m slug.

* Pin Grok's local slug with session/set_model after session/new.

-m after agent is not enough: session/new still starts on
[models].default, so initialize reports grok-4.6 even when oMLX was
picked. Set the slug over the wire (same as Hermes/Droid) and report
that model on session.started.

---------

Co-authored-by: Max <ajsdaksfjhs@gmail.com>
…ontrol

Add Ubuntu screen preview and guarded local control betas
…#252)

Midnight (the current palette, unchanged) plus three alternatives, switchable
in Settings → General and remembered per machine. They fill a grid: dark/cool
is Midnight, light/warm Atelier, dark/warm Foundry, light/cool Lagoon.

This is mostly a CSS change because of a property this codebase already has:
916 token classes against 8 hardcoded hex values and no Tailwind greys. A skin
is therefore one block of custom properties — Lagoon cost 51 lines. The
selector is `[data-skin=…]` rather than `:root[data-skin=…]`, so any element
can open a skin context for its subtree, which is how the picker renders a
live miniature of each skin instead of a painted mockup.

One token is doing real work beyond theming: --color-accent-ink, the ink
carried by a filled accent or danger surface. Components hardcode `text-white`
on those fills at ~20 sites, which holds only while every accent is dark
enough to carry white. That is the constraint behind a measurement worth
having on its own: white on the current accent is 3.65:1 and on danger
3.10:1, both short of the 4.5:1 that AA asks of the 12-14px labels they carry.
Midnight sets the token to #ffffff so its rendering is byte-identical, while
Foundry can invert the relationship — brass fill, near-black lettering, 7.4:1
— which is what lets it read as lit metal rather than as a dark paint chip.

Same story for the disabled state: `disabled:opacity-40` works on a dark
ground and inverts on a light one, where Atelier's accent at 40% over white
lands on #d9bfa8 and white lettering measures 1.75:1. Disabled buttons get
their own colours instead of transparency.

Contrast is measured, not asserted: check-skin-contrast.mjs parses the
stylesheet, reads @theme and :root as the inherited base — so a token a skin
does NOT set is still measured as it ships — and fails on an undefined token
rather than skipping it. 27 pairs per skin. Midnight is advisory, since its
job is to be the original, gaps included.
Release Ubuntu installables for v0.1.25
…llisions (#277)

A team manifest is untrusted input — it can arrive from the remote catalog,
a GitHub repo, or a file someone shared. Import already only ever created
records (fresh ids from createBot, privileged member keys stripped by the
zod parse), but that guarantee was implicit: three accidental layers, no
single place that owned it, and no test that would catch a regression.

Two changes make it structural:

1. importedMemberProfile() is now the one boundary where a parsed member
   becomes bot fields. It is an allowlist built field by field — name,
   title, description, color, mascotExpression — so every privilege-bearing
   BotRecord field (autoApprove, alwaysAllow, chiefOfStaff,
   approvePeerComms, composio, computer, cloudBackend, cwd) is absent by
   construction, whatever the file claimed. A future manifest field cannot
   reach a bot record without consciously widening this return type. The
   route still forces composio: false after creation, because that is the
   one privilege where absence means allowed.

2. Name collisions are numbered, never merged. Display names are identity
   wherever bots address each other (@mention resolution, the Chief of
   Staff roster, peer-approval prompts), so an imported member wearing an
   existing bot's name could be mentioned or granted as if it were that
   bot. A colliding name now arrives visibly numbered ("Mira" -> "Mira 2"),
   case-insensitively, hidden bots included, capped at the 100-char member
   limit — the same convention the name generator already uses.

Re-importing the same file is documented behavior: it creates a second,
freshly numbered set and never reaches back into the first (a user's edits
to imported bots are theirs). Replace-mode archival is unchanged — it is
driven solely by the mode parameter the user chose, touches only
hidden/chiefOfStaff on their own bots, and nothing in the file influences
which bots it archives.

Tests cover the whole threat model at both layers: parse-level stripping of
every smuggled privileged field, profile-level allowlist + dedup edge cases
(case-insensitive, batch-internal, max-length), and route-level — claimed
ids go nowhere, every privileged field lands at its safe default, the armed
existing bot is untouched field for field, the single-Chief invariant
survives a chiefOfStaff claim, a legacy v1 room block neither creates nor
touches rooms, and a re-import after user edits leaves the edit intact.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Ship a macOS Intel (x64) build alongside Apple silicon

Closes #257. The published mac app was arm64-only; an Intel Mac cannot
run it and Rosetta does not translate in that direction.

- electron-builder.yml: dmg + zip for [arm64, x64]. The dmg section's
  own artifactName override was arch-less, so the x64 dmg silently
  OVERWROTE the arm64 one on the first dual-arch build and the feed
  listed one filename twice with two different hashes — it now carries
  ${arch}. extraResources resolve through dist-native/${arch} so each
  bundle ships its own natives.
- pnpm.supportedArchitectures.cpu = [arm64, x64]: pnpm installs both
  darwin CUA native packages (x64 exists at the exact pinned 0.20.0);
  lockfile is byte-identical.
- prepare-cua.mjs: stages per-arch dirs, asserts the driver binary is
  genuinely universal via lipo (a future non-universal pin fails at
  package time, not on a user's Intel Mac), and falls back to the
  official universal download when the locally installed CuaDriver.app
  is single-arch. The pure-JS SDK bundle is built once and shipped in
  both dirs.
- build-speech-helper.mjs: two swiftc passes targeting
  {arm64,x86_64}-apple-macos12 (the app's LSMinimumSystemVersion),
  lipo'd and asserted universal — bare swiftc built host-arch only,
  which inside an Intel app is a dictation helper that cannot launch.
- scripts/regenerate-mac-feed.mjs (new): refreshes latest-mac.yml
  hashes after notarization stapling rewrites artifact bytes,
  preserving electron-builder's file order (electron-updater filters
  that list by arch: arm64-marked files on Apple silicon, unmarked on
  Intel) and verifying every entry against the bytes on disk before
  finishing.
- smoke-cua.mjs follows the per-arch staging layout; README gains the
  Intel download row (stable name: OpenMausBot-intel.dmg).

Verified: four distinct artifacts; app binaries x86_64/arm64
respectively; universal cua-driver and speech helper inside the x64
bundle; the x64 app launched under Rosetta on this arm64 Mac with a
sandboxed data dir — server forked, /api/health static:true, all 8
spawned proxy paths resolving inside Resources/server; full suite
green (1187 passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* README: give the Intel build its own download button

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* ci: fail the run when suspiciously few tests were counted

A green `vitest run` only proves that everything that ran passed — it says
nothing about whether everything that should have run, ran. A test file that
never registers its tests (a rename that slips out of the include globs, a
directory dropped from the config, an import-time throw swallowed as an empty
collection) leaves the surviving files passing and CI green with a chunk of
the suite silently missing.

scripts/test-floor.mjs closes that gap: it runs the same suite with a JSON
reporter alongside the console one, counts the tests the run registered, and
fails below TEST_COUNT_FLOOR (970 — roughly 90% of today's 1081 tests, a
floor rather than an exact count so ordinary churn never touches it). It also
refuses to report a pass when the count cannot be determined at all, because
an uncountable run has exactly the shape of a collection failure. Registered
tests are counted rather than executed ones so the POSIX-only tests that
self-skip on Windows do not force a per-platform floor.

The `test` script's leading `vitest run` becomes `node scripts/test-floor.mjs`;
ci.yml is untouched and the broker/updater/packaged-server legs run as before.
The pass/fail decision is a pure function with its own unit tests, which run
inside the counted suite via a new scripts/**/*.test.mjs include glob.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: preserve targeted test runs

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Log every authorization decision to a fleet-wide audit file

The per-thread event log records that a permission request opened and
later resolved, but not WHY: whether a grant waved it through, which
guard held it, or that an unattended block withheld a grant that would
otherwise have fired. That reason exists for one moment at the
request.opened fold and was thrown away — so "which tool call was
allowed, denied, or carded, by which rule, for which bot" had no answer.

Now it does: ~/.openmausbot/decisions.ndjson, one row per decision,
written where the decision is made. Auto-approvals are logged only after
the provider takes the answer (same discipline as the transcript chip, so
the log never claims an approval nothing received); every card that
reaches a human logs which rule sent it there — including the most
audit-worthy row of all, `unattended-block`, a grant withheld because
nobody started the turn; and a human's allow/deny logs a second row when
it actually reaches the engine. Rule attribution comes from a new
autoVerdict() in auto-approve.ts that returns the matched guard regex or
granted key alongside the unchanged verdict, so the call site never
re-derives (and eventually mis-derives) the match. autoDecision() is now
a thin wrapper over it; no decision changes in this commit.

Same hygiene as the event tee: 0600, through redactSecrets, and
fire-and-forget — an audit log must never take down the decision it is
auditing. Rotation is a single .1 sidecar at 4MB, bounded rather than
archival. GET /api/decisions?limit=N reads the newest rows back, newest
last, matching the thread-events inspector. ask_bot peer-approval cards
never cross the runtime bus and stay deliberately out of scope.

Each wiring point was mutation-checked: dropping the card row, blanking
the rule, skipping the user row, and mislabeling the unattended block
each fail exactly the test written for them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: make decision auditing durable

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ion per driver (#279)

* Keep workspace credentials out of plaintext config and unrelated child env

Two halves of one rule — a credential should be readable only by the
process that needs it, and rest encrypted where the platform allows.

At rest: the packaged app already moved the Composio key out of
config.json into the OS-encrypted credentials.bin. The xai key, box
token, voice key and OpenCode Go key now get the same sweep on boot
(electron/workspace-credentials.mjs): plaintext values migrate into the
encrypted store and the field is DELETED, not blanked — the server saves
a mid-session change back into config.json, so a non-empty field is the
newest user intent (overwrites the store), "" is a clear (drops the
stored secret), and an absent field means already-migrated. The server
now prefers env over the file for every credential (the shell injects
one var per stored secret at spawn), with the file as the dev-mode
fallback; syncCredentialEnv keeps the running process's env in step with
a save so the boot-time value cannot shadow it until relaunch.

In children: instanceConfigs() used to copy XAI_API_KEY and BOX_TOKEN
into EVERY instance's environment; each key now goes only to the driver
that reads it (grok API, boxAgent), the pattern opencodeGo already
followed. And because the packaged server process now carries these
secrets in its own env, every engine-CLI spawn point (claude, codex,
antigravity, ACP core) strips WORKSPACE_CREDENTIAL_ENV from the child
env — an engine that brings its own login has no business inheriting
the box token or the voice key.

Migration is idempotent and lossless; a downgrade behaves like the
composio path (key re-entry, nothing corrupted). Every new guard was
mutation-checked: broken deliberately, watched the new test fail,
restored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: complete encrypted credential handling

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…k for your hands (#280)

* computer: let a person take the wheel, and let the bot ask for hands

A bot mid-task on its computer had no way to hand the keyboard to the
person, and the person had no way to take it: opening the desktop viewer
while the bot kept clicking meant two sets of hands on one mouse.

The harness now keeps a per-bot who-is-driving record. The person takes
and releases control from the computer panel; while they hold it, every
computer action the bot attempts is refused — not queued, because a
queued click lands after the person has moved on. The bot's only verb is
computer_request_help: it can plead (with a notification and a panel
banner) and wait for the hand-back, but it can never take control or
clear a hold.

Enforcement lives where the actions flow: the box REST adapter refuses
in its own tools, and the Local VM / VPS stdio bridges gain an opt-in
line-level gate that answers tools/call with a refusal — the far side is
Cua Driver's own MCP server, which has no concept of a person holding
the wheel, so the refusal has to come from the near side. With no gate
configured the bridges stay byte-for-byte transparent. The gate fails
open on control-endpoint errors: pausing is cooperation between the
person and their own bot, not a security boundary, and a harness hiccup
must not brick every computer mid-turn.

Host CUA ("This Mac") is deliberately not gated — the person is at that
keyboard already, and Stop remains the way to halt a turn there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: harden human computer takeover

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Actions -> Release -> Run workflow now builds macOS (arm64 + x64,
signed, notarized, stapled), Windows, and Ubuntu from ONE pinned commit,
verifies every artifact the way a user receives it, assembles a complete
draft on openmausbot-releases, and publishes when asked to.

Every gate encodes an incident from the hand-cut releases: clean output
dirs (0.1.15 broken seal), codesign before notarization (notarization
accepts invalid signatures), the packaged-server smoke (0.1.24 died on
launch on an unbundled import), the proxy-path probe (its fix broke
every helper while health stayed green), staple-then-hash (stapling
invalidates published feeds), blockmap regeneration, and
assemble-then-publish (0.1.24 sat invisible as a draft).

Needs four one-time secrets (Developer ID p12, App Store Connect API
key, releases-repo PAT) — documented in docs/releasing.md along with the
local fallback flow. smoke-packaged-server.mjs learns OMB_SMOKE_DIST so
the workflow can aim it at each built app's Resources/server.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
iPad has never had a layout pass; ship family 1 and revisit for 1.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: design configurable room turn timeout

* feat: persist room turn timeout setting

* test: cover room timeout config without provider reload

* fix: honor configurable room turn timeout

* feat: expose room timeout config to the app

* feat: add room timeout setting to general settings

* fix: serialize room timeout saves

* fix: release stalled room turns

* test: exercise room timeout update in a room
…ript (#269)

* Add one pinned message per thread, shown as a banner above the transcript

Closes #268. Threads accumulate exactly the content you keep scrolling
back to — the constraint, the decision, the file path, the answer. A
pin button joins the message hover actions; the one pinned message
renders as a compact banner (sender, one line, click to jump, X to
unpin) between the header and the transcript, in 1:1 threads and rooms.

pinnedMessageId on the bot and group records, through the existing
PATCH paths; stored verbatim and resolved against the transcript at
render, so a pin whose message was edited to another branch or deleted
simply renders nothing. Clearing sends "" (JSON.stringify drops
undefined), which the server normalizes to an unset field.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Cover room pinning and empty-string clearing on PATCH /api/groups.

The UI clears a pin with "", and rooms used the same field without an HTTP assertion.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
feat(ios): Messages-shaped companion — glass chrome, Updates bar, the desktop's mascot, tailed bubbles
* Adopt Apache License 2.0

* Complete Apache-only relicensing
Closes #263. The reaction bar offered five hardcoded emoji even though
the store and API accept any emoji string — every other reaction was
impossible from the UI. The quick strip stays exactly as it was; a "+"
at its end opens an anchored popover with a 22-emoji palette in a 6x4
grid, following the same dismiss contract as the sidebar menus
(outside click, Escape, window blur). Selecting toggles via the
existing toggleReaction dispatch — no store or server changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
…, 6.9" screenshots

- Bundle ids become com.openmausbot.app / .app.widgets: the .companion id
  is registered to another team and the portal refuses it; .app is the
  desktop's identity and registered cleanly under this team. Existing
  sideloads keep working; a store install pairs afresh.
- ExportOptions.plist: app-store-connect upload, automatic signing.
- Store screenshots at 1320×2868 (6.9"), dark and light, from the
  Simulator harness. Build 1.0.0 (1) uploaded from this configuration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
chore(ios): App Store packaging — com.openmausbot.app, export options, 6.9" screenshots
Co-authored-by: Will Sigmon <wjsigmon@gmail.com>
Co-authored-by: Will Sigmon <wjsigmon@gmail.com>
Co-authored-by: Will Sigmon <wjsigmon@gmail.com>
Wrap `agent acp` as a built-in subscription engine so bots can run on a
Cursor login (or CURSOR_API_KEY) instead of only Claude/Codex/Grok.

Co-authored-by: Nocturnus <NocturnusCoder@users.noreply.github.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7349458911

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

env: Record<string, string | undefined>,
run: typeof execCli = execCli,
): Promise<ModelCatalog> {
const stdout = await execText(run, cli, ["models", "--format", "json"], env);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry Cursor model discovery without unsupported format flag

When the installed Cursor CLI follows the documented parameter set, agent models --format json can exit on the unsupported --format option: Cursor documents agent models/--list-models for model listing, while command-specific --format is listed only for status, whoami, and about (https://cursor.com/docs/cli/reference/parameters). Since execText returns null on any error and this code never retries plain agent models or --list-models, the picker silently stays on the static fallback instead of the account's live model catalog.

Useful? React with 👍 / 👎.

@cursor
cursor Bot changed the base branch from openmausbot-main to main August 20, 2026 10:44
@cursor cursor Bot closed this Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.