From 84619ba209719dc1ddd2670f8799b64eedc7c83e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 6 Oct 2026 10:25:02 +0000 Subject: [PATCH] feat(herd): the herd in your pocket, phase 1 (PRD 0020) Your herd on your phone at app.moshcode.sh/m, with no SSH, no Tailscale and no port: the box dials out. - `moshcode herd phone on|off|status` installs `moshcode herd relay` as a systemd user unit (launchd on macOS), logging in first if needed. - src/herd-relay.mjs publishes the roster (on change + 30 s heartbeat), long-polls commands, and streams one pane's screen only while a phone watches it. - Engine-aware one-tap approvals: `approve` maps for claude (1 / 2 / Esc) and codex (y / a / Esc). Stale guard: the same blocked spell (blockedAt) AND the dialog's menu at the bottom of the screen, or the tap is refused as stale. Fixture screens in test/fixtures/approve/. - apps/pwa: /api/herd/* (roster, commands, results, screens, SSE), push on transitions into blocked/done with Allow/Deny action buttons backed by a one-time token, migration 026, and the /m phone app (herd list, pane with live terminal, key bar, composer, machines/pairing). Manifest start_url /m. Verified end to end on dev2: real tmux herd session showing Claude's permission dialog, real relay, real app server, headless Chromium at iPhone size. A live run caught an answered dialog still accepting a second Allow; fixed and covered by a test. Co-Authored-By: Claude Opus 5.5 --- apps/pwa/docs/herd-phone-api.md | 59 ++ apps/pwa/public/herd/herd.css | 105 ++++ apps/pwa/public/herd/herd.js | 319 +++++++++++ apps/pwa/public/herd/index.html | 33 ++ apps/pwa/public/manifest.webmanifest | 4 +- apps/pwa/public/sw.js | 53 +- .../pwa/src/migrations-pg/026_herd_mobile.sql | 49 ++ apps/pwa/src/migrations/026_herd_mobile.sql | 51 ++ apps/pwa/src/routes/herd.mjs | 432 ++++++++++++++ apps/pwa/src/server.mjs | 2 + apps/pwa/test/herd-routes.test.mjs | 225 ++++++++ src/cli-schema.mjs | 11 + src/engines.mjs | 30 + src/herd-cli.mjs | 93 +++ src/herd-relay.mjs | 534 ++++++++++++++++++ src/herd.mjs | 7 +- test/fixtures/approve/claude-bash.txt | 13 + test/fixtures/approve/claude-edit.txt | 11 + .../claude-talking-about-permissions.txt | 9 + test/fixtures/approve/codex-command.txt | 11 + test/herd-relay.test.mjs | 291 ++++++++++ 21 files changed, 2327 insertions(+), 15 deletions(-) create mode 100644 apps/pwa/docs/herd-phone-api.md create mode 100644 apps/pwa/public/herd/herd.css create mode 100644 apps/pwa/public/herd/herd.js create mode 100644 apps/pwa/public/herd/index.html create mode 100644 apps/pwa/src/migrations-pg/026_herd_mobile.sql create mode 100644 apps/pwa/src/migrations/026_herd_mobile.sql create mode 100644 apps/pwa/src/routes/herd.mjs create mode 100644 apps/pwa/test/herd-routes.test.mjs create mode 100644 src/herd-relay.mjs create mode 100644 test/fixtures/approve/claude-bash.txt create mode 100644 test/fixtures/approve/claude-edit.txt create mode 100644 test/fixtures/approve/claude-talking-about-permissions.txt create mode 100644 test/fixtures/approve/codex-command.txt create mode 100644 test/herd-relay.test.mjs diff --git a/apps/pwa/docs/herd-phone-api.md b/apps/pwa/docs/herd-phone-api.md new file mode 100644 index 00000000..6ef2271a --- /dev/null +++ b/apps/pwa/docs/herd-phone-api.md @@ -0,0 +1,59 @@ +# The herd on a phone: `/m` and `/api/herd/*` + +PRD 0020, phase 1. The same API the phone app uses; nothing private. + +## Pairing + +On the box running the agents, signed in to the same account: + +```sh +moshcode herd phone on # installs the relay as a user service (systemd / launchd) +moshcode herd phone status # running? as which machine? +moshcode herd phone off +moshcode herd relay # the same process in the foreground +``` + +The box dials out. Nothing listens on it. + +## Box side (Bearer API key, the CLI login token) + +| Method | Path | Body / query | Answer | +|---|---|---|---| +| POST | `/api/herd/roster` | `{machine:{id,name,platform,version}, sessions:[{name,engine,state,blockedOn,confidence,cwd,kind,lastLines,since,blockedAt,approval}]}` | `{ok, machine, watching:[names]}` | +| GET | `/api/herd/commands?machine=` | long-poll, 25 s | `{commands:[{id,session,kind,args}]}` (each claimed once) | +| POST | `/api/herd/commands/:id` | `{ok, stale?, error?}` | `{ok}` | +| POST | `/api/herd/screen` | `{machine, session, screen}` | `{ok, watching}`; stop streaming when `watching` is false | + +`machine.id` is the box's own id (`~/.moshcode/herd/machine-id`). The server scopes +it to the account, so two accounts on one box are two machines. + +A push goes out on a transition **into** `blocked` (a new `blockedAt` counts as a +new one) or from `working`/`blocked` into `done`, never on a machine's first +publish. The push carries no screen text. + +## Phone side (cookie session) + +| Method | Path | Notes | +|---|---|---| +| GET | `/m` | the app (`public/herd/`); signed out, redirects to sign-in and back | +| GET | `/api/herd` | `{machines:[{id,name,online,lastSeen,sessions:[…]}], csrf}` | +| GET | `/api/herd/stream` | SSE: `{type:"roster"}`, `{type:"result",…}` | +| GET | `/api/herd/pane/stream?machine=&session=` | SSE: `{type:"screen", screen}`; asks the relay to stream | +| POST | `/api/herd/command` | header `x-csrf-token`; `{machine, session, kind, args}`; waits up to 8 s for the relay | +| GET | `/api/herd/command/:id` | status of one command | + +Commands: + +- `approve` `{intent: "allow"|"allowAll"|"deny", blockedAt}`. The relay re-reads the + screen and refuses with `stale` unless the same blocked spell is still showing the + engine's permission dialog at the bottom of the screen. Keys per engine live in + `src/engines.mjs` (`approve`), with fixture screens in `test/fixtures/approve/`. +- `prompt` `{text}` types the text and presses Enter. +- `keys` `{keys:[…]}` sends key-bar names only: `esc tab shift-tab up down left right enter ctrl-c ctrl-d slash backspace y n 1 2 3`. + +## Push buttons + +`POST /api/herd/act {token, intent: "allow"|"deny"}` needs no cookie. The token is a +one-time capability minted for one blocked spell and delivered only inside the push. +The first tap spends it; a second tap, or a token for a session that moved on, is +`410 {stale:true}`. diff --git a/apps/pwa/public/herd/herd.css b/apps/pwa/public/herd/herd.css new file mode 100644 index 00000000..41d81b0b --- /dev/null +++ b/apps/pwa/public/herd/herd.css @@ -0,0 +1,105 @@ +/* moshcode herd on a phone (PRD 0020). Same palette as the rest of the app. */ +:root { + color-scheme: dark; + --bg: #070806; --surface: #101208; --surface-2: #15180d; + --line: #23281a; --line-2: #333a25; --text: #edf2e4; --dim: #969d85; --faint: #5d6350; + --acid: #a6ff1a; --acid-ink: #0a1400; --volt: #16e0ff; --danger: #ff0050; --warn: #ffc233; + --mono: ui-monospace, "JetBrains Mono", "SF Mono", SFMono-Regular, Menlo, Consolas, monospace; + --sans: "Helvetica Neue", Helvetica, system-ui, -apple-system, Arial, sans-serif; + --top: calc(52px + env(safe-area-inset-top)); +} +* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } +[hidden] { display: none !important; } +html, body { margin: 0; background: var(--bg); color: var(--text); font-family: var(--sans); } +body { min-height: 100dvh; padding-bottom: env(safe-area-inset-bottom); overflow-x: hidden; } +a { color: inherit; text-decoration: none; } +button { font: inherit; color: inherit; } + +.top { + position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 8px; + height: var(--top); padding: env(safe-area-inset-top) 12px 0; + background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(10px); + border-bottom: 1px solid var(--line); +} +.top h1 { margin: 0; font-size: 1rem; font-weight: 800; text-transform: uppercase; letter-spacing: .02em; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } +.top .spacer { flex: 1; } +.icon, .back { display: inline-grid; place-items: center; min-width: 40px; height: 40px; border-radius: 10px; + border: 1px solid var(--line-2); background: transparent; font-size: 1.05rem; cursor: pointer; } +.back { font-size: 1.6rem; line-height: 1; border: 0; } +.icon[data-on="1"] { border-color: color-mix(in srgb, var(--acid) 50%, var(--line)); } +.why { margin: 0; padding: 8px 14px; font: .75rem var(--mono); color: var(--warn); border-bottom: 1px solid var(--line); } + +main { padding: 12px 12px 24px; max-width: 760px; margin: 0 auto; } +.label { font: .64rem var(--mono); letter-spacing: .18em; text-transform: uppercase; color: var(--faint); } +.dim { color: var(--dim); } .mono { font-family: var(--mono); } + +/* the herd */ +.machine { margin: 6px 0 18px; } +.machine-head { display: flex; align-items: center; gap: 8px; margin: 0 2px 8px; } +.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; } +.dot.on { background: var(--acid); box-shadow: 0 0 8px color-mix(in srgb, var(--acid) 60%, transparent); } +.machine-head .name { font: 600 .82rem var(--mono); } +.machine-head .seen { margin-left: auto; font: .7rem var(--mono); color: var(--faint); } +.row { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: center; + padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); margin-bottom: 8px; } +.row:active { background: var(--surface-2); } +.row .who { font: 700 .95rem var(--mono); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.row .meta { grid-column: 2; font: .72rem var(--mono); color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.row .line { grid-column: 1 / -1; font: .72rem var(--mono); color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.row.blocked { border-color: color-mix(in srgb, var(--danger) 55%, var(--line)); } +.empty { padding: 28px 16px; text-align: center; color: var(--dim); border: 1px dashed var(--line-2); border-radius: 12px; } +.empty code, .cmd code { color: var(--acid); } + +.chip { font: 700 .6rem var(--mono); letter-spacing: .12em; text-transform: uppercase; padding: 3px 8px; + border-radius: 999px; border: 1px solid var(--line-2); color: var(--dim); white-space: nowrap; } +.chip.blocked { color: #fff; background: var(--danger); border-color: var(--danger); } +.chip.working { color: var(--acid); border-color: color-mix(in srgb, var(--acid) 45%, var(--line)); animation: pulse 1.6s ease-in-out infinite; } +.chip.done { color: var(--faint); } +.chip.idle { color: var(--volt); border-color: color-mix(in srgb, var(--volt) 40%, var(--line)); } +@keyframes pulse { 50% { opacity: .45; } } +@media (prefers-reduced-motion: reduce) { .chip.working { animation: none; } } + +/* a pane */ +.pane-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; font: .75rem var(--mono); color: var(--dim); } +.approve { border: 1px solid color-mix(in srgb, var(--danger) 55%, var(--line)); border-radius: 12px; padding: 12px; margin-bottom: 10px; + background: color-mix(in srgb, var(--danger) 7%, var(--surface)); } +.approve p { margin: 0 0 10px; font-weight: 700; } +.approve .btns { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.approve .btns .wide { grid-column: 1 / -1; } +.btn { min-height: 46px; padding: 10px 14px; border-radius: 10px; border: 1px solid var(--line-2); background: var(--surface-2); + font: 700 .85rem var(--mono); cursor: pointer; } +.btn.go { background: var(--acid); color: var(--acid-ink); border-color: var(--acid); } +.btn.no { border-color: color-mix(in srgb, var(--danger) 60%, var(--line)); color: var(--danger); } +.btn:disabled { opacity: .5; } +.hint { margin: 0 0 10px; font: .75rem var(--mono); color: var(--warn); } + +.term-wrap { border: 1px solid var(--line); border-radius: 10px; background: #000; overflow-x: auto; overflow-y: hidden; padding: 6px; min-height: 38vh; } +.term-wrap .xterm { padding: 0; } +.term-status { font: .7rem var(--mono); color: var(--faint); margin: 6px 2px 0; min-height: 1em; } + +.keybar { display: flex; gap: 6px; overflow-x: auto; padding: 10px 0 4px; scrollbar-width: none; } +.keybar::-webkit-scrollbar { display: none; } +.keybar button { flex: none; min-width: 46px; height: 40px; padding: 0 10px; border-radius: 8px; border: 1px solid var(--line-2); + background: var(--surface); font: 600 .8rem var(--mono); cursor: pointer; } +.keybar button:active { background: var(--surface-2); border-color: var(--acid); } + +.composer { display: flex; gap: 8px; margin-top: 8px; position: sticky; bottom: calc(8px + env(safe-area-inset-bottom)); } +.composer textarea { flex: 1; min-height: 46px; max-height: 30vh; resize: none; padding: 12px; border-radius: 10px; + border: 1px solid var(--line-2); background: var(--surface); color: var(--text); font: 16px var(--mono); } +.composer textarea:focus { outline: none; border-color: var(--acid); } + +/* machines */ +.card { border: 1px solid var(--line); border-radius: 12px; background: var(--surface); padding: 14px; margin-bottom: 12px; } +.card h2 { margin: 0 0 8px; font-size: .85rem; text-transform: uppercase; letter-spacing: .04em; } +.card p { margin: 6px 0; color: var(--dim); font-size: .85rem; line-height: 1.5; } +.cmd { display: flex; gap: 8px; align-items: center; margin: 8px 0; } +.cmd code { flex: 1; min-width: 0; overflow-x: auto; white-space: nowrap; padding: 10px; border-radius: 8px; background: #000; + border: 1px solid var(--line); font: .78rem var(--mono); } +.cmd .btn { min-height: 38px; padding: 6px 10px; font-size: .72rem; } + +.toast { position: fixed; left: 50%; bottom: calc(18px + env(safe-area-inset-bottom)); transform: translateX(-50%); z-index: 9; + max-width: calc(100vw - 32px); padding: 10px 14px; border-radius: 10px; background: var(--surface-2); border: 1px solid var(--line-2); + font: .8rem var(--mono); box-shadow: 0 8px 30px rgba(0,0,0,.5); } +.toast.bad { border-color: var(--danger); color: var(--danger); } +.toast.good { border-color: var(--acid); color: var(--acid); } diff --git a/apps/pwa/public/herd/herd.js b/apps/pwa/public/herd/herd.js new file mode 100644 index 00000000..b90bf7d9 --- /dev/null +++ b/apps/pwa/public/herd/herd.js @@ -0,0 +1,319 @@ +// moshcode herd on a phone (PRD 0020, phase 1). Plain ES module, no framework. +// +// Routes (hash, so the service worker and a refresh both land on /m): +// #/ every session on every machine, blocked first +// #/p// one pane: approval card, live screen, key bar, composer +// #/machines paired machines, and the one command that pairs one +import { pushSupport, subscribe, unsubscribe, getSubscription } from "/vendor/notifications-client.js"; + +const view = document.getElementById("view"); +const title = document.getElementById("title"); +const back = document.getElementById("back"); +const toastEl = document.getElementById("toast"); + +const STATE_ORDER = { blocked: 0, working: 1, idle: 2, unknown: 3, done: 4 }; +// The key bar (PRD §4): names the relay accepts (src/herd-relay.mjs PHONE_KEYS). +const KEYBAR = [ + ["esc", "Esc"], ["tab", "Tab"], ["up", "↑"], ["down", "↓"], ["left", "←"], ["right", "→"], + ["enter", "⏎"], ["ctrl-c", "^C"], ["slash", "/"], ["shift-tab", "⇧Tab"], ["1", "1"], ["2", "2"], ["3", "3"], + ["y", "y"], ["n", "n"], ["backspace", "⌫"], ["ctrl-d", "^D"], +]; + +let herd = { machines: [] }; +let csrf = ""; +let paneStream = null; +let term = null; +let fit = null; + +const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); +const base = (p) => String(p || "").split("/").filter(Boolean).pop() || ""; + +function cookieCsrf() { + const m = document.cookie.match(/(?:^|; )mc_csrf=([^;]+)/); + return m ? decodeURIComponent(m[1]) : csrf; +} + +function toast(message, tone = "") { + toastEl.textContent = message; + toastEl.className = `toast ${tone}`; + toastEl.hidden = false; + clearTimeout(toast.t); + toast.t = setTimeout(() => { toastEl.hidden = true; }, 3200); +} + +function ago(ms) { + if (!ms) return "never"; + const s = Math.max(0, Math.round((Date.now() - ms) / 1000)); + if (s < 60) return `${s}s ago`; + if (s < 3600) return `${Math.round(s / 60)}m ago`; + if (s < 86400) return `${Math.round(s / 3600)}h ago`; + return `${Math.round(s / 86400)}d ago`; +} + +async function load() { + const res = await fetch("/api/herd", { credentials: "same-origin", cache: "no-store" }); + if (res.status === 401) { location.href = "/"; return; } + const data = await res.json(); + herd = data; + csrf = data.csrf || csrf; +} + +let reloadTimer = null; +function reloadSoon() { + clearTimeout(reloadTimer); + reloadTimer = setTimeout(async () => { await load().catch(() => {}); render(); }, 250); +} + +function listen() { + const es = new EventSource("/api/herd/stream"); + es.onmessage = (e) => { + let event; try { event = JSON.parse(e.data); } catch { return; } + if (event.type === "roster") reloadSoon(); + if (event.type === "result") onResult(event); + }; + es.onerror = () => { /* EventSource reconnects on its own */ }; +} + +function onResult(event) { + if (event.kind === "open-stream") return; + if (event.status === "done") toast(`${event.session}: sent`, "good"); + else if (event.status === "stale") toast(`${event.session}: already answered`, ""); + else toast(`${event.session}: ${event.error || "failed"}`, "bad"); +} + +async function command(machine, session, kind, args) { + const res = await fetch("/api/herd/command", { + method: "POST", + credentials: "same-origin", + headers: { "content-type": "application/json", "x-csrf-token": cookieCsrf() }, + body: JSON.stringify({ machine, session, kind, args }), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) { toast(body.error || `failed (${res.status})`, "bad"); return null; } + if (body.status === "queued") toast("queued — the machine has not answered yet"); + else onResult({ ...body, session, kind }); + return body; +} + +// ---- views ---- + +function chip(s) { + const label = s.state === "blocked" && s.blockedOn ? `blocked · ${s.blockedOn}` : s.state; + return `${esc(label)}`; +} + +function renderHerd() { + title.textContent = "herd"; + back.hidden = true; + const machines = herd.machines || []; + if (!machines.length) { + view.innerHTML = `
+

No machines yet.

+

On the box running your agents:

+

moshcode herd phone on

+

how pairing works →

`; + return; + } + view.innerHTML = machines.map((m) => { + const sessions = [...m.sessions].sort((a, b) => (STATE_ORDER[a.state] ?? 9) - (STATE_ORDER[b.state] ?? 9) || a.name.localeCompare(b.name)); + const rows = sessions.length ? sessions.map((s) => ` + + ${chip(s)}${esc(s.name)} + ${esc(s.engine || "")}${s.cwd ? ` · ${esc(base(s.cwd))}` : ""} + ${esc((s.lastLines || []).slice(-1)[0] || "")} + `).join("") : `
nothing running in this herd
`; + return `
+
${esc(m.name)} + ${m.online ? "online" : `seen ${ago(m.lastSeen)}`}
+ ${rows}
`; + }).join(""); +} + +function findPane(mid, name) { + const machine = (herd.machines || []).find((m) => m.id === mid); + return { machine, session: machine?.sessions.find((s) => s.name === name) || null }; +} + +function approvalCard(machine, s) { + if (!machine?.online) return `

${esc(machine?.name || "this machine")} is offline — answers will wait until its relay is back.

`; + if (s.state !== "blocked") return ""; + if (!s.approval) { + return `

blocked${s.blockedOn ? ` on a ${esc(s.blockedOn)}` : ""} — answer it with the keys or a reply below.

`; + } + return `
+

${esc(s.name)} wants permission

+
+ + + ${s.approval.allowAll ? `` : ""} +
`; +} + +function renderPane(mid, name) { + const { machine, session } = findPane(mid, name); + title.textContent = name; + back.hidden = false; + if (!session) { + closePane(); + view.innerHTML = `
${esc(name)} is not in the herd any more.
`; + return; + } + const head = `
${chip(session)}${esc(session.engine || "")}· + ${esc(machine.name)}${session.cwd ? `·${esc(base(session.cwd))}` : ""}
`; + + // Re-render only the parts that change when the roster updates; the + // terminal is kept, because tearing it down drops the screen and the stream. + const existing = document.getElementById("pane"); + if (existing && existing.dataset.key === `${mid}/${name}`) { + document.getElementById("pane-head").innerHTML = head; + document.getElementById("pane-approve").innerHTML = approvalCard(machine, session); + wireApprove(mid, session); + return; + } + + closePane(); + view.innerHTML = `
+
${head}
+
${approvalCard(machine, session)}
+
+
connecting to the screen…
+
${KEYBAR.map(([k, label]) => ``).join("")}
+
+ + +
`; + wireApprove(mid, session); + + document.getElementById("keybar").addEventListener("click", (e) => { + const key = e.target.closest("button")?.dataset.key; + if (key) command(mid, name, "keys", { keys: [key] }); + }); + const input = document.getElementById("prompt"); + document.getElementById("composer").addEventListener("submit", async (e) => { + e.preventDefault(); + const text = input.value.trim(); + if (!text) return; + const r = await command(mid, name, "prompt", { text }); + if (r && r.status !== "failed") input.value = ""; + }); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); document.getElementById("composer").requestSubmit(); } + }); + openTerminal(mid, name); +} + +function wireApprove(mid, session) { + const card = document.getElementById("approve"); + if (!card) return; + card.addEventListener("click", async (e) => { + const intent = e.target.closest("button")?.dataset.intent; + if (!intent) return; + for (const b of card.querySelectorAll("button")) b.disabled = true; + const r = await command(mid, session.name, "approve", { intent, blockedAt: session.blockedAt }); + if (!r || r.status === "failed") for (const b of card.querySelectorAll("button")) b.disabled = false; + }); +} + +/** Paint a captured screen. The pane is wider than a phone, so the font fits the widest line. */ +function paint(screen) { + const host = document.getElementById("term"); + if (!host || !window.Terminal) return; + const lines = String(screen || "").replace(/\n+$/, "").split("\n"); + const plain = lines.map((l) => l.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "")); + const cols = Math.min(240, Math.max(40, ...plain.map((l) => l.length))); + const rows = Math.max(10, Math.min(80, lines.length)); + const width = host.clientWidth - 12; + const fontSize = Math.max(6, Math.min(13, Math.floor((width / cols) / 0.6))); + if (!term) { + term = new window.Terminal({ + cols, rows, fontSize, convertEol: true, disableStdin: true, cursorBlink: false, scrollback: 0, + fontFamily: 'ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace', + theme: { background: "#000000", foreground: "#edf2e4", cursor: "#a6ff1a" }, + }); + term.open(host); + } else { + if (term.options.fontSize !== fontSize) term.options.fontSize = fontSize; + if (term.cols !== cols || term.rows !== rows) term.resize(cols, rows); + } + term.write("\x1b[2J\x1b[3J\x1b[H" + lines.join("\r\n")); +} + +function openTerminal(mid, name) { + const status = document.getElementById("term-status"); + paneStream = new EventSource(`/api/herd/pane/stream?machine=${encodeURIComponent(mid)}&session=${encodeURIComponent(name)}`); + let got = false; + const waiting = setTimeout(() => { if (!got && status) status.textContent = "waiting for the machine to send its screen…"; }, 4000); + paneStream.onmessage = (e) => { + let event; try { event = JSON.parse(e.data); } catch { return; } + if (event.type !== "screen") return; + got = true; + clearTimeout(waiting); + if (status) status.textContent = event.cached ? "last screen — refreshing…" : "live"; + paint(event.screen); + }; +} + +function closePane() { + if (paneStream) { paneStream.close(); paneStream = null; } + if (term) { term.dispose(); term = null; } +} + +function renderMachines() { + closePane(); + title.textContent = "machines"; + back.hidden = false; + const machines = herd.machines || []; + const list = machines.length ? machines.map((m) => ` +
${esc(m.name)} + ${m.online ? "online" : `seen ${ago(m.lastSeen)}`} · ${m.sessions.length} session${m.sessions.length === 1 ? "" : "s"}
`).join("") + : `

none yet.

`; + view.innerHTML = ` +

Pair a machine

+

On the box running your agents, signed in to the same account:

+
moshcode herd phone on
+

No moshcode there yet?

+
curl -fsSL https://moshcoding.com/install.sh | sh
+

The box dials out to app.moshcode.sh. Nothing listens on it: no port, no Tailscale, no SSH key on this phone.

+

Paired

${list}
+

On iPhone

+

Share → Add to Home Screen, then open moshcode from the home screen and tap 🔔. Safari only delivers notifications to an installed app, and shows no Allow/Deny buttons on them: tapping one opens the approval here.

`; + view.querySelectorAll("[data-copy]").forEach((b) => b.addEventListener("click", async () => { + try { await navigator.clipboard.writeText(b.dataset.copy); toast("copied", "good"); } catch { toast("copy failed", "bad"); } + })); +} + +function render() { + const parts = location.hash.replace(/^#\/?/, "").split("/").map(decodeURIComponent); + if (parts[0] === "p" && parts[1] && parts[2]) return renderPane(parts[1], parts[2]); + closePane(); + if (parts[0] === "machines") return renderMachines(); + return renderHerd(); +} + +// ---- push on this device ---- +async function setupPush() { + const btn = document.getElementById("push-btn"); + const why = document.getElementById("push-why"); + const explain = (m) => { why.textContent = m || ""; why.hidden = !m; }; + const set = (on) => { btn.dataset.on = on ? "1" : "0"; btn.textContent = on ? "🔔" : "🔕"; btn.title = on ? "Notifications on" : "Turn on notifications"; }; + const support = pushSupport(); + if (!support.supported) { set(false); btn.addEventListener("click", () => explain(support.message)); return; } + try { set(Boolean(await getSubscription())); } catch { set(false); } + btn.addEventListener("click", async () => { + btn.disabled = true; + try { + if (btn.dataset.on === "1") { await unsubscribe({ removeUrl: "/push/unsubscribe", headers: { "x-csrf-token": cookieCsrf() } }); set(false); toast("notifications off on this device"); } + else { await subscribe({ saveUrl: "/push/subscribe", headers: { "x-csrf-token": cookieCsrf() } }); set(true); explain(""); toast("notifications on — a blocked agent will buzz this phone", "good"); } + } catch (e) { explain(e?.message || "could not change notifications"); } + finally { btn.disabled = false; } + }); +} + +if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js").catch(() => {}); +window.addEventListener("hashchange", render); +window.addEventListener("resize", () => { if (term) reloadSoon(); }); +document.addEventListener("visibilitychange", () => { if (!document.hidden) reloadSoon(); }); +await load().catch(() => {}); +render(); +listen(); +setupPush(); diff --git a/apps/pwa/public/herd/index.html b/apps/pwa/public/herd/index.html new file mode 100644 index 00000000..076a9c21 --- /dev/null +++ b/apps/pwa/public/herd/index.html @@ -0,0 +1,33 @@ + + + + + + + + + + + moshcode herd + + + + + + + +
+ +

herd

+ + + ⌘ +
+ +
+ + + + + + diff --git a/apps/pwa/public/manifest.webmanifest b/apps/pwa/public/manifest.webmanifest index fc5e0d65..937a613c 100644 --- a/apps/pwa/public/manifest.webmanifest +++ b/apps/pwa/public/manifest.webmanifest @@ -1,8 +1,8 @@ { "name": "moshcode", "short_name": "moshcode", - "description": "Human-in-the-loop approvals for your moshscript loops.", - "start_url": "/", + "description": "Your herd of coding agents on your phone: every session, one-tap approvals, a terminal with a key bar.", + "start_url": "/m", "scope": "/", "display": "standalone", "background_color": "#070806", diff --git a/apps/pwa/public/sw.js b/apps/pwa/public/sw.js index b6e66d9b..4f90f0e7 100644 --- a/apps/pwa/public/sw.js +++ b/apps/pwa/public/sw.js @@ -1,5 +1,5 @@ /* moshcode PWA service worker — offline app shell (network-first for docs). */ -const CACHE = "moshcode-v3"; +const CACHE = "moshcode-v4"; const SHELL = ["/", "/icon.svg", "/manifest.webmanifest", "/passkey.js"]; self.addEventListener("install", (e) => { @@ -12,26 +12,57 @@ self.addEventListener("activate", (e) => { ); }); -// approval push notifications +// Push: approvals, and the herd (PRD 0020). A herd push for a permission +// prompt carries Allow / Deny actions and a one-time token; tapping one answers +// the agent from the notification without opening the app. Platforms that show +// no action buttons (Safari) open the pane, where the same buttons are. self.addEventListener("push", (e) => { let d = {}; try { d = e.data ? e.data.json() : {}; } catch (_) {} - e.waitUntil(self.registration.showNotification(d.title || "moshcode 🤘", { + const options = { body: d.body || "You have an approval waiting.", icon: "/icon.svg", badge: "/icon.svg", - data: { url: d.url || "/" }, - tag: "moshcode-approval", - })); + data: { url: d.url || "/", act: d.act || null }, + tag: d.tag || "moshcode-approval", + renotify: Boolean(d.tag), + requireInteraction: Boolean(d.requireInteraction), + }; + if (Array.isArray(d.actions) && d.actions.length) options.actions = d.actions.slice(0, 2); + e.waitUntil(self.registration.showNotification(d.title || "moshcode 🤘", options)); }); -self.addEventListener("notificationclick", (e) => { - e.notification.close(); - const url = (e.notification.data && e.notification.data.url) || "/"; - e.waitUntil(clients.matchAll({ type: "window" }).then((cs) => { +async function act(data, intent) { + try { + const res = await fetch("/api/herd/act", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token: data.act.token, intent }), + }); + const body = await res.json().catch(() => ({})); + const said = res.ok ? (intent === "allow" ? "allowed" : "denied") + : body.stale ? "already answered" : (body.error || "could not answer"); + await self.registration.showNotification(`moshcode: ${said}`, { body: "", icon: "/icon.svg", tag: "moshcode-herd-ack", data: { url: data.url } }); + } catch (_) { + return openUrl(data.url); + } +} + +function openUrl(url) { + return clients.matchAll({ type: "window" }).then((cs) => { for (const c of cs) if ("focus" in c) { c.navigate(url); return c.focus(); } return clients.openWindow(url); - })); + }); +} + +self.addEventListener("notificationclick", (e) => { + e.notification.close(); + const data = e.notification.data || {}; + if ((e.action === "allow" || e.action === "deny") && data.act && data.act.token) { + e.waitUntil(act(data, e.action)); + return; + } + e.waitUntil(openUrl(data.url || "/")); }); self.addEventListener("fetch", (e) => { diff --git a/apps/pwa/src/migrations-pg/026_herd_mobile.sql b/apps/pwa/src/migrations-pg/026_herd_mobile.sql new file mode 100644 index 00000000..671f1c2c --- /dev/null +++ b/apps/pwa/src/migrations-pg/026_herd_mobile.sql @@ -0,0 +1,49 @@ +-- Postgres twin of migrations/026_herd_mobile.sql (hand-converted, reviewed). + +create table if not exists herd_machines ( + id text PRIMARY KEY, + user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name text NOT NULL, + platform text, + version text, + created_at bigint NOT NULL, + last_seen_at bigint NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_herd_machines_user ON herd_machines(user_id, last_seen_at DESC); + +create table if not exists herd_sessions ( + machine_id text NOT NULL REFERENCES herd_machines(id) ON DELETE CASCADE, + name text NOT NULL, + engine text, + state text NOT NULL, + blocked_on text, + confidence text, + cwd text, + kind text, + last_lines text, + since bigint, + blocked_at bigint, + approval text, + action_token text, + updated_at bigint NOT NULL, + PRIMARY KEY (machine_id, name) +); + +create table if not exists herd_commands ( + id text PRIMARY KEY, + machine_id text NOT NULL REFERENCES herd_machines(id) ON DELETE CASCADE, + user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session text NOT NULL, + kind text NOT NULL, + args text, + status text NOT NULL DEFAULT 'queued', + result text, + via text, + user_agent text, + created_at bigint NOT NULL, + claimed_at bigint, + done_at bigint +); + +CREATE INDEX IF NOT EXISTS idx_herd_commands_machine ON herd_commands(machine_id, status, created_at); diff --git a/apps/pwa/src/migrations/026_herd_mobile.sql b/apps/pwa/src/migrations/026_herd_mobile.sql new file mode 100644 index 00000000..777f7572 --- /dev/null +++ b/apps/pwa/src/migrations/026_herd_mobile.sql @@ -0,0 +1,51 @@ +-- The herd in your pocket (PRD 0020). A box running `moshcode herd relay` +-- publishes its herd here; the phone at /m reads it and queues commands back. +-- Screens are never stored: they pass through memory to whoever is watching. +CREATE TABLE IF NOT EXISTS herd_machines ( + id TEXT PRIMARY KEY, -- the box's own machine id (herd/machine-id) + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, -- user@host + platform TEXT, + version TEXT, + created_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_herd_machines_user ON herd_machines(user_id, last_seen_at DESC); + +-- One row per session on a machine, replaced wholesale on every publish. +CREATE TABLE IF NOT EXISTS herd_sessions ( + machine_id TEXT NOT NULL REFERENCES herd_machines(id) ON DELETE CASCADE, + name TEXT NOT NULL, + engine TEXT, + state TEXT NOT NULL, + blocked_on TEXT, + confidence TEXT, + cwd TEXT, + kind TEXT, + last_lines TEXT, -- JSON array + since INTEGER, -- when the current state began, box clock + blocked_at INTEGER, -- = since while blocked; what an approve must carry + approval TEXT, -- JSON {allowAll} while a permission dialog is up + action_token TEXT, -- one-time capability for the push buttons + updated_at INTEGER NOT NULL, + PRIMARY KEY (machine_id, name) +); + +-- Commands from the phone, claimed exactly once by the relay that owns the +-- machine. Same single-claim discipline as session_commands. +CREATE TABLE IF NOT EXISTS herd_commands ( + id TEXT PRIMARY KEY, + machine_id TEXT NOT NULL REFERENCES herd_machines(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session TEXT NOT NULL, + kind TEXT NOT NULL, -- approve | prompt | keys | open-stream + args TEXT, -- JSON + status TEXT NOT NULL DEFAULT 'queued', -- queued | claimed | done | failed | stale + result TEXT, -- JSON from the relay + via TEXT, -- app | push — for the "answered from a phone" metric + user_agent TEXT, + created_at INTEGER NOT NULL, + claimed_at INTEGER, + done_at INTEGER +); +CREATE INDEX IF NOT EXISTS idx_herd_commands_machine ON herd_commands(machine_id, status, created_at); diff --git a/apps/pwa/src/routes/herd.mjs b/apps/pwa/src/routes/herd.mjs new file mode 100644 index 00000000..09d05ea0 --- /dev/null +++ b/apps/pwa/src/routes/herd.mjs @@ -0,0 +1,432 @@ +// The herd in your pocket (PRD 0020, phase 1): the app half. +// +// Box side (Bearer API key — the relay started by `moshcode herd phone on`): +// POST /api/herd/roster publish this machine's herd; push on blocked/done +// GET /api/herd/commands?machine= long-poll + claim queued commands +// POST /api/herd/commands/:id the relay's result for one command +// POST /api/herd/screen one pane's screen, while a phone watches it +// +// Phone side (cookie session; JSON writes carry x-csrf-token): +// GET /m the phone app (public/herd/) +// GET /api/herd every machine and session +// GET /api/herd/stream SSE: roster changed, command results +// GET /api/herd/pane/stream SSE: one pane's screen (starts the relay's stream) +// POST /api/herd/command approve | prompt | keys +// GET /api/herd/command/:id one command's status +// +// Push buttons (no cookie: a service worker has no CSRF token to send): +// POST /api/herd/act {token, intent} — the token is a one-time +// capability minted for one blocked spell +import crypto from "node:crypto"; +import path from "node:path"; +import { Router } from "express"; +import { sendPushToMany } from "@profullstack/notifications/server"; +import { all, get, run } from "../db.mjs"; +import { config } from "../config.mjs"; +import { id, token } from "../lib/crypto.mjs"; +import { bearer, userForApiKey } from "../lib/apikey.mjs"; +import { requireAuth } from "../lib/session.mjs"; + +export const herdRouter = Router(); + +// A machine is online while its relay keeps checking in. The relay publishes +// at least every 30 s and its command poll touches last_seen every 25 s. +export const ONLINE_MS = 90 * 1000; +const DEFAULT_LONG_POLL_MS = 25 * 1000; +const LONG_POLL_MS = Number(process.env.HERD_POLL_MS) > 0 ? Number(process.env.HERD_POLL_MS) : DEFAULT_LONG_POLL_MS; +// How long POST /api/herd/command waits for the relay before answering +// "queued". Long enough for a live relay (one poll round trip), short enough +// that a phone on a dead machine is not left spinning. +const RESULT_WAIT_MS = Number(process.env.HERD_RESULT_WAIT_MS) >= 0 && process.env.HERD_RESULT_WAIT_MS !== undefined + ? Number(process.env.HERD_RESULT_WAIT_MS) : 8000; +const MAX_SESSIONS = 100; +const MAX_SCREEN = 200 * 1000; +const PUSH_TTL = 60 * 60; // a blocked prompt an hour old is not worth waking a phone for +export const COMMAND_KINDS = ["approve", "prompt", "keys"]; +export const INTENTS = ["allow", "allowAll", "deny"]; + +const str = (v, max) => (v == null ? null : String(v).slice(0, max)); +const num = (v) => (Number.isFinite(Number(v)) ? Math.floor(Number(v)) : null); + +/** The row id for a box, scoped to its owner so two accounts on one box never collide. */ +export function machineRowId(userId, machineKey) { + return crypto.createHash("sha256").update(`${userId}:${machineKey}`).digest("hex").slice(0, 32); +} + +// ---- in-process fan-out (best effort in front of the DB, like /sessions) ---- +const userStreams = new Map(); // userId -> Set +const paneStreams = new Map(); // `${machineId}/${session}` -> Set +const screens = new Map(); // `${machineId}/${session}` -> { screen, at } +const waiters = new Map(); // machineId -> Set (relay long-polls) +const resultWaiters = new Map(); // commandId -> Set (phones awaiting a result) + +function addTo(map, key, value) { if (!map.has(key)) map.set(key, new Set()); map.get(key).add(value); } +function removeFrom(map, key, value) { + const set = map.get(key); + if (!set) return; + set.delete(value); + if (!set.size) map.delete(key); +} +function sse(res, event) { try { res.write(`data: ${JSON.stringify(event)}\n\n`); } catch { /* gone */ } } +function toUser(userId, event) { for (const res of userStreams.get(userId) || []) sse(res, event); } +function wakeMachine(machineId) { for (const fn of [...(waiters.get(machineId) || [])]) fn(); } +function settleResult(commandId, result) { for (const fn of [...(resultWaiters.get(commandId) || [])]) fn(result); } + +function openSse(req, res) { + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + "x-accel-buffering": "no", + }); + res.write(": connected\n\n"); + const ping = setInterval(() => { try { res.write(": ping\n\n"); } catch { /* gone */ } }, 25000); + req.on("close", () => clearInterval(ping)); +} + +// ---- push ---- + +/** What the phone shows for a session that just started needing someone. */ +export function pushFor(machine, s) { + const where = s.cwd ? ` in ${path.basename(String(s.cwd))}` : ""; + const url = `/m#/p/${encodeURIComponent(machine.id)}/${encodeURIComponent(s.name)}`; + if (s.state === "done") { + return { title: `${s.name} is done`, body: `${s.engine || "agent"} on ${machine.name}${where}`, url, tag: `herd-${machine.id}-${s.name}` }; + } + const what = s.blocked_on === "question" ? "has a question" : s.approval ? "wants permission" : "is waiting on you"; + const payload = { + title: `${s.name} ${what}`, + body: `${s.engine || "agent"} on ${machine.name}${where}`, + url, + tag: `herd-${machine.id}-${s.name}`, + requireInteraction: true, + }; + if (s.approval && s.action_token) { + payload.actions = [{ action: "allow", title: "Allow" }, { action: "deny", title: "Deny" }]; + payload.act = { token: s.action_token }; + } + return payload; +} + +async function pushToUser(userId, payload) { + if (!config.push.keys) { console.log(`[herd push:stub] ${payload.title}`); return; } + const subs = await all(`SELECT * FROM push_subscriptions WHERE user_id = ?`, [userId]); + if (!subs.length) return; + await sendPushToMany( + subs.map((s) => ({ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } })), + JSON.stringify(payload), + { + keys: config.push.keys, + subject: config.push.subject, + ttl: PUSH_TTL, + onGone: (endpoint) => run(`DELETE FROM push_subscriptions WHERE endpoint = ? AND user_id = ?`, [endpoint, userId]), + }, + ).catch((e) => console.error("herd push failed:", e.message)); +} + +/** + * Which incoming rows deserve a push. Only transitions INTO blocked or done, + * the rule `herd watch` already follows (shouldNotify): a session that sits + * blocked does not page every heartbeat, and a machine's first publish is + * history, not news. A new blocked spell (a different blockedAt) is news even + * when the previous row was also blocked: that is a second permission prompt. + */ +export function shouldPush(prev, next, { firstPublish = false } = {}) { + if (firstPublish || !prev) return false; + if (next.state === "blocked") return prev.state !== "blocked" || Number(prev.blocked_at) !== Number(next.blocked_at); + if (next.state === "done") return prev.state === "working" || prev.state === "blocked"; + return false; +} + +// ---- box side ---- + +async function machineAuth(req, res, next) { + const user = await userForApiKey(bearer(req)); + if (!user) return res.status(401).json({ error: "invalid or missing API key — run `moshcode login`" }); + req.apiUser = user; + next(); +} + +/** The caller's machine, or null. `key` is the box's own machine id. */ +const ownedMachine = (userId, key) => get(`SELECT * FROM herd_machines WHERE id = ? AND user_id = ?`, [machineRowId(userId, String(key || "")), userId]); + +function readSession(raw) { + const name = str(raw?.name, 64); + const state = str(raw?.state, 16); + if (!name || !state) return null; + const approval = raw?.approval && typeof raw.approval === "object" ? { allowAll: Boolean(raw.approval.allowAll) } : null; + const lines = Array.isArray(raw?.lastLines) ? raw.lastLines.slice(-12).map((l) => String(l).slice(0, 300)) : []; + return { + name, + engine: str(raw.engine, 32), + state, + blocked_on: str(raw.blockedOn, 16), + confidence: str(raw.confidence, 16), + cwd: str(raw.cwd, 300), + kind: str(raw.kind, 16), + last_lines: JSON.stringify(lines), + since: num(raw.since), + blocked_at: state === "blocked" ? num(raw.blockedAt) : null, + approval: state === "blocked" && approval ? approval : null, + }; +} + +herdRouter.post("/api/herd/roster", machineAuth, async (req, res) => { + const key = str(req.body?.machine?.id, 64); + if (!key || !/^[A-Za-z0-9-]{8,64}$/.test(key)) return res.status(400).json({ error: "machine.id required" }); + const user = req.apiUser; + const now = Date.now(); + const mid = machineRowId(user.id, key); + const name = str(req.body?.machine?.name, 80) || "machine"; + const existing = await get(`SELECT * FROM herd_machines WHERE id = ?`, [mid]); + if (existing) { + await run(`UPDATE herd_machines SET name=?, platform=?, version=?, last_seen_at=? WHERE id=?`, + [name, str(req.body.machine.platform, 20), str(req.body.machine.version, 20), now, mid]); + } else { + await run(`INSERT INTO herd_machines (id,user_id,name,platform,version,created_at,last_seen_at) VALUES (?,?,?,?,?,?,?)`, + [mid, user.id, name, str(req.body.machine.platform, 20), str(req.body.machine.version, 20), now, now]); + } + const machine = { id: mid, name }; + + const incoming = (Array.isArray(req.body?.sessions) ? req.body.sessions : []).slice(0, MAX_SESSIONS).map(readSession).filter(Boolean); + const before = new Map((await all(`SELECT * FROM herd_sessions WHERE machine_id = ?`, [mid])).map((r) => [r.name, r])); + const pushes = []; + for (const s of incoming) { + const prev = before.get(s.name); + // A new blocked spell gets a new capability; the same spell keeps its own, + // so a heartbeat does not invalidate the buttons already on the phone. + const sameSpell = prev && prev.state === "blocked" && s.state === "blocked" && Number(prev.blocked_at) === Number(s.blocked_at); + const actionToken = s.state === "blocked" && s.approval ? (sameSpell && prev.action_token ? prev.action_token : token(18)) : null; + const row = { ...s, action_token: actionToken }; + if (prev) { + await run(`UPDATE herd_sessions SET engine=?, state=?, blocked_on=?, confidence=?, cwd=?, kind=?, last_lines=?, since=?, blocked_at=?, approval=?, action_token=?, updated_at=? + WHERE machine_id=? AND name=?`, + [row.engine, row.state, row.blocked_on, row.confidence, row.cwd, row.kind, row.last_lines, row.since, row.blocked_at, + row.approval ? JSON.stringify(row.approval) : null, row.action_token, now, mid, row.name]); + } else { + await run(`INSERT INTO herd_sessions (machine_id,name,engine,state,blocked_on,confidence,cwd,kind,last_lines,since,blocked_at,approval,action_token,updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [mid, row.name, row.engine, row.state, row.blocked_on, row.confidence, row.cwd, row.kind, row.last_lines, row.since, row.blocked_at, + row.approval ? JSON.stringify(row.approval) : null, row.action_token, now]); + } + if (shouldPush(prev, row, { firstPublish: !existing })) pushes.push(row); + } + const present = new Set(incoming.map((s) => s.name)); + for (const name of before.keys()) { + if (!present.has(name)) await run(`DELETE FROM herd_sessions WHERE machine_id = ? AND name = ?`, [mid, name]); + } + + toUser(user.id, { type: "roster", machine: mid }); + for (const row of pushes) await pushToUser(user.id, pushFor(machine, row)); + + const watching = incoming.filter((s) => paneStreams.has(`${mid}/${s.name}`)).map((s) => s.name); + res.json({ ok: true, machine: mid, watching }); +}); + +herdRouter.get("/api/herd/commands", machineAuth, async (req, res) => { + const machine = await ownedMachine(req.apiUser.id, req.query.machine); + if (!machine) return res.status(404).json({ error: "unknown machine — publish a roster first" }); + await run(`UPDATE herd_machines SET last_seen_at = ? WHERE id = ?`, [Date.now(), machine.id]); + + const claim = async () => { + const queued = await all(`SELECT * FROM herd_commands WHERE machine_id = ? AND status = 'queued' ORDER BY created_at ASC LIMIT 10`, [machine.id]); + const mine = []; + for (const c of queued) { + // The UPDATE is the lock: only the poll that flips 'queued' runs it. + const claimed = await run(`UPDATE herd_commands SET status='claimed', claimed_at=? WHERE id=? AND status='queued'`, [Date.now(), c.id]); + if (claimed.rowsAffected) mine.push({ id: c.id, session: c.session, kind: c.kind, args: c.args ? JSON.parse(c.args) : {} }); + } + return mine; + }; + + const first = await claim(); + if (first.length) return res.json({ commands: first }); + let settled = false; + const finish = async () => { + if (settled) return; + settled = true; + removeFrom(waiters, machine.id, finish); + clearTimeout(timer); + try { res.json({ commands: await claim() }); } catch { /* client gone */ } + }; + const timer = setTimeout(finish, LONG_POLL_MS); + addTo(waiters, machine.id, finish); + req.on("close", () => { settled = true; removeFrom(waiters, machine.id, finish); clearTimeout(timer); }); +}); + +herdRouter.post("/api/herd/commands/:id", machineAuth, async (req, res) => { + const command = await get(`SELECT * FROM herd_commands WHERE id = ? AND user_id = ?`, [req.params.id, req.apiUser.id]); + if (!command) return res.status(404).json({ error: "no such command" }); + const result = { ok: Boolean(req.body?.ok), stale: Boolean(req.body?.stale), error: str(req.body?.error, 300) }; + const status = result.ok ? "done" : result.stale ? "stale" : "failed"; + await run(`UPDATE herd_commands SET status=?, result=?, done_at=? WHERE id=? AND status='claimed'`, + [status, JSON.stringify(result), Date.now(), command.id]); + const event = { type: "result", id: command.id, machine: command.machine_id, session: command.session, kind: command.kind, status, ...result }; + toUser(req.apiUser.id, event); + settleResult(command.id, event); + res.json({ ok: true }); +}); + +herdRouter.post("/api/herd/screen", machineAuth, async (req, res) => { + const machine = await ownedMachine(req.apiUser.id, req.body?.machine); + if (!machine) return res.status(404).json({ error: "unknown machine" }); + const session = str(req.body?.session, 64); + if (!session) return res.status(400).json({ error: "session required" }); + const key = `${machine.id}/${session}`; + const screen = String(req.body?.screen ?? "").slice(-MAX_SCREEN); + screens.set(key, { screen, at: Date.now() }); + for (const out of paneStreams.get(key) || []) sse(out, { type: "screen", screen }); + res.json({ ok: true, watching: paneStreams.has(key) }); +}); + +// ---- phone side ---- + +/** Cookie routes under /api/ skip csrfGuard (it assumes Bearer); writes check here. */ +function csrfOk(req) { + const sent = req.get("x-csrf-token"); + return Boolean(sent) && sent === req.cookies?.mc_csrf; +} + +function apiAuth(req, res, next) { + if (!req.user) return res.status(401).json({ error: "sign in at app.moshcode.sh first", login: "/" }); + next(); +} + +const phoneApp = path.join(config.root, "public", "herd", "index.html"); +herdRouter.get(["/m", "/m/"], requireAuth, (_req, res) => { + res.set("cache-control", "no-cache"); + res.sendFile(phoneApp); +}); + +/** One session row as the phone sees it. The action token never leaves the server here. */ +export function sessionView(r) { + let lastLines = []; + try { lastLines = JSON.parse(r.last_lines || "[]"); } catch { lastLines = []; } + let approval = null; + try { approval = r.approval ? JSON.parse(r.approval) : null; } catch { approval = null; } + return { + name: r.name, engine: r.engine, state: r.state, blockedOn: r.blocked_on, confidence: r.confidence, + cwd: r.cwd, kind: r.kind, lastLines, since: num(r.since), blockedAt: num(r.blocked_at), approval, updatedAt: num(r.updated_at), + }; +} + +export async function herdFor(userId, now = Date.now()) { + const machines = await all(`SELECT * FROM herd_machines WHERE user_id = ? ORDER BY last_seen_at DESC`, [userId]); + const out = []; + for (const m of machines) { + const rows = await all(`SELECT * FROM herd_sessions WHERE machine_id = ? ORDER BY name ASC`, [m.id]); + out.push({ + id: m.id, name: m.name, platform: m.platform, version: m.version, + lastSeen: num(m.last_seen_at), online: now - Number(m.last_seen_at) < ONLINE_MS, + sessions: rows.map(sessionView), + }); + } + return out; +} + +herdRouter.get("/api/herd", apiAuth, async (req, res) => { + res.set("cache-control", "no-store"); + res.json({ machines: await herdFor(req.user.id), csrf: req.csrfToken }); +}); + +herdRouter.get("/api/herd/stream", apiAuth, (req, res) => { + openSse(req, res); + addTo(userStreams, req.user.id, res); + req.on("close", () => removeFrom(userStreams, req.user.id, res)); +}); + +async function queueCommand({ user, machine, session, kind, args, via, userAgent }) { + const cid = id(); + await run(`INSERT INTO herd_commands (id,machine_id,user_id,session,kind,args,status,via,user_agent,created_at) VALUES (?,?,?,?,?,?,'queued',?,?,?)`, + [cid, machine.id, user.id, session, kind, JSON.stringify(args || {}), via, userAgent, Date.now()]); + wakeMachine(machine.id); + return cid; +} + +herdRouter.get("/api/herd/pane/stream", apiAuth, async (req, res) => { + const machine = await get(`SELECT * FROM herd_machines WHERE id = ? AND user_id = ?`, [String(req.query.machine || ""), req.user.id]); + const session = str(req.query.session, 64); + if (!machine || !session) return res.status(404).json({ error: "no such pane" }); + const key = `${machine.id}/${session}`; + openSse(req, res); + const cached = screens.get(key); + if (cached) sse(res, { type: "screen", screen: cached.screen, cached: true }); + addTo(paneStreams, key, res); + req.on("close", () => removeFrom(paneStreams, key, res)); + // Ask the relay to start streaming unless it plainly already is. + if (!cached || Date.now() - cached.at > 3000) { + await queueCommand({ user: req.user, machine, session, kind: "open-stream", args: {}, via: "app", userAgent: str(req.get("user-agent"), 200) }); + } + // A relay streams for a minute past the last sign of life; keep it alive + // while this page is open, with a cheap re-ask every 40 s. + const keep = setInterval(() => { + queueCommand({ user: req.user, machine, session, kind: "open-stream", args: {}, via: "app", userAgent: null }).catch(() => {}); + }, 40000); + req.on("close", () => clearInterval(keep)); +}); + +/** Validate a phone command's shape; returns an error string or null. */ +export function commandError(kind, args) { + if (!COMMAND_KINDS.includes(kind)) return "unknown command"; + if (kind === "approve") { + if (!INTENTS.includes(args?.intent)) return "intent must be allow, allowAll or deny"; + if (num(args?.blockedAt) == null) return "blockedAt required"; + } + if (kind === "prompt" && !String(args?.text || "").trim()) return "text required"; + if (kind === "keys" && (!Array.isArray(args?.keys) || !args.keys.length || args.keys.length > 16)) return "keys required"; + return null; +} + +/** Wait for the relay's answer to one command, up to `ms`. */ +function awaitResult(commandId, ms) { + if (ms <= 0) return Promise.resolve(null); + return new Promise((resolve) => { + const done = (result) => { clearTimeout(timer); removeFrom(resultWaiters, commandId, done); resolve(result); }; + const timer = setTimeout(() => done(null), ms); + addTo(resultWaiters, commandId, done); + }); +} + +herdRouter.post("/api/herd/command", apiAuth, async (req, res) => { + if (!csrfOk(req)) return res.status(403).json({ error: "bad csrf token — reload the page" }); + const machine = await get(`SELECT * FROM herd_machines WHERE id = ? AND user_id = ?`, [String(req.body?.machine || ""), req.user.id]); + if (!machine) return res.status(404).json({ error: "no such machine" }); + if (Date.now() - Number(machine.last_seen_at) >= ONLINE_MS) return res.status(409).json({ error: `${machine.name} is offline — is \`moshcode herd phone on\` running there?` }); + const session = str(req.body?.session, 64); + const kind = String(req.body?.kind || ""); + const args = req.body?.args && typeof req.body.args === "object" ? req.body.args : {}; + const problem = commandError(kind, args); + if (!session || problem) return res.status(400).json({ error: problem || "session required" }); + const clean = kind === "approve" ? { intent: args.intent, blockedAt: num(args.blockedAt) } + : kind === "prompt" ? { text: String(args.text).slice(0, 4000) } + : { keys: args.keys.map((k) => String(k).slice(0, 16)) }; + const cid = await queueCommand({ user: req.user, machine, session, kind, args: clean, via: "app", userAgent: str(req.get("user-agent"), 200) }); + const result = await awaitResult(cid, RESULT_WAIT_MS); + res.json(result ? { id: cid, ...result } : { id: cid, status: "queued" }); +}); + +herdRouter.get("/api/herd/command/:id", apiAuth, async (req, res) => { + const c = await get(`SELECT * FROM herd_commands WHERE id = ? AND user_id = ?`, [req.params.id, req.user.id]); + if (!c) return res.status(404).json({ error: "no such command" }); + let result = null; + try { result = c.result ? JSON.parse(c.result) : null; } catch { result = null; } + res.json({ id: c.id, status: c.status, ...(result || {}) }); +}); + +// ---- push buttons ---- + +herdRouter.post("/api/herd/act", async (req, res) => { + const tok = str(req.body?.token, 64); + const intent = String(req.body?.intent || ""); + if (!tok || !["allow", "deny"].includes(intent)) return res.status(400).json({ error: "token and intent (allow|deny) required" }); + const s = await get(`SELECT s.*, m.user_id, m.last_seen_at FROM herd_sessions s JOIN herd_machines m ON m.id = s.machine_id WHERE s.action_token = ?`, [tok]); + if (!s || s.state !== "blocked") return res.status(410).json({ error: "already answered", stale: true }); + // One use: the token is spent before the command is queued, so a double tap + // (or a notification shown on two devices) answers once. + const spent = await run(`UPDATE herd_sessions SET action_token = NULL WHERE machine_id = ? AND name = ? AND action_token = ?`, [s.machine_id, s.name, tok]); + if (!spent.rowsAffected) return res.status(410).json({ error: "already answered", stale: true }); + const user = { id: s.user_id }; + const cid = await queueCommand({ user, machine: { id: s.machine_id }, session: s.name, kind: "approve", + args: { intent, blockedAt: num(s.blocked_at) }, via: "push", userAgent: str(req.get("user-agent"), 200) }); + res.json({ ok: true, id: cid }); +}); diff --git a/apps/pwa/src/server.mjs b/apps/pwa/src/server.mjs index 23c0eb2e..15f99b47 100644 --- a/apps/pwa/src/server.mjs +++ b/apps/pwa/src/server.mjs @@ -12,6 +12,7 @@ import { approvalsRouter } from "./routes/approvals.mjs"; import { creditsRouter } from "./routes/credits.mjs"; import { cliRouter } from "./routes/cli.mjs"; import { sessionsRouter } from "./routes/sessions.mjs"; +import { herdRouter } from "./routes/herd.mjs"; import { organizationsRouter } from "./routes/organizations.mjs"; import { mcpRouter } from "./routes/mcp.mjs"; import { mcpOAuthBrowserRouter, mcpOAuthMachineRouter } from "./routes/mcp-oauth.mjs"; @@ -86,6 +87,7 @@ app.use(creditsRouter); app.use(cliRouter); // /cli/authorize, /cli/token, /api/me app.use(adminRouter); // /api/admin/*, ADMIN_EMAILS operators only app.use(sessionsRouter); // /sessions (live CLI mirror) + /api/sessions +app.use(herdRouter); // /m (the herd on a phone, PRD 0020) + /api/herd/* app.use(organizationsRouter); // organizations, teams, membership, and session sharing app.use(mcpOAuthBrowserRouter); // /oauth/authorize — logged-in consent + CSRF app.use(pagesRouter); // /app, /settings diff --git a/apps/pwa/test/herd-routes.test.mjs b/apps/pwa/test/herd-routes.test.mjs new file mode 100644 index 00000000..212d55e6 --- /dev/null +++ b/apps/pwa/test/herd-routes.test.mjs @@ -0,0 +1,225 @@ +// PRD 0020 phase 1: the app half of the herd on a phone. +// +// Boots the real router against a throwaway libsql file database, the same way +// approvals-notify.test.mjs does, and skips when the PWA deps are missing. +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +let deps = null; +try { + deps = { express: require("express"), cookieParser: require("cookie-parser") }; +} catch { + deps = null; +} + +const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-pwa-herd-test-")); +process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`; +process.env.SESSION_SECRET = "test-secret"; +process.env.HERD_POLL_MS = "150"; +process.env.HERD_RESULT_WAIT_MS = "400"; + +const MACHINE = "aaaaaaaa-1111-2222-3333-444444444444"; + +async function boot() { + const { migrate } = await import("../src/migrate.mjs"); + await migrate(); + const { run, all, get } = await import("../src/db.mjs"); + const { sessionMiddleware, csrfGuard } = await import("../src/lib/session.mjs"); + const { herdRouter, machineRowId } = await import("../src/routes/herd.mjs"); + const { createApiKey } = await import("../src/lib/apikey.mjs"); + const { token } = await import("../src/lib/crypto.mjs"); + + const app = deps.express(); + app.use(deps.express.json()); + app.use(deps.cookieParser()); + app.use(sessionMiddleware); + app.use(csrfGuard); + app.use(herdRouter); + const server = await new Promise((resolve) => { const s = app.listen(0, "127.0.0.1", () => resolve(s)); }); + const base = `http://127.0.0.1:${server.address().port}`; + + const seedUser = async (userId) => { + await run(`INSERT INTO users (id, email, display_name, created_at) VALUES (?,?,?,?)`, [userId, `${userId}@b.c`, "demo", Date.now()]); + const { plaintext } = await createApiKey(userId, "cli"); + const sess = token(); + await run(`INSERT INTO sessions (token,user_id,created_at,expires_at) VALUES (?,?,?,?)`, [sess, userId, Date.now(), Date.now() + 3600e3]); + const csrf = token(16); + return { id: userId, key: plaintext, cookie: `mc_sess=${sess}; mc_csrf=${csrf}`, csrf, mid: machineRowId(userId, MACHINE) }; + }; + + const box = (u) => ({ + publish: (sessions, machine = MACHINE) => fetch(`${base}/api/herd/roster`, { + method: "POST", headers: { authorization: `Bearer ${u.key}`, "content-type": "application/json" }, + body: JSON.stringify({ machine: { id: machine, name: "me@box", platform: "linux", version: "0.112.0" }, sessions }), + }).then(async (r) => ({ status: r.status, body: await r.json() })), + poll: () => fetch(`${base}/api/herd/commands?machine=${MACHINE}`, { headers: { authorization: `Bearer ${u.key}` } }) + .then(async (r) => ({ status: r.status, body: await r.json() })), + result: (cid, body) => fetch(`${base}/api/herd/commands/${cid}`, { + method: "POST", headers: { authorization: `Bearer ${u.key}`, "content-type": "application/json" }, body: JSON.stringify(body), + }).then((r) => r.status), + }); + + const phone = (u) => ({ + herd: () => fetch(`${base}/api/herd`, { headers: { cookie: u.cookie } }).then(async (r) => ({ status: r.status, body: await r.json() })), + command: (body, { csrf = u.csrf } = {}) => fetch(`${base}/api/herd/command`, { + method: "POST", headers: { cookie: u.cookie, "content-type": "application/json", "x-csrf-token": csrf }, body: JSON.stringify(body), + }).then(async (r) => ({ status: r.status, body: await r.json() })), + }); + + const act = (tok, intent) => fetch(`${base}/api/herd/act`, { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: tok, intent }), + }).then(async (r) => ({ status: r.status, body: await r.json() })); + + return { base, server, run, all, get, seedUser, box, phone, act }; +} + +const blocked = (blockedAt, extra = {}) => ({ + name: "api", engine: "claude", state: "blocked", blockedOn: "menu", cwd: "/src/api", lastLines: ["Do you want to proceed?", "❯ 1. Yes"], + since: blockedAt, blockedAt, approval: { allowAll: true }, ...extra, +}); +const working = (since) => ({ name: "api", engine: "claude", state: "working", cwd: "/src/api", lastLines: ["esc to interrupt"], since }); + +test("herd on a phone: publish, list, approve, answer, push token", { skip: !deps && "apps/pwa deps not installed" }, async (t) => { + const app = await boot(); + t.after(() => app.server.close()); + const me = await app.seedUser("u-herd-1"); + const other = await app.seedUser("u-herd-2"); + + await t.test("the box publishes; the phone lists it with no action token in sight", async () => { + const r = await app.box(me).publish([working(1)]); + assert.equal(r.status, 200); + assert.equal(r.body.machine, me.mid); + const h = await app.phone(me).herd(); + assert.equal(h.status, 200); + assert.equal(h.body.machines.length, 1); + const [m] = h.body.machines; + assert.equal(m.name, "me@box"); + assert.equal(m.online, true); + assert.equal(m.sessions[0].state, "working"); + assert.ok(!JSON.stringify(h.body).includes("action_token")); + }); + + await t.test("another account never sees it, even publishing the same machine id", async () => { + await app.box(other).publish([working(1)]); + const h = await app.phone(other).herd(); + assert.equal(h.body.machines.length, 1); + assert.notEqual(h.body.machines[0].id, me.mid); + const mine = await app.phone(me).herd(); + assert.equal(mine.body.machines.length, 1, "my roster is still only mine"); + }); + + await t.test("signed out, the API says 401", async () => { + const r = await fetch(`${app.base}/api/herd`); + assert.equal(r.status, 401); + }); + + let actionToken = null; + await t.test("a transition into blocked mints a one-time action token", async () => { + await app.box(me).publish([blocked(500)]); + const row = await app.get(`SELECT * FROM herd_sessions WHERE machine_id = ? AND name = 'api'`, [me.mid]); + assert.equal(row.state, "blocked"); + assert.equal(Number(row.blocked_at), 500); + assert.ok(row.action_token); + actionToken = row.action_token; + // A heartbeat in the same spell keeps the same token. + await app.box(me).publish([blocked(500)]); + const again = await app.get(`SELECT action_token FROM herd_sessions WHERE machine_id = ? AND name = 'api'`, [me.mid]); + assert.equal(again.action_token, actionToken); + }); + + await t.test("a phone command needs the csrf header", async () => { + const r = await app.phone(me).command({ machine: me.mid, session: "api", kind: "approve", args: { intent: "allow", blockedAt: 500 } }, { csrf: "wrong" }); + assert.equal(r.status, 403); + }); + + await t.test("bad shapes are refused before they reach the box", async () => { + const p = app.phone(me); + assert.equal((await p.command({ machine: me.mid, session: "api", kind: "approve", args: { intent: "yolo", blockedAt: 1 } })).status, 400); + assert.equal((await p.command({ machine: me.mid, session: "api", kind: "approve", args: { intent: "allow" } })).status, 400); + assert.equal((await p.command({ machine: me.mid, session: "api", kind: "exec", args: {} })).status, 400); + assert.equal((await p.command({ machine: other.mid, session: "api", kind: "prompt", args: { text: "x" } })).status, 404, "someone else's machine"); + }); + + await t.test("approve: queued, claimed once by the relay, answered back to the phone", async () => { + const pending = app.phone(me).command({ machine: me.mid, session: "api", kind: "approve", args: { intent: "allow", blockedAt: 500 } }); + // The relay's long-poll picks it up. + let claimed = null; + for (let i = 0; i < 10 && !claimed; i++) { + const p = await app.box(me).poll(); + claimed = p.body.commands.find((c) => c.kind === "approve") || null; + } + assert.ok(claimed, "the relay got the command"); + assert.deepEqual(claimed.args, { intent: "allow", blockedAt: 500 }); + // A second poll does not get it again. + const second = await app.box(me).poll(); + assert.equal(second.body.commands.filter((c) => c.id === claimed.id).length, 0); + assert.equal(await app.box(me).result(claimed.id, { ok: true }), 200); + const answer = await pending; + assert.equal(answer.status, 200); + assert.equal(answer.body.status, "done"); + const row = await app.get(`SELECT status, via FROM herd_commands WHERE id = ?`, [claimed.id]); + assert.equal(row.status, "done"); + assert.equal(row.via, "app"); + }); + + await t.test("the push button: one tap answers once, a second tap is stale", async () => { + const first = await app.act(actionToken, "allow"); + assert.equal(first.status, 200); + const twice = await app.act(actionToken, "deny"); + assert.equal(twice.status, 410); + assert.equal(twice.body.stale, true); + const cmd = await app.get(`SELECT * FROM herd_commands WHERE id = ?`, [first.body.id]); + assert.equal(cmd.kind, "approve"); + assert.equal(cmd.via, "push"); + assert.deepEqual(JSON.parse(cmd.args), { intent: "allow", blockedAt: 500 }); + }); + + await t.test("a token for a session that moved on is refused", async () => { + await app.box(me).publish([blocked(900)]); + const { action_token: tok } = await app.get(`SELECT action_token FROM herd_sessions WHERE machine_id = ? AND name = 'api'`, [me.mid]); + assert.ok(tok); + await app.box(me).publish([working(950)]); + const r = await app.act(tok, "allow"); + assert.equal(r.status, 410); + }); + + await t.test("a session that disappears from the box disappears from the phone", async () => { + await app.box(me).publish([]); + const h = await app.phone(me).herd(); + assert.equal(h.body.machines[0].sessions.length, 0); + }); + + await t.test("an offline machine refuses commands with a reason", async () => { + await app.run(`UPDATE herd_machines SET last_seen_at = ? WHERE id = ?`, [Date.now() - 10 * 60e3, me.mid]); + const r = await app.phone(me).command({ machine: me.mid, session: "api", kind: "prompt", args: { text: "hi" } }); + assert.equal(r.status, 409); + assert.match(r.body.error, /offline/); + }); +}); + +test("shouldPush: only transitions into blocked or done, never a first publish", async () => { + if (!deps) return; + const { shouldPush, pushFor } = await import("../src/routes/herd.mjs"); + assert.equal(shouldPush(undefined, { state: "blocked", blocked_at: 1 }), false); + assert.equal(shouldPush({ state: "working" }, { state: "blocked", blocked_at: 1 }, { firstPublish: true }), false); + assert.equal(shouldPush({ state: "working" }, { state: "blocked", blocked_at: 1 }), true); + assert.equal(shouldPush({ state: "blocked", blocked_at: 1 }, { state: "blocked", blocked_at: 1 }), false); + assert.equal(shouldPush({ state: "blocked", blocked_at: 1 }, { state: "blocked", blocked_at: 2 }), true, "a second prompt"); + assert.equal(shouldPush({ state: "working" }, { state: "done" }), true); + assert.equal(shouldPush({ state: "idle" }, { state: "done" }), false); + assert.equal(shouldPush({ state: "blocked" }, { state: "working" }), false); + + const push = pushFor({ id: "m1", name: "me@box" }, { name: "api", engine: "claude", state: "blocked", cwd: "/src/api", approval: { allowAll: true }, action_token: "tok" }); + assert.equal(push.title, "api wants permission"); + assert.deepEqual(push.actions.map((a) => a.action), ["allow", "deny"]); + assert.deepEqual(push.act, { token: "tok" }); + assert.equal(push.url, "/m#/p/m1/api"); + assert.ok(!push.body.includes("Do you want"), "no screen text leaves through the push service"); + const done = pushFor({ id: "m1", name: "me@box" }, { name: "api", engine: "claude", state: "done", cwd: "/src/api" }); + assert.equal(done.actions, undefined); +}); diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 9e58936a..902d5ee0 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -1650,6 +1650,17 @@ export const HERD_VERBS = [ { name: "watch", description: "deliver those notifications (run it inside the herd)", synopsis: [["moshcode herd watch [--interval 5s]", ""]], flags: [["--interval ", "how often to look", "5s"], ["--force", "watch even with notifications off", ""]] }, + // PRD 0020 — the herd in your pocket. + { name: "phone", description: "put this herd on your phone (app.moshcode.sh/m): roster, one-tap approvals, a terminal with a key bar", + synopsis: [["moshcode herd phone ", ""]], + flags: [["--json", "machine-readable (status)", ""]], + examples: [ + ["moshcode herd phone on", "install the relay as a user service, then open app.moshcode.sh/m on your phone"], + ["moshcode herd phone status", "is the relay running, and as which machine"], + ], + note: "the box dials out to app.moshcode.sh; nothing listens, so there is no port, no Tailscale and no SSH key on the phone." }, + { name: "relay", description: "run the phone relay in the foreground (what `herd phone on` installs)", + synopsis: [["moshcode herd relay", ""]] }, { name: "stop", description: "stop the whole runtime and everything in it", synopsis: [["moshcode herd stop --yes", ""]], flags: [["--yes, -y", "required when sessions are running", ""]] }, diff --git a/src/engines.mjs b/src/engines.mjs index 088518e7..c3b874a9 100644 --- a/src/engines.mjs +++ b/src/engines.mjs @@ -179,6 +179,23 @@ export const ENGINES = { // works cannot outrank "esc to interrupt". idle: [/\?\s+for shortc/i, /shift\+tab to cycle/i, /^\s*❯\s+Try\s+"/m], }, + // One-tap answers to the permission dialog from a phone (PRD 0020 §2). + // `prompt` must match the bottom of the screen before any key is sent: + // it is what tells a live permission dialog from whatever came after it, + // so a late tap is refused as stale instead of typing `1` into the next + // prompt. Claude's dialog is a numbered menu, and a digit picks its option + // outright; option 2 is "Yes, and don't ask again …" for commands and + // "Yes, allow all edits during this session" for edits. Esc is the deny + // the dialog itself advertises. Fixture screens: test/fixtures/approve/. + approve: { + prompt: [/\bdo you want to (?:proceed|make this edit|create|allow)\b/i], + // Older releases draw the dialog inside a box, so a line may open with │. + options: [/^[\s│|]*[❯›>]?\s*1\.\s*Yes\b/m], + allow: ["1"], + allowAll: ["2"], + allowAllWhen: /^[\s│|]*[❯›>]?\s*2\.\s*Yes,/m, + deny: ["Escape"], + }, }, codex: { desc: "Codex — OpenAI's coding CLI", @@ -199,6 +216,19 @@ export const ENGINES = { blocked: [/\ballow (?:this )?command\b/i, /\bapprove this (?:command|edit|change)\b/i], working: [/\besc to interrupt\b/i, /^\s*working\b/im], }, + // Codex labels each approval option with its own shortcut letter — + // "Yes, proceed (y)", "… don't ask again for this command (a)" — and those + // letters are what it binds, so they are what we send. "Always" is only + // offered when the dialog offers it. See claude's `approve` for the rules. + approve: { + prompt: [/\bwould you like to (?:run|make|apply)\b/i, /\ballow (?:this )?command\?/i], + options: [/\bYes,? proceed\b|\(y\)/i], + allow: ["y"], + allowAll: ["a"], + allowAllWhen: /\(a\)/, + footer: [/\bpress enter to confirm\b|\besc to cancel\b/i], + deny: ["Escape"], + }, }, gemini: { desc: "Gemini CLI — Google's agentic CLI", diff --git a/src/herd-cli.mjs b/src/herd-cli.mjs index bc545b24..93b682d7 100644 --- a/src/herd-cli.mjs +++ b/src/herd-cli.mjs @@ -1123,6 +1123,97 @@ export async function herdWatch(argv, { write = console.log, once = false } = {} } } +/** + * The phone relay in the foreground (PRD 0020). `herd phone on` installs this + * as a service; running it by hand is for a box with no service manager, or + * for watching what it does. + */ +export async function herdRelay(argv, { write = console.log } = {}) { + const { runRelay } = await import("./herd-relay.mjs"); + const { moshcodeVersion } = await import("./ui.mjs"); + const controller = new AbortController(); + for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => controller.abort()); + try { + await runRelay({ roster, signal: controller.signal, version: moshcodeVersion() || "", write: (line) => write(info(line)) }); + return EXIT.matched; + } catch (e) { + write(err(e.message || String(e))); + return EXIT.infra; + } +} + +/** `moshcode herd phone on|off|status` — install, remove or inspect the relay service. */ +export async function herdPhone(argv, { write = console.log, exec = null } = {}) { + const relay = await import("./herd-relay.mjs"); + const { loadCreds } = await import("./auth.mjs"); + const [verb = "status"] = argv; + const run = exec || (async (cmd, args) => { + const { spawnSync } = await import("node:child_process"); + const r = spawnSync(cmd, args, { encoding: "utf8" }); + return { ok: r.status === 0, error: (r.stderr || r.error?.message || "").trim(), stdout: r.stdout || "" }; + }); + const file = relay.relayServicePath(); + const api = (process.env.MOSHCODE_API || loadCreds()?.api || "https://app.moshcode.sh").replace(/\/+$/, ""); + + if (verb === "on") { + if (!process.env.MOSHCODE_API_KEY && !loadCreds()?.token) { + // No manual steps: log in now rather than tell someone to. + write(info("not logged in to app.moshcode.sh yet — logging in first.")); + const { loginAuto } = await import("./auth.mjs"); + const result = await loginAuto({}).catch((e) => ({ ok: false, error: e })); + if (!loadCreds()?.token) { + write(err(`login did not finish${result?.error ? `: ${result.error.message || result.error}` : ""} — run \`moshcode login\` and then this again.`)); + return EXIT.usage; + } + } + const machine = relay.machineIdentity(); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, relay.relayServiceFile({ env: relay.relayEnv() })); + write(ok(`wrote ${file}`)); + for (const [cmd, args, opts] of relay.relayServiceCommands({ file, action: "on" })) { + const r = await run(cmd, args); + if (!r.ok && !opts?.optional) { + write(err(`${cmd} ${args.join(" ")} failed: ${r.error}`)); + write(info(`run ${acid("moshcode herd relay")} in the herd instead; it is the same process in the foreground.`)); + return EXIT.infra; + } + if (r.ok) write(ok(`${cmd} ${args.join(" ")}`)); + } + write(""); + write(ok(`${bone(machine.name)} is on your phone. open ${acid(`${api}/m`)} and add it to your home screen.`)); + write(info("sign in with the same account, allow notifications, and a blocked agent buzzes you with Allow / Deny.")); + return EXIT.matched; + } + + if (verb === "off") { + for (const [cmd, args] of relay.relayServiceCommands({ file, action: "off" })) await run(cmd, args); + try { fs.rmSync(file, { force: true }); } catch { /* already gone */ } + write(ok("phone relay off — this box is no longer on app.moshcode.sh/m.")); + return EXIT.matched; + } + + if (verb === "status") { + const installed = fs.existsSync(file); + const [[cmd, args]] = relay.relayServiceCommands({ file, action: "status" }); + const running = installed ? (await run(cmd, args)).ok : false; + const machine = relay.machineIdentity(); + const loggedIn = Boolean(process.env.MOSHCODE_API_KEY || loadCreds()?.token); + if (argv.includes("--json")) { + write(JSON.stringify({ installed, running, loggedIn, machine, file, url: `${api}/m` })); + return EXIT.matched; + } + write(`${bone("machine")} ${machine.name} ${dim(machine.id)}`); + write(`${bone("service")} ${installed ? (running ? acid("running") : amber("installed, not running")) : ash("not installed")} ${dim(file)}`); + write(`${bone("login")} ${loggedIn ? acid("yes") : amber("no — run `moshcode login`")}`); + write(`${bone("phone")} ${acid(`${api}/m`)}`); + if (!installed) write(info(`turn it on with ${acid("moshcode herd phone on")}`)); + return EXIT.matched; + } + + write(err("usage: moshcode herd phone ")); + return EXIT.usage; +} + /** * What a reply to each kind of blocked has to look like. * @@ -1780,6 +1871,8 @@ const VERBS = { read: herdRead, prompt: herdPrompt, "send-keys": herdSendKeys, wait: herdWait, restore: herdRestore, report: herdReport, notify: herdNotify, watch: herdWatch, stop: herdStop, + // PRD 0020: the herd on a phone, through a relay the box dials out on. + phone: herdPhone, relay: herdRelay, // PRD 0011. Same shape as everything above: one verb, `--json` on all of // them, and no second API anywhere — `serve` is this surface answering a // socket rather than a parallel one. diff --git a/src/herd-relay.mjs b/src/herd-relay.mjs new file mode 100644 index 00000000..c7d59d42 --- /dev/null +++ b/src/herd-relay.mjs @@ -0,0 +1,534 @@ +// The herd in your pocket (PRD 0020, phase 1): the box half. +// +// `moshcode herd relay` is a long-lived process that dials OUT to +// app.moshcode.sh. Nothing listens on the box, so there is no port to open, no +// Tailscale and no SSH key on the phone. It does three things: +// +// roster POST /api/herd/roster on every change and every 30 s, carrying +// each session's state, what it is blocked on, and the last lines +// of its screen. +// commands long-polls GET /api/herd/commands and runs what the phone asked +// for — approve, prompt, keys, open-stream — through the herd verbs +// that already exist (send-keys, prompt). No new pty code. +// screens while a phone is looking at one pane, POSTs that pane's screen +// when it changes. Nothing streams unless somebody is watching, and +// a stream that nobody reads stops on its own after a minute. +// +// `moshcode herd phone on` installs it as a user service so it outlives the +// shell that started it; see installRelayService. +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { loadCreds } from "./auth.mjs"; +import { ENGINES } from "./engines.mjs"; +import { capture, detectSubstrate, herdDir, sendKeys, sendPrompt } from "./herd.mjs"; +import { stripAnsi } from "./herd-state.mjs"; + +export const HEARTBEAT_MS = 30 * 1000; +export const TICK_MS = 3 * 1000; +export const STREAM_TICK_MS = 700; +// A stream that no phone has asked about for this long stops (PRD §1). +export const STREAM_IDLE_MS = 60 * 1000; +export const LAST_LINES = 6; +const MAX_PROMPT = 4000; + +const API = () => (process.env.MOSHCODE_API || loadCreds()?.api || "https://app.moshcode.sh").replace(/\/+$/, ""); +const KEY = () => process.env.MOSHCODE_API_KEY || loadCreds()?.token || ""; + +/** + * This box, as the phone will know it. The id is random and written once, so + * a hostname change (a laptop renamed, a container rebuilt with the same + * volume) is the same machine and not a second one in the list. + */ +export function machineIdentity({ dir = herdDir(), host = os.hostname(), user = os.userInfo().username } = {}) { + const file = path.join(dir, "machine-id"); + let id = ""; + try { id = fs.readFileSync(file, "utf8").trim(); } catch { /* first run */ } + if (!/^[0-9a-f-]{36}$/.test(id)) { + id = crypto.randomUUID(); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(file, `${id}\n`, { mode: 0o600 }); + } + return { id, name: `${user}@${host}` }; +} + +// --------------------------------------------------------------------------- +// Approvals +// --------------------------------------------------------------------------- + +/** The bottom of a screen, where a live dialog is. Same window classify reads. */ +function tail(screen, lines = 25) { + const all = stripAnsi(screen).split("\n"); + return all.slice(Math.max(0, all.length - lines)).join("\n"); +} + +/** + * Is `screen` showing this engine's permission dialog right now, and which + * answers does it accept? Null when it is not, which is the stale guard: the + * phone's tap is only ever turned into keys while this says yes. + */ +export function approvalFor(engine, screen) { + const spec = ENGINES[engine]?.approve; + if (!spec || !screen) return null; + const text = tail(screen); + if (!spec.prompt.some((re) => re.test(text))) return null; + if (spec.options && !spec.options.some((re) => re.test(text))) return null; + // The dialog has to be the last thing on the screen, not merely somewhere in + // its bottom 25 lines: an answered dialog leaves its text behind until the + // engine redraws, and a second tap must not type `1` into what came next. + // So below the menu's last numbered option there may be only box borders and + // the engine's own footer (`approve.footer`, e.g. Codex's "Press enter to + // confirm or esc to cancel"); any other line means the dialog is history. + const lines = text.split("\n").filter((l) => l.trim() && !/^[\s─━═╰╯╭╮└┘┌┐│|]+$/.test(l)); + let last = -1; + lines.forEach((l, i) => { if (/^[\s│|]*[❯›▸>]?\s*\d+\.\s+\S/.test(l)) last = i; }); + if (last < 0) return null; + const footer = spec.footer || []; + if (!lines.slice(last + 1).every((l) => footer.some((re) => re.test(l)))) return null; + return { allowAll: Boolean(spec.allowAll && (!spec.allowAllWhen || spec.allowAllWhen.test(text))) }; +} + +export const INTENTS = ["allow", "allowAll", "deny"]; + +// The phone's key bar, by name. Each key is spelled twice because the two +// substrates take different things: tmux reads key names, and the pty fallback +// writes bytes. Only these names are accepted from the phone. +export const PHONE_KEYS = { + esc: { tmux: "Escape", bytes: "\x1b" }, + tab: { tmux: "Tab", bytes: "\t" }, + "shift-tab": { tmux: "BTab", bytes: "\x1b[Z" }, + up: { tmux: "Up", bytes: "\x1b[A" }, + down: { tmux: "Down", bytes: "\x1b[B" }, + right: { tmux: "Right", bytes: "\x1b[C" }, + left: { tmux: "Left", bytes: "\x1b[D" }, + enter: { tmux: "Enter", bytes: "\r" }, + "ctrl-c": { tmux: "C-c", bytes: "\x03" }, + "ctrl-d": { tmux: "C-d", bytes: "\x04" }, + slash: { tmux: "/", bytes: "/" }, + backspace: { tmux: "BSpace", bytes: "\x7f" }, + y: { tmux: "y", bytes: "y" }, + n: { tmux: "n", bytes: "n" }, + 1: { tmux: "1", bytes: "1" }, + 2: { tmux: "2", bytes: "2" }, + 3: { tmux: "3", bytes: "3" }, +}; + +function keysFor(names, substrate) { + return names.map((name) => (substrate === "tmux" ? PHONE_KEYS[name].tmux : PHONE_KEYS[name].bytes)); +} + +/** The engine's own keys for an intent, spelled for the substrate. */ +function intentKeys(engine, intent, substrate) { + const keys = ENGINES[engine]?.approve?.[intent] || []; + if (substrate === "tmux") return keys; + return keys.map((k) => (k === "Escape" ? "\x1b" : k === "Enter" ? "\r" : k)); +} + +// --------------------------------------------------------------------------- +// The relay's memory of the herd +// --------------------------------------------------------------------------- + +/** + * Per-session bookkeeping the server cannot do for us: when did the current + * state start? `blockedAt` is what an approve command carries back, and a + * session that left blocked and came back is a different question with a + * different `blockedAt` — so an answer to the first can never land on the + * second. + */ +export function createTracker({ now = () => Date.now() } = {}) { + const seen = new Map(); // name -> { state, blockedOn, since } + return { + observe(session) { + const prev = seen.get(session.name); + const key = `${session.state}:${session.blockedOn || ""}`; + if (!prev || prev.key !== key) { + const entry = { key, since: now() }; + seen.set(session.name, entry); + return entry.since; + } + return prev.since; + }, + since(name) { return seen.get(name)?.since ?? null; }, + forget(present) { for (const name of [...seen.keys()]) if (!present.has(name)) seen.delete(name); }, + }; +} + +/** The last few non-blank lines of a screen, for the roster row. */ +export function lastLinesOf(screen, count = LAST_LINES) { + return stripAnsi(screen || "").split("\n") + // A dialog's box is paint, not content: drop border-only lines and the + // side rails, so the phone's one-line preview is words. + .map((l) => l.replace(/^\s*[│┃|]\s?/, "").replace(/\s*[│┃|]\s*$/, "").replace(/\s+$/, "")) + .filter((l) => l.trim() && !/^[\s─━═╰╯╭╮└┘┌┐│┃|]+$/.test(l)) + .slice(-count); +} + +/** + * One roster row per session, the shape the phone lists. `read` is the screen + * capture, injected for tests. + */ +export function snapshot(rows, tracker, { read = (name) => capture(name, { lines: 40 }) } = {}) { + const sessions = []; + for (const s of rows) { + if (s.state === "gone") continue; + const since = tracker.observe(s); + const screen = s.kind === "remote" || !s.alive ? "" : read(s.name); + const approval = s.state === "blocked" ? approvalFor(s.engine, screen) : null; + sessions.push({ + name: s.name, + engine: String(s.engine || ""), + state: s.state, + blockedOn: s.blockedOn || null, + confidence: s.confidence || null, + cwd: s.cwd || "", + kind: s.kind || "local", + lastLines: lastLinesOf(screen), + since, + blockedAt: s.state === "blocked" ? since : null, + approval, + }); + } + tracker.forget(new Set(rows.map((r) => r.name))); + return sessions; +} + +/** What has to change for a roster to be worth publishing before the heartbeat. */ +export function fingerprint(sessions) { + return JSON.stringify(sessions.map((s) => [s.name, s.state, s.blockedOn, s.lastLines, s.approval])); +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +/** + * Run one command from the phone. Returns what the phone is told: + * `{ ok: true }`, `{ ok: false, stale: true }` (already answered, or the + * dialog changed), or `{ ok: false, error }`. + */ +export function execute(command, { + rows, + tracker, + streams, + substrate = detectSubstrate(), + read = (name) => capture(name, { lines: 40 }), + keys = sendKeys, + prompt = sendPrompt, + now = () => Date.now(), +} = {}) { + const args = command.args || {}; + const session = rows.find((r) => r.name === command.session); + if (!session || session.state === "gone") return { ok: false, error: "no such session" }; + if (session.kind === "remote") return { ok: false, error: "that session is a remote member; answer it on its own box" }; + + if (command.kind === "approve") { + const intent = args.intent; + if (!INTENTS.includes(intent)) return { ok: false, error: "unknown answer" }; + // Stale guard, part one: the session must still be in the same blocked + // spell the phone saw. Part two: the dialog must still be on the screen. + if (session.state !== "blocked") return { ok: false, stale: true }; + const since = tracker.since(session.name); + if (args.blockedAt != null && since != null && Number(args.blockedAt) !== since) return { ok: false, stale: true }; + const approval = approvalFor(session.engine, read(session.name)); + if (!approval) return { ok: false, stale: true }; + if (intent === "allowAll" && !approval.allowAll) return { ok: false, error: "this dialog has no allow-all option" }; + const sent = keys(session.name, intentKeys(session.engine, intent, substrate), { substrate }); + return sent.ok ? { ok: true } : { ok: false, error: sent.error?.message || "send-keys failed" }; + } + + if (command.kind === "prompt") { + const text = String(args.text || "").slice(0, MAX_PROMPT); + if (!text.trim()) return { ok: false, error: "empty prompt" }; + const sent = prompt(session.name, text, { substrate }); + return sent.ok ? { ok: true } : { ok: false, error: sent.error?.message || "prompt failed" }; + } + + if (command.kind === "keys") { + const names = Array.isArray(args.keys) ? args.keys.map(String) : []; + if (!names.length || names.length > 16 || !names.every((n) => Object.hasOwn(PHONE_KEYS, n))) { + return { ok: false, error: "unknown key" }; + } + // One key per send-keys call on the pty side would interleave with output; + // one call carrying all of them is what a fast typist would produce anyway. + const sent = keys(session.name, keysFor(names, substrate), { substrate }); + return sent.ok ? { ok: true } : { ok: false, error: sent.error?.message || "send-keys failed" }; + } + + if (command.kind === "open-stream") { + streams.open(session.name, now()); + return { ok: true }; + } + + return { ok: false, error: `unknown command ${JSON.stringify(command.kind)}` }; +} + +/** Which panes are being streamed, and until when. */ +export function createStreams({ idleMs = STREAM_IDLE_MS } = {}) { + const open = new Map(); // name -> { until, last } + return { + open(name, at) { + const entry = open.get(name) || { last: null }; + entry.until = at + idleMs; + open.set(name, entry); + }, + extend(name, at) { const e = open.get(name); if (e) e.until = at + idleMs; }, + close(name) { open.delete(name); }, + active(at) { + for (const [name, e] of open) if (e.until <= at) open.delete(name); + return [...open.keys()]; + }, + entry(name) { return open.get(name) || null; }, + }; +} + +// --------------------------------------------------------------------------- +// The loop +// --------------------------------------------------------------------------- + +async function call(fetchImpl, method, url, body, { key, signal } = {}) { + const res = await fetchImpl(url, { + method, + headers: { authorization: `Bearer ${key}`, ...(body ? { "content-type": "application/json" } : {}) }, + body: body ? JSON.stringify(body) : undefined, + signal, + }); + let data = null; + try { data = await res.json(); } catch { data = null; } + return { ok: res.ok, status: res.status, data }; +} + +const pause = (ms, signal) => new Promise((resolve) => { + const t = setTimeout(resolve, ms); + signal?.addEventListener?.("abort", () => { clearTimeout(t); resolve(); }, { once: true }); +}); + +/** + * Run the relay until `signal` aborts. Three loops sharing one roster: + * publish, command pull, and screen streams. Every network failure is + * retried with a pause; the relay is supposed to be the thing that stays up. + */ +export async function runRelay({ + roster, + fetchImpl = fetch, + api = API(), + key = KEY(), + machine = machineIdentity(), + version = "", + signal: outer, + write = console.log, + read, + screenRead = (name) => capture(name, { lines: 0, escapes: true }), + keysImpl = sendKeys, + promptImpl = sendPrompt, + now = () => Date.now(), + tickMs = TICK_MS, + heartbeatMs = HEARTBEAT_MS, + streamTickMs = STREAM_TICK_MS, +} = {}) { + if (!key) throw new Error("not logged in — run `moshcode login` first"); + // One controller for all three loops, so a fatal error in one (a revoked + // login) stops the other two instead of leaving them polling forever. + const inner = new AbortController(); + const signal = inner.signal; + if (outer?.aborted) inner.abort(); + outer?.addEventListener?.("abort", () => inner.abort(), { once: true }); + let fatal = null; + const tracker = createTracker({ now }); + const streams = createStreams(); + const opts = read ? { read } : {}; + let rows = roster(); + let dirty = true; + let lastPrint = ""; + let lastSent = 0; + let failures = 0; + + const publish = async () => { + rows = roster(); + const sessions = snapshot(rows, tracker, opts); + const print = fingerprint(sessions); + if (!dirty && print === lastPrint && now() - lastSent < heartbeatMs) return; + const r = await call(fetchImpl, "POST", `${api}/api/herd/roster`, { + machine: { id: machine.id, name: machine.name, platform: process.platform, version }, + sessions, + }, { key, signal }); + if (r.status === 401) throw new Error("app.moshcode.sh refused this login (401) — run `moshcode login` again"); + if (!r.ok) throw new Error(`roster publish failed (${r.status})`); + lastPrint = print; + lastSent = now(); + dirty = false; + }; + + const publishLoop = async () => { + while (!signal?.aborted) { + try { await publish(); failures = 0; } + catch (e) { + if (signal?.aborted) return; + failures++; + if (failures === 1 || failures % 20 === 0) write(`relay: ${e.message}`); + if (/401/.test(e.message)) { fatal = e; inner.abort(); return; } + } + await pause(tickMs, signal); + } + }; + + const commandLoop = async () => { + while (!signal?.aborted) { + let r; + try { + r = await call(fetchImpl, "GET", `${api}/api/herd/commands?machine=${encodeURIComponent(machine.id)}`, null, { key, signal }); + } catch { + if (signal?.aborted) return; + await pause(5000, signal); + continue; + } + if (!r.ok) { await pause(r.status === 404 ? 3000 : 5000, signal); continue; } + for (const command of r.data?.commands || []) { + let result; + try { + rows = roster(); + for (const s of rows) tracker.observe(s); + result = execute(command, { rows, tracker, streams, read: opts.read, keys: keysImpl, prompt: promptImpl, now }); + } catch (e) { + result = { ok: false, error: String(e.message || e) }; + } + dirty = true; + try { + await call(fetchImpl, "POST", `${api}/api/herd/commands/${encodeURIComponent(command.id)}`, + { machine: machine.id, ...result }, { key, signal }); + } catch { /* the phone will see it as unanswered and can retry */ } + write(`relay: ${command.kind} ${command.session} → ${result.ok ? "ok" : result.stale ? "stale" : result.error}`); + } + } + }; + + const streamLoop = async () => { + while (!signal?.aborted) { + for (const name of streams.active(now())) { + const entry = streams.entry(name); + let screen = ""; + try { screen = screenRead(name); } catch { screen = ""; } + if (screen === entry.last) continue; + try { + const r = await call(fetchImpl, "POST", `${api}/api/herd/screen`, + { machine: machine.id, session: name, screen }, { key, signal }); + entry.last = screen; + if (r.ok && r.data?.watching === false) streams.close(name); + else if (r.ok) streams.extend(name, now()); + } catch { /* next tick */ } + } + await pause(streamTickMs, signal); + } + }; + + write(`relay: ${machine.name} → ${api}/m`); + await Promise.all([publishLoop(), commandLoop(), streamLoop()]); + if (fatal) throw fatal; +} + +// --------------------------------------------------------------------------- +// The service: `moshcode herd phone on|off|status` +// --------------------------------------------------------------------------- + +export const RELAY_UNIT = "moshcode-herd-relay.service"; +export const RELAY_LABEL = "sh.moshcode.herd-relay"; + +/** The entry script this install runs from. */ +export function entryScript() { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "bin", "moshcode.mjs"); +} + +/** + * Where the service definition lives. Linux gets a systemd user unit, macOS a + * LaunchAgent. Both are written from the running process (node and the entry + * script), for the reason src/dns-service.mjs gives: nothing on a mise or nvm + * box is on the service manager's PATH. + */ +export function relayServicePath({ platform = process.platform, home = os.homedir() } = {}) { + if (platform === "darwin") return path.join(home, "Library", "LaunchAgents", `${RELAY_LABEL}.plist`); + return path.join(home, ".config", "systemd", "user", RELAY_UNIT); +} + +export function relayServiceFile({ platform = process.platform, execPath = process.execPath, entry = entryScript(), env = {} } = {}) { + if (platform === "darwin") { + const esc = (s) => String(s).replace(/&/g, "&").replace(/ ` ${esc(k)}${esc(v)}`).join("\n"); + return ` + + + + + Label${RELAY_LABEL} + ProgramArguments + + ${esc(execPath)} + ${esc(entry)} + herd + relay + +${envXml ? ` EnvironmentVariables\n \n${envXml}\n \n` : ""} RunAtLoad + KeepAlive + StandardOutPath/tmp/moshcode-herd-relay.log + StandardErrorPath/tmp/moshcode-herd-relay.log + + +`; + } + const exec = [execPath, entry, "herd", "relay"].map((p) => (/\s/.test(p) ? JSON.stringify(p) : p)).join(" "); + return [ + "# Generated by `moshcode herd phone on`. Regenerate it rather than editing:", + "# the paths below are this install's.", + "[Unit]", + "Description=moshcode herd relay (your herd on your phone)", + "Documentation=https://github.com/moshcoder/moshcode/blob/main/prd/0020-the-herd-in-your-pocket.md", + "After=network-online.target", + "Wants=network-online.target", + "", + "[Service]", + "Type=simple", + `ExecStart=${exec}`, + ...Object.entries(env).map(([k, v]) => `Environment=${k}=${v}`), + "Restart=always", + "RestartSec=5", + "NoNewPrivileges=yes", + "", + "[Install]", + "WantedBy=default.target", + "", + ].join("\n"); +} + +/** + * The environment the service needs that a service manager will not give it: + * the herd's tmux socket and home, which are where `herd ps` looks. + */ +export function relayEnv(env = process.env) { + const out = {}; + for (const k of ["MOSHCODE_HOME", "MOSHCODE_HERD_SOCKET", "MOSHCODE_API", "TMUX_TMPDIR"]) if (env[k]) out[k] = env[k]; + return out; +} + +export function relayServiceCommands({ platform = process.platform, file = relayServicePath({ platform }), action }) { + if (platform === "darwin") { + const uid = typeof process.getuid === "function" ? process.getuid() : 501; + if (action === "on") return [["launchctl", ["bootout", `gui/${uid}/${RELAY_LABEL}`], { optional: true }], ["launchctl", ["bootstrap", `gui/${uid}`, file]]]; + if (action === "off") return [["launchctl", ["bootout", `gui/${uid}/${RELAY_LABEL}`], { optional: true }]]; + return [["launchctl", ["print", `gui/${uid}/${RELAY_LABEL}`]]]; + } + if (action === "on") { + return [ + ["systemctl", ["--user", "daemon-reload"]], + ["systemctl", ["--user", "enable", RELAY_UNIT]], + ["systemctl", ["--user", "restart", RELAY_UNIT]], + // Without lingering, a user service stops when the last session ends — + // which on a server is the moment you log out, and the whole point of + // this is that you are not logged in. Allowed for yourself by default + // polkit policy; harmless when it is refused. + ["loginctl", ["enable-linger", os.userInfo().username], { optional: true }], + ]; + } + if (action === "off") return [["systemctl", ["--user", "disable", "--now", RELAY_UNIT], { optional: true }]]; + return [["systemctl", ["--user", "is-active", RELAY_UNIT]]]; +} diff --git a/src/herd.mjs b/src/herd.mjs index 1d866d69..53154b94 100644 --- a/src/herd.mjs +++ b/src/herd.mjs @@ -712,9 +712,12 @@ export function startSession({ } /** The last `lines` rows of a session's screen — what the classifier reads. */ -export function capture(name, { lines = 60, substrate = detectSubstrate(), runner = spawnSync } = {}) { +export function capture(name, { lines = 60, escapes = false, substrate = detectSubstrate(), runner = spawnSync } = {}) { if (substrate === "tmux") { - const r = tmux(["capture-pane", "-p", "-t", target(name, { runner }), "-S", `-${Math.max(0, lines)}`], { runner }); + // `escapes` keeps colour and attributes (-e), for a screen that is going to + // be painted by a terminal rather than read by a classifier. + const flags = escapes ? ["-p", "-e"] : ["-p"]; + const r = tmux(["capture-pane", ...flags, "-t", target(name, { runner }), "-S", `-${Math.max(0, lines)}`], { runner }); return r.ok ? r.stdout.replace(/\n+$/, "") : ""; } if (substrate === "pty") return ptyCapture(name, lines); diff --git a/test/fixtures/approve/claude-bash.txt b/test/fixtures/approve/claude-bash.txt new file mode 100644 index 00000000..75d1619d --- /dev/null +++ b/test/fixtures/approve/claude-bash.txt @@ -0,0 +1,13 @@ +⏺ I'll run the test suite to check the change. + +╭──────────────────────────────────────────────────────────────────────────────╮ +│ Bash command │ +│ │ +│ npm test │ +│ Run the test suite │ +│ │ +│ Do you want to proceed? │ +│ ❯ 1. Yes │ +│ 2. Yes, and don't ask again for npm test commands in /home/me/src/api │ +│ 3. No, and tell Claude what to do differently (esc) │ +╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/test/fixtures/approve/claude-edit.txt b/test/fixtures/approve/claude-edit.txt new file mode 100644 index 00000000..7a200180 --- /dev/null +++ b/test/fixtures/approve/claude-edit.txt @@ -0,0 +1,11 @@ +⏺ Update(src/server.mjs) + + Edit file + src/server.mjs + 12 import { sessionsRouter } from "./routes/sessions.mjs"; + 13 +import { herdRouter } from "./routes/herd.mjs"; + + Do you want to make this edit to server.mjs? + ❯ 1. Yes + 2. Yes, allow all edits during this session (shift+tab) + 3. No, and tell Claude what to do differently (esc) diff --git a/test/fixtures/approve/claude-talking-about-permissions.txt b/test/fixtures/approve/claude-talking-about-permissions.txt new file mode 100644 index 00000000..ddc8f9d9 --- /dev/null +++ b/test/fixtures/approve/claude-talking-about-permissions.txt @@ -0,0 +1,9 @@ +⏺ The approvals page now asks "Do you want to proceed?" before deploying, and + lists the options as 1. Yes and 2. No. I updated the copy and the tests. + + Shall I also update the README? + +╭──────────────────────────────────────────────────────────────────────────────╮ +│ > │ +╰──────────────────────────────────────────────────────────────────────────────╯ + ? for shortcuts diff --git a/test/fixtures/approve/codex-command.txt b/test/fixtures/approve/codex-command.txt new file mode 100644 index 00000000..f10795b4 --- /dev/null +++ b/test/fixtures/approve/codex-command.txt @@ -0,0 +1,11 @@ +• I need to run the tests to confirm the fix. + + Would you like to run the following command? + + $ npm test + +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for this command (a) + 3. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel diff --git a/test/herd-relay.test.mjs b/test/herd-relay.test.mjs new file mode 100644 index 00000000..b5c58682 --- /dev/null +++ b/test/herd-relay.test.mjs @@ -0,0 +1,291 @@ +// PRD 0020 phase 1: the box half of the herd on a phone. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + approvalFor, createStreams, createTracker, execute, fingerprint, lastLinesOf, machineIdentity, + PHONE_KEYS, relayServiceCommands, relayServiceFile, runRelay, snapshot, +} from "../src/herd-relay.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const fixture = (name) => fs.readFileSync(path.join(here, "fixtures", "approve", name), "utf8"); + +// ---- approve maps against real dialogs ---- + +test("claude's Bash permission dialog is approvable, with allow-all", () => { + assert.deepEqual(approvalFor("claude", fixture("claude-bash.txt")), { allowAll: true }); +}); + +test("claude's edit dialog is approvable, with allow-all-edits", () => { + assert.deepEqual(approvalFor("claude", fixture("claude-edit.txt")), { allowAll: true }); +}); + +test("codex's command dialog is approvable, and offers (a)", () => { + assert.deepEqual(approvalFor("codex", fixture("codex-command.txt")), { allowAll: true }); +}); + +test("prose about a dialog is not a dialog", () => { + assert.equal(approvalFor("claude", fixture("claude-talking-about-permissions.txt")), null); +}); + +test("an engine with no approve map is never approvable", () => { + assert.equal(approvalFor("aider", fixture("claude-bash.txt")), null); + assert.equal(approvalFor("nope", fixture("claude-bash.txt")), null); +}); + +test("codex without the (a) option offers no allow-all", () => { + const screen = fixture("codex-command.txt").replace(/^.*\(a\)\n/m, ""); + assert.deepEqual(approvalFor("codex", screen), { allowAll: false }); +}); + +test("an answered dialog still on screen is not live (found live: a second Allow typed into the next prompt)", () => { + const answered = `${fixture("claude-bash.txt")}\nANSWERED: [1]\n? for shortcuts\n`; + assert.equal(approvalFor("claude", answered), null); + const codex = `${fixture("codex-command.txt")}\n• Ran npm test\n └ 42 passing\n\n› `; + assert.equal(approvalFor("codex", codex), null); +}); + +test("a dialog scrolled far above the bottom is not live", () => { + const screen = fixture("claude-bash.txt") + "\n" + Array(40).fill("output line").join("\n"); + assert.equal(approvalFor("claude", screen), null); +}); + +// ---- the command executor ---- + +function rig({ state = "blocked", engine = "claude", screen = fixture("claude-bash.txt"), substrate = "tmux" } = {}) { + let clock = 1000; + const tracker = createTracker({ now: () => clock }); + const rows = [{ name: "api", engine, state, blockedOn: state === "blocked" ? "menu" : undefined, alive: true }]; + tracker.observe(rows[0]); + const sent = []; + const prompts = []; + const deps = { + rows, tracker, streams: createStreams(), substrate, + read: () => screen, + keys: (name, keys) => { sent.push({ name, keys }); return { ok: true }; }, + prompt: (name, text) => { prompts.push({ name, text }); return { ok: true }; }, + now: () => clock, + }; + return { deps, sent, prompts, blockedAt: tracker.since("api"), tick: (ms) => { clock += ms; } }; +} + +test("allow sends claude's 1, deny sends Escape, allowAll sends 2", () => { + for (const [intent, keys] of [["allow", ["1"]], ["deny", ["Escape"]], ["allowAll", ["2"]]]) { + const r = rig(); + const result = execute({ kind: "approve", session: "api", args: { intent, blockedAt: r.blockedAt } }, r.deps); + assert.deepEqual(result, { ok: true }); + assert.deepEqual(r.sent, [{ name: "api", keys }]); + } +}); + +test("codex answers with its own letters", () => { + const r = rig({ engine: "codex", screen: fixture("codex-command.txt") }); + execute({ kind: "approve", session: "api", args: { intent: "allow", blockedAt: r.blockedAt } }, r.deps); + assert.deepEqual(r.sent[0].keys, ["y"]); +}); + +test("on the pty substrate Escape is a byte", () => { + const r = rig({ substrate: "pty" }); + execute({ kind: "approve", session: "api", args: { intent: "deny", blockedAt: r.blockedAt } }, r.deps); + assert.deepEqual(r.sent[0].keys, ["\x1b"]); +}); + +test("stale: the session is no longer blocked", () => { + const r = rig({ state: "working" }); + const result = execute({ kind: "approve", session: "api", args: { intent: "allow", blockedAt: 1000 } }, r.deps); + assert.deepEqual(result, { ok: false, stale: true }); + assert.equal(r.sent.length, 0); +}); + +test("stale: a different blocked spell than the one the phone saw", () => { + const r = rig(); + const result = execute({ kind: "approve", session: "api", args: { intent: "allow", blockedAt: r.blockedAt - 5000 } }, r.deps); + assert.deepEqual(result, { ok: false, stale: true }); + assert.equal(r.sent.length, 0); +}); + +test("stale: the dialog left the screen even though state still says blocked", () => { + const r = rig({ screen: fixture("claude-talking-about-permissions.txt") }); + const result = execute({ kind: "approve", session: "api", args: { intent: "allow", blockedAt: r.blockedAt } }, r.deps); + assert.deepEqual(result, { ok: false, stale: true }); + assert.equal(r.sent.length, 0); +}); + +test("allowAll is refused when the dialog has no such option", () => { + const r = rig({ engine: "codex", screen: fixture("codex-command.txt").replace(/^.*\(a\)\n/m, "") }); + const result = execute({ kind: "approve", session: "api", args: { intent: "allowAll", blockedAt: r.blockedAt } }, r.deps); + assert.equal(result.ok, false); + assert.equal(r.sent.length, 0); +}); + +test("keys: only the key bar's names, mapped per substrate", () => { + const r = rig({ state: "working" }); + assert.deepEqual(execute({ kind: "keys", session: "api", args: { keys: ["esc", "ctrl-c", "up"] } }, r.deps), { ok: true }); + assert.deepEqual(r.sent[0].keys, ["Escape", "C-c", "Up"]); + assert.equal(execute({ kind: "keys", session: "api", args: { keys: ["rm -rf /"] } }, r.deps).ok, false); + assert.equal(execute({ kind: "keys", session: "api", args: { keys: ["__proto__"] } }, r.deps).ok, false); + const p = rig({ state: "working", substrate: "pty" }); + execute({ kind: "keys", session: "api", args: { keys: ["up", "enter"] } }, p.deps); + assert.deepEqual(p.sent[0].keys, [PHONE_KEYS.up.bytes, "\r"]); +}); + +test("prompt types text through sendPrompt; empty is refused", () => { + const r = rig({ state: "idle" }); + assert.deepEqual(execute({ kind: "prompt", session: "api", args: { text: "ship it" } }, r.deps), { ok: true }); + assert.deepEqual(r.prompts, [{ name: "api", text: "ship it" }]); + assert.equal(execute({ kind: "prompt", session: "api", args: { text: " " } }, r.deps).ok, false); +}); + +test("unknown sessions and remote members are refused", () => { + const r = rig(); + assert.equal(execute({ kind: "prompt", session: "nope", args: { text: "x" } }, r.deps).error, "no such session"); + r.deps.rows[0].kind = "remote"; + assert.match(execute({ kind: "prompt", session: "api", args: { text: "x" } }, r.deps).error, /remote/); +}); + +test("open-stream opens a stream that expires when nobody asks again", () => { + const r = rig({ state: "working" }); + execute({ kind: "open-stream", session: "api" }, r.deps); + assert.deepEqual(r.deps.streams.active(1000), ["api"]); + assert.deepEqual(r.deps.streams.active(1000 + 61_000), []); +}); + +// ---- the tracker and the snapshot ---- + +test("blockedAt is when the blocked spell began, and a new spell gets a new one", () => { + let clock = 0; + const t = createTracker({ now: () => clock }); + const s = { name: "a", state: "blocked", blockedOn: "menu" }; + assert.equal(t.observe(s), 0); + clock = 10; + assert.equal(t.observe(s), 0, "same spell"); + clock = 20; + t.observe({ ...s, state: "working" }); + clock = 30; + assert.equal(t.observe(s), 30, "blocked again is a new question"); +}); + +test("snapshot shapes rows, drops gone sessions, and marks approvable dialogs", () => { + const t = createTracker({ now: () => 5 }); + const rows = [ + { name: "api", engine: "claude", state: "blocked", blockedOn: "menu", alive: true, cwd: "/src/api", confidence: "inferred" }, + { name: "web", engine: "codex", state: "working", alive: true, cwd: "/src/web" }, + { name: "old", engine: "claude", state: "gone", alive: false }, + ]; + const screens = { api: fixture("claude-bash.txt"), web: "building…\nesc to interrupt" }; + const out = snapshot(rows, t, { read: (n) => screens[n] }); + assert.deepEqual(out.map((s) => s.name), ["api", "web"]); + assert.deepEqual(out[0].approval, { allowAll: true }); + assert.equal(out[0].blockedAt, 5); + assert.equal(out[1].approval, null); + assert.equal(out[1].blockedAt, null); + assert.deepEqual(out[1].lastLines, ["building…", "esc to interrupt"]); +}); + +test("lastLinesOf strips colour and blank lines", () => { + assert.deepEqual(lastLinesOf("\x1b[32mok\x1b[0m\n\n \nnext \n", 5), ["ok", "next"]); +}); + +test("fingerprint ignores the clock, notices state and screen", () => { + const a = [{ name: "x", state: "working", blockedOn: null, lastLines: ["a"], approval: null, since: 1 }]; + assert.equal(fingerprint(a), fingerprint([{ ...a[0], since: 999 }])); + assert.notEqual(fingerprint(a), fingerprint([{ ...a[0], lastLines: ["b"] }])); +}); + +test("machineIdentity is written once and reused", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "herd-relay-id-")); + const one = machineIdentity({ dir, host: "box", user: "me" }); + const two = machineIdentity({ dir, host: "renamed", user: "me" }); + assert.equal(one.id, two.id); + assert.equal(two.name, "me@renamed"); + assert.match(one.id, /^[0-9a-f-]{36}$/); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +// ---- the service ---- + +test("the systemd unit runs this node on this entry, herd relay", () => { + const unit = relayServiceFile({ platform: "linux", execPath: "/opt/node", entry: "/m/bin/moshcode.mjs", env: { MOSHCODE_HERD_SOCKET: "x" } }); + assert.match(unit, /^ExecStart=\/opt\/node \/m\/bin\/moshcode\.mjs herd relay$/m); + assert.match(unit, /^Environment=MOSHCODE_HERD_SOCKET=x$/m); + assert.match(unit, /^WantedBy=default\.target$/m); +}); + +test("the launchd plist keeps the relay alive", () => { + const plist = relayServiceFile({ platform: "darwin", execPath: "/opt/node", entry: "/m/bin/moshcode.mjs" }); + assert.match(plist, /\/opt\/node<\/string>/); + assert.match(plist, /KeepAlive<\/key>/); +}); + +test("phone on enables, restarts and asks for lingering (optional)", () => { + const steps = relayServiceCommands({ platform: "linux", file: "/f", action: "on" }); + assert.deepEqual(steps.map(([c, a]) => `${c} ${a.join(" ")}`).slice(0, 3), [ + "systemctl --user daemon-reload", "systemctl --user enable moshcode-herd-relay.service", "systemctl --user restart moshcode-herd-relay.service", + ]); + assert.equal(steps[3][0], "loginctl"); + assert.equal(steps[3][2].optional, true); +}); + +// ---- the loop, end to end against a fake app ---- + +test("runRelay publishes the roster, runs a queued approve, and reports the result", async () => { + const controller = new AbortController(); + const calls = []; + let delivered = false; + const sent = []; + const rows = [{ name: "api", engine: "claude", state: "blocked", blockedOn: "menu", alive: true, cwd: "/src/api" }]; + let blockedAt = null; + const fetchImpl = async (url, init) => { + const u = new URL(url); + const body = init.body ? JSON.parse(init.body) : null; + calls.push({ path: u.pathname, method: init.method, body }); + const json = (data, status = 200) => ({ ok: status < 400, status, json: async () => data }); + if (u.pathname === "/api/herd/roster") { blockedAt = body.sessions[0].blockedAt; return json({ ok: true, watching: [] }); } + if (u.pathname === "/api/herd/commands" && init.method === "GET") { + if (!delivered && blockedAt != null) { + delivered = true; + return json({ commands: [{ id: "c1", session: "api", kind: "approve", args: { intent: "allow", blockedAt } }] }); + } + await new Promise((r) => setTimeout(r, 20)); + return json({ commands: [] }); + } + if (u.pathname === "/api/herd/commands/c1") { setTimeout(() => controller.abort(), 5); return json({ ok: true }); } + return json({}, 404); + }; + await runRelay({ + roster: () => rows, + fetchImpl, + api: "http://app.test", + key: "mck_test", + machine: { id: "11111111-1111-1111-1111-111111111111", name: "me@box" }, + signal: controller.signal, + write: () => {}, + read: () => fixture("claude-bash.txt"), + screenRead: () => "", + tickMs: 5, + streamTickMs: 5, + keysImpl: (name, keys) => { sent.push({ name, keys }); return { ok: true }; }, + }); + const publish = calls.find((c) => c.path === "/api/herd/roster"); + assert.equal(publish.body.machine.name, "me@box"); + assert.deepEqual(publish.body.sessions[0].approval, { allowAll: true }); + const result = calls.find((c) => c.path === "/api/herd/commands/c1"); + assert.deepEqual(result.body, { machine: "11111111-1111-1111-1111-111111111111", ok: true }); + assert.deepEqual(sent, [{ name: "api", keys: ["1"] }]); +}); + +test("runRelay stops on a revoked login instead of polling forever", async () => { + const fetchImpl = async (url) => ({ + ok: false, status: 401, json: async () => ({ error: "nope" }), + url, + }); + await assert.rejects(runRelay({ + roster: () => [], fetchImpl, api: "http://app.test", key: "mck_dead", + machine: { id: "22222222-2222-2222-2222-222222222222", name: "me@box" }, + write: () => {}, tickMs: 5, streamTickMs: 5, + }), /401/); +});