feat(web): queue a shell command for the developer's Mac, held for a signed-in Mac to take and answer (mac commands 1/4)

Merged
DA
6d ago
+4985-0

What

Dean: "Let's give Luke a tool to run commands on the local mac, just like the tool you have." Ruling: no approval, no screening or redaction, on by default, for every agent (planning brain, coding agents, their subagents), starting in the home folder, with one Settings switch to turn it off. This is part 1 of 4: the service's queue. Nothing declares the tool yet.

How a command reaches the Mac. The call's voice socket exists only during a call, and coding agents run without one, so the Mac pulls instead. A signed-in Mac with the switch on holds GET /api/mac-commands open, the same held read the account inbox already uses. The service holds it up to 20 s and looks every 500 ms. Each read marks the Mac as listening (mac_command_listener), then answers with every command waiting for the account, claimed for that Mac. An agent's call (runOnMac, server/hosted/mac-command.ts) refuses at once in one line if no Mac read within 45 s. Otherwise it inserts a mac_command row and waits for the result until the command's timeout (60 s default, at most 600 s) plus a 30 s grace. The Mac posts the result back (POST /api/mac-commands), and the call deletes the row either way, so a row exists only while its call does.

  1. Wire: @sidecar/hosted/mac-command-wire: the command, the result (exitCode, stdout, stderr, each at most 20,000 characters, truncated, timedOut), and the settle request.
  2. Store: mac_command and mac_command_listener, both account-scoped and cascading with the account, in one idempotent migration. mac-command-store.ts:
    • A command is claimed once and settled once, only by its own account.
    • A command can be claimed only within 30 s of being queued, so one taken late still finishes inside the call's wait, and a row left behind by a call that died is never run later.
    • runOnMac checks the ask against the wire schema, so an empty command or a timeout outside 1–600 s gets a one-line not-run and is never queued.
  3. Two Macs on one account: whichever Mac's held read takes a command runs it, and only that one, because the claim is a single atomic UPDATE … RETURNING. Picking the most recently active Mac would need a per-Mac id and focus tracking passed through the host, so it's left out of this simple version.
  4. Route: mac-commands-app.ts, plus the regenerated rewrites.

Tests

  • packages/database/src/models/__tests__/mac-command-store.test.ts (real dialect, TestClock): claimed and settled once; another account claims, settles and reads nothing; a stale or withdrawn row is never claimed; listening is counted from the last read only.
  • apps/web/tests/mac-commands-app.test.ts, through the route and runOnMac:
    • refuses when no Mac has read
    • a Mac's read is handed the queued command, and its posted result is what the call answers
    • another account can't settle the command, and it is withdrawn after the call answers
    • a Mac that never posts gets the no-answer line after the timeout plus grace
    • an unreadable ask is refused before it is queued
    • a command no Mac took within the claim window is never handed to a later read
    • a request with no bearer gets 401
  • storage-schema.test.ts lists the two new tables.

./scripts/check.sh exits 0. This PR doesn't touch the Mac app. CI builds nothing for the Mac and ./scripts/verify.sh was not run (LUKE-159).

šŸ¤– Generated with Claude Code