diff --git a/README.md b/README.md index 2ccbba1..facc576 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,12 @@ or miss one that does. A test fails the build when it drifts. |---|---|---| | `moshcode agents` | engines | list engines or launch one autonomously | | `moshcode start` | engines | launch an engine with its native defaults | +| `moshcode herd` | runtime | run agent sessions that outlive this terminal | +| `moshcode ps` | runtime | list herd sessions and what each one is doing | +| `moshcode attach` | runtime | attach this terminal to a herd session | +| `moshcode kill` | runtime | end a herd session | +| `moshcode wait` | runtime | block until a session is blocked, done, or idle | +| `moshcode restore` | runtime | rebuild the herd's sessions after a reboot | | `moshcode install` | engines | install an engine or workflow tool | | `moshcode uninstall`
`remove` | engines | take an engine or workflow tool off this machine | | `moshcode upgrade`
`update` | engines | update moshcode, engines, or tools | @@ -102,6 +108,103 @@ is shorthand for `moshcode start claude`. In the TUI, use `/agents ` for autonomous mode or `/start ` for raw mode. Running `moshcode agents` or `/agents` without an engine still lists engines and their install status. +## The herd — sessions that outlive your terminal + +Every launch above hands an engine the whole terminal and waits. That is why +they feel native, and it is also why the pit can only do one thing at a time and +why closing the lid kills the work. + +The herd inverts it. Add `-d` and the session runs in a runtime that outlives +the pit, so you get your prompt back immediately: + +```sh +moshcode start claude -d --name api # runs in the background, prompt returns +moshcode agents codex -d # autonomous, and still detached +moshcode ps # who is running, and who wants you +moshcode attach api # step in; Ctrl-b d steps back out +moshcode kill api # end it +``` + +Close the terminal, drop the SSH link, come back tomorrow — `moshcode ps` still +answers, and `moshcode attach` puts you back inside. In the pit the same verbs +are `/ps`, `/attach`, `/kill`, and the roster prints on the way in. + +### Which one needs you + +Every session carries a state: `working`, `blocked`, `done`, `idle`, or +`unknown`. `blocked` means a human decision is the only thing missing. + +``` + api claude blocked ~/src/coinpay 12m + web codex working ~/src/ugig.net 4m + audit opencode done ~/src/moshpit-dns 1h +``` + +State comes from one authority per session, never two. An engine that reports +through a lifecycle hook (`moshcode herd report `) is believed and +its screen is not second-guessed; everything else is classified from the bottom +of its screen. Nothing recognisable reads `unknown`, which is a safe answer — +detection never gates a launch. Patterns that go stale can be fixed in +`~/.moshcode/herd/rules.json` without waiting for a release. + +Blocked can also come and find you, using the same notification fan-out as +`notify()`/`ask()`: + +```sh +moshcode herd notify on --ask # email/SMS/Slack/Telegram/push +moshcode herd start claude --name watch # then run `moshcode herd watch` in the herd +``` + +With `--ask`, whatever you reply is typed into the session that was waiting. + +### Driving it from a script or another agent + +There is no second API — every verb takes `--json`, and that is what a machine +reads. `wait` exists to be branched on: exit `0` matched, `2` timed out, `3` no +such session. + +```sh +moshcode herd start claude --name api --json +moshcode herd prompt api "port the auth routes" --wait +moshcode herd read api --lines 40 +moshcode wait api --state blocked --timeout 1h +``` + +moshscript gets the same surface as values rather than exit codes, which is what +makes fan-out practical: + +```js +herdStart("claude", { name: "api" }); +herdStart("codex", { name: "web" }); +herdPrompt("api", "port the auth routes"); +herdPrompt("web", "port the dashboard"); +await herdWait("api"); await herdWait("web"); +say(herdRead("api", { lines: 20 })); +``` + +### After a reboot + +```sh +moshcode restore --dry-run # what would come back +moshcode restore --resume # and ask each engine to reopen its conversation +``` + +This brings back the *shape* — the sessions, in their directories, on their +engines. The processes are new. Work that was in flight is not still running, +and `--resume` only reaches engines that have a resume flag of their own. + +### What it runs on + +`tmux` when the box has it: real resizing, scrollback, native attach. Without +tmux, sessions run under `script(1)` with their input on a FIFO — they work and +they persist, but their size is fixed when they start. With neither, launches +stay in the foreground and say so once. moshcode does not turn a soft dependency +into a hard one, so `-d` never fails; at worst it degrades and tells you what +would fix it. + +The session manifest and every transcript are written `0600`: engine argv and +engine output both carry secrets. + ### Parallel pit tabs At the mosh prompt, `/new` opens and switches to another independent moshcode diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index 2c864ac..99b73db 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -28,6 +28,8 @@ import { createPrd, listPrds, authoringPrompt } from "../src/prd.mjs"; import { loginAuto, whoami, logout } from "../src/auth.mjs"; import { tui } from "../src/tui.mjs"; import { consoleCommand } from "../src/console.mjs"; +import { herdCommand, herdStart, splitDetachArgs } from "../src/herd-cli.mjs"; +import { detectSubstrate, substrateNote } from "../src/herd.mjs"; import { dnsCommand } from "../src/dns.mjs"; import { templateCommand } from "../src/templates.mjs"; import { serveCommand } from "../src/serve.mjs"; @@ -117,6 +119,23 @@ function printEngineStatus(json = false) { } async function launchEngine(key, engine, args, { agentMode = false } = {}) { + const { detach, name, rest: engineArgs } = splitDetachArgs(args); + if (detach) { + const substrate = detectSubstrate(); + if (substrate) { + const code = herdStart([ + key, ...(name ? ["--name", name] : []), ...(agentMode ? ["--agent"] : []), ...engineArgs, + ]); + if (code) process.exitCode = code; + if (!process.stdin.isTTY || process.env.MOSHCODE_NESTED === "1") return; + return tui(); + } + // R2: degrade, loudly, once — and then still do the thing that was asked + // for. A launch that refuses because the box has no tmux would be a worse + // answer than a launch that works and ends with this terminal. + console.error(`⚠ ${substrateNote(null)}`); + } + args = engineArgs; if (agentMode) { const note = `agent mode: ${key} ${agentLaunchArgs(engine).join(" ")}`; console.error(engine.agentsView @@ -314,6 +333,18 @@ async function main() { const [key, engine] = resolved; return launchEngine(key, engine, rest.slice(1)); } + // The herd (PRD 0009). `herd` is the namespace; the five verbs people reach + // for most often are also top-level, because `moshcode ps` is what someone + // types when they want to know what is running and nobody should have to + // learn a namespace to ask that. + if (cmd === "herd") { + process.exitCode = (await herdCommand(rest)) || 0; + return; + } + if (["ps", "attach", "kill", "wait", "restore"].includes(cmd)) { + process.exitCode = (await herdCommand([cmd === "ps" ? "ps" : cmd, ...rest])) || 0; + return; + } if (cmd === "tools") { const asJson = rest.includes("--json"); printStatus(toolStatus(), asJson); diff --git a/prd/0009-persistent-agent-runtime.md b/prd/0009-persistent-agent-runtime.md index 69dee45..b4052d4 100644 --- a/prd/0009-persistent-agent-runtime.md +++ b/prd/0009-persistent-agent-runtime.md @@ -2,14 +2,14 @@ openprd: "0.2" id: "0009" title: "Keep the herd alive — a persistent runtime, semantic agent state, and one control surface for humans and agents" -status: Draft +status: Accepted authors: - anthony@profullstack.com created: 2026-08-09 updated: 2026-08-09 repo: https://github.com/moshcoder/moshcode -discussion: -implementation: +discussion: https://github.com/moshcoder/moshcode/pull/341 +implementation: src/herd.mjs, src/herd-state.mjs, src/herd-cli.mjs tags: [runtime, sessions, agents, tui, notify] supersedes: superseded-by: @@ -203,17 +203,26 @@ entries in a new `runtime` group: | command | what it does | |---|---| -| `moshcode runtime` | start / inspect / stop the background runtime | -| `moshcode ps` | list live sessions with state | +| `moshcode herd` | the namespace: status, start, prompt, read, send-keys, report, notify, watch, prune, stop | +| `moshcode ps` | list sessions with state | | `moshcode attach ` | attach to a session | | `moshcode kill ` | end a session | | `moshcode wait ` | block until a state transition | | `moshcode restore` | rebuild sessions from the manifest | -| `moshcode agent ` | start / prompt / read / send-keys / stop | -TUI equivalents follow the existing convention: `/ps`, `/attach `, -`/kill `, `/restore`. `/agents ` keeps its meaning and simply -gains `--name` and a detachable session underneath it. +The namespace is `herd`, not the `runtime` / `agent ` this document first +proposed. Two reasons, both found while building it. `agent` is already a +registered alias of `agents` in `PIT_COMMANDS`, and a test pins +`suggest("agent") === "agents"` — so `moshcode agent start` would have meant two +different things depending on where it was typed. And `runtime` is what +`src/runtime.mjs` already calls the moshscript interpreter. The five verbs +people reach for most are top-level anyway, which is what the original table was +really asking for: nobody should have to learn a namespace to ask what is +running. + +TUI equivalents follow the existing convention: `/herd`, `/ps`, `/attach `, +`/kill `, `/wait`, `/restore`. `/agents ` and `/start ` +keep their meaning and simply gain `-d` / `--name`. **The pit's front door changes.** Today `moshcode` prints a banner and a prompt. With anything running it prints the herd first: @@ -301,3 +310,53 @@ exactly like today. No repeated nagging, no failure. - **Scope.** Phases 1–3 are independently shippable and should ship that way. Phase 1 alone — sessions that survive the terminal — is the bulk of the value and does not require a single line of state detection. + +## Implementation Notes + +Written after the build, so the document and the code agree. + +**A second substrate, which this PRD did not ask for.** R2 promised only to +degrade gracefully without tmux. That was not good enough: `/new` already +required tmux and it is the wart people notice. So there are two substrates +behind one interface — tmux when the box has it, and otherwise `script(1)` with +the session's stdin on a FIFO, reusing the capability detection `pty.mjs` +already does. The FIFO is opened `O_RDWR` before the spawn so the child is its +own writer and never sees EOF when the pit exits, which is the whole trick. Its +one real limit: nothing outside a pty can ioctl its master, so the size is fixed +at launch (set from inside by `stty`) and a later resize does not reach it. +`MOSHCODE_HERD=pty` forces it, which is how the fallback is tested on a box that +has tmux. + +**Two bugs the survival test caught**, both of which would have shipped as +"finished agents report `gone`". tmux's `remain-on-exit` was being set in a +second call, and a fast command finishes before that process starts — fixed by +making the session and its option one invocation using tmux's `;` argument. And +the pty substrate could not tell "the agent finished" from "the box rebooted", +since both are a dead pid — fixed by having the session's own shell record its +exit code on the way out. + +**Delivered:** R1–R12 and R14. Both substrates are covered by an integration +test that starts a session in one process, exits it, and talks to the session +from another. + +**Not delivered, deliberately:** + +- **R7 tier-1 hook installation.** The protocol ships and works — + `moshcode herd report ` takes authority, suppresses screen + classification entirely while it is live, and expires so a crashed agent + cannot read `working` forever. What is not built is auto-installing that call + into each engine's hook config via the `plugins.mjs` / `skills.mjs` fan-out. + Until then tier 1 is opt-in and tier 2 carries the roster. +- **R13, the browser as a real client.** `console.mjs` still points ttyd at a + shell rather than at `moshcode attach `, and `mirror.mjs` keeps its + documented blind spot. The runtime it would attach to now exists, so this is a + small follow-up rather than a design question. +- **R15, scrollback replay.** P2 and opt-in in this document; still the right + call not to write engine output across a reboot by default. + +**Rules will rot, and that is planned for.** The shipped patterns are +conservative and anchored to things a terminal draws — brackets, selectors, line +anchors — never bare English words, and a test asserts that. `unknown` is +common and safe. `~/.moshcode/herd/rules.json` lets a rotted pattern be fixed on +the box it rots on, and a malformed entry there loses that pattern rather than +the file. diff --git a/prd/README.md b/prd/README.md index a6f2c09..d610016 100644 --- a/prd/README.md +++ b/prd/README.md @@ -24,5 +24,5 @@ Start one with `moshcode prd ""` (TUI: `/prd`). | [0006](0006-help.md) | --help | Draft | | [0007](0007-profullstack-site-init.md) | Generate batteries-included Profullstack sites for Moshpit names | Draft | | [0008](0008-ticker-research-and-plugin-marketplace.md) | Bring equity research into the pit, and ship the pit's slash commands as a plugin | Draft | -| [0009](0009-persistent-agent-runtime.md) | Keep the herd alive — a persistent runtime, semantic agent state, and one control surface for humans and agents | Draft | +| [0009](0009-persistent-agent-runtime.md) | Keep the herd alive — a persistent runtime, semantic agent state, and one control surface for humans and agents | Accepted | diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 344439a..f26a201 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -20,6 +20,7 @@ */ export const COMMAND_GROUPS = [ { key: "engines", title: "engines" }, + { key: "runtime", title: "runtime" }, { key: "tools", title: "tools" }, { key: "extend", title: "extend" }, { key: "script", title: "script" }, @@ -53,9 +54,92 @@ export const CORE_CLI_COMMANDS = [ name: "start", group: "engines", description: "launch an engine with its native defaults", - synopsis: [["moshcode start [args…]", "no bypass flags, no agent view"]], - examples: [["moshcode start opencode", ""]], - seeAlso: ["agents", "engines"], + synopsis: [ + ["moshcode start [args…]", "no bypass flags, no agent view"], + ["moshcode start --detach", "run it in the herd and keep your prompt"], + ], + flags: [ + ["--detach, -d", "start in the herd instead of taking this terminal", ""], + ["--name ", "name the herd session (implies --detach)", "-"], + ], + examples: [ + ["moshcode start opencode", ""], + ["moshcode start claude --name api-refactor", "runs in the background; moshcode ps to see it"], + ], + seeAlso: ["agents", "engines", "herd", "ps"], + }, + { + name: "herd", + group: "runtime", + description: "run agent sessions that outlive this terminal", + synopsis: [ + ["moshcode herd", "the roster — same as moshcode ps"], + ["moshcode herd [args…]", "drive one session"], + ], + verbs: "HERD_VERBS", + flags: [["--json", "machine-readable, on every verb", ""]], + examples: [ + ["moshcode herd start claude --name api", "start one and get your prompt back"], + ["moshcode herd prompt api \"run the tests\" --wait", "hand it work and block until it lands"], + ["moshcode herd read api --lines 40", "read its screen without attaching"], + ], + seeAlso: ["ps", "attach", "wait", "restore", "start"], + note: "sessions live in a tmux server moshcode owns, or under script(1) when there is no tmux. with neither, launches stay in the foreground and say so.", + }, + { + name: "ps", + group: "runtime", + description: "list herd sessions and what each one is doing", + synopsis: [["moshcode ps [--json]", "name, engine, state, cwd, age"]], + flags: [["--json", "machine-readable", ""]], + examples: [["moshcode ps", "which agent is blocked?"]], + seeAlso: ["herd", "attach", "wait"], + note: "state is idle, working, blocked, done or unknown — unknown is a safe answer, not a failure.", + }, + { + name: "attach", + group: "runtime", + description: "attach this terminal to a herd session", + synopsis: [["moshcode attach ", "detach again with Ctrl-b d (or Ctrl-] without tmux)"]], + examples: [["moshcode attach api", ""]], + seeAlso: ["ps", "herd", "kill"], + note: "detaching leaves the session running. ending it is `moshcode kill`.", + }, + { + name: "kill", + group: "runtime", + description: "end a herd session", + synopsis: [["moshcode kill | --all", ""]], + flags: [["--all", "end every session in the herd", ""]], + examples: [["moshcode kill api", ""]], + seeAlso: ["ps", "herd", "attach"], + }, + { + name: "wait", + group: "runtime", + description: "block until a session is blocked, done, or idle", + synopsis: [["moshcode wait [--state blocked,done] [--timeout 30m]", ""]], + flags: [ + ["--state ", "states to wait for, comma-separated", "blocked,done"], + ["--timeout ", "give up after this long (30s, 10m, 2h)", "30m"], + ["--json", "machine-readable", ""], + ], + examples: [["moshcode wait api --state blocked --timeout 1h", "exit 0 matched · 2 timed out · 3 gone"]], + seeAlso: ["herd", "ps"], + note: "exit codes are the point: 0 matched, 2 timed out, 3 no such session.", + }, + { + name: "restore", + group: "runtime", + description: "rebuild the herd's sessions after a reboot", + synopsis: [["moshcode restore [--resume] [--dry-run]", ""]], + flags: [ + ["--resume", "ask each engine to reopen its own last conversation", ""], + ["--dry-run", "say what would come back, change nothing", ""], + ], + examples: [["moshcode restore --dry-run", ""]], + seeAlso: ["herd", "ps"], + note: "this brings back the shape — sessions, directories, engines. the processes are new; work that was in flight is not still running.", }, { name: "install", @@ -620,7 +704,70 @@ export const PLUGIN_VERBS = [ ]; /** Sub-verb tables, by the name a command's `verbs` field refers to. */ +export const HERD_VERBS = [ + { name: "ps", description: "the roster: every session and its state", + synopsis: [["moshcode herd ps [--json]", ""]], + flags: [["--json", "machine-readable", ""]] }, + { name: "status", description: "what the herd is running on, and how many sessions", + synopsis: [["moshcode herd status [--json]", ""]], + flags: [["--json", "machine-readable", ""]] }, + { name: "start", description: "start a session and hand the prompt back", + synopsis: [["moshcode herd start [--name ] [--agent] [args…]", ""]], + flags: [ + ["--name ", "session name", "-"], + ["--cwd ", "where to run it", "this directory"], + ["--agent", "autonomous mode — bypasses the engine's approvals", ""], + ["--json", "machine-readable", ""], + ] }, + { name: "attach", description: "put this terminal inside a session", + synopsis: [["moshcode herd attach ", ""]] }, + { name: "kill", description: "end a session", + synopsis: [["moshcode herd kill | --all", ""]], + flags: [["--all", "every session", ""]] }, + { name: "prune", description: "forget sessions the runtime no longer has", + synopsis: [["moshcode herd prune", "never ends anything that is running"]] }, + { name: "read", description: "read a session's screen without attaching", + synopsis: [["moshcode herd read [--lines N]", ""]], + flags: [["--lines ", "how much of the screen", "60"], ["--json", "machine-readable", ""]] }, + { name: "prompt", description: "type a prompt into a session", + synopsis: [['moshcode herd prompt "" [--wait]', ""]], + flags: [ + ["--wait", "block until the session stops working", ""], + ["--timeout ", "give up waiting after this long", "30m"], + ["--json", "machine-readable", ""], + ] }, + { name: "send-keys", description: "send raw keys (Enter, Escape, C-c, literal text)", + synopsis: [["moshcode herd send-keys ", ""]] }, + { name: "wait", description: "block until a session reaches a state", + synopsis: [["moshcode herd wait [--state blocked,done]", ""]], + flags: [ + ["--state ", "states to wait for", "blocked,done"], + ["--timeout ", "give up after this long", "30m"], + ["--json", "machine-readable", ""], + ] }, + { name: "restore", description: "rebuild remembered sessions after a reboot", + synopsis: [["moshcode herd restore [--resume] [--dry-run]", ""]], + flags: [["--resume", "reopen each engine's last conversation", ""], ["--dry-run", "change nothing", ""]] }, + { name: "report", description: "record an authoritative state (for engine hooks)", + synopsis: [["moshcode herd report [--ttl 15m]", ""]], + flags: [["--ttl ", "how long the report stays authoritative", "15m"]] }, + { name: "notify", description: "page the operator when a session blocks", + synopsis: [["moshcode herd notify [--state blocked,done] [--ask]", ""]], + flags: [ + ["--state ", "which transitions are worth a notification", "blocked"], + ["--ask", "wait for a reply and type it into the session", ""], + ["--no-ask", "notify only; never wait for a reply", ""], + ] }, + { 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", ""]] }, + { name: "stop", description: "stop the whole runtime and everything in it", + synopsis: [["moshcode herd stop --yes", ""]], + flags: [["--yes, -y", "required when sessions are running", ""]] }, +]; + export const VERB_TABLES = { + HERD_VERBS, MCP_VERBS, SKILL_VERBS, UPGRADE_TARGETS, @@ -651,6 +798,18 @@ export const PIT_COMMANDS = [ description: "list engines, or launch one autonomously" }, { name: "start", args: " [args…]", cli: "start", description: "raw launch; inject no engine arguments" }, + { name: "herd", args: "[verb] [args…]", cli: "herd", + description: "sessions that keep running when you leave" }, + { name: "ps", cli: "ps", + description: "what the herd is running, and which one wants you" }, + { name: "attach", args: "", cli: "attach", + description: "step into a herd session (detach leaves it running)" }, + { name: "kill", args: "", cli: "kill", + description: "end a herd session" }, + { name: "wait", args: " [--state …]", cli: "wait", + description: "block until a session is blocked or done" }, + { name: "restore", args: "[--resume]", cli: "restore", + description: "rebuild the herd's sessions after a reboot" }, { name: "tools", args: "[name] [args…]", cli: "tools", description: "list workflow tools, or run one" }, { name: "trade", args: " [args…]", cli: "trade", diff --git a/src/commands.mjs b/src/commands.mjs index 543bc49..41f59f6 100644 --- a/src/commands.mjs +++ b/src/commands.mjs @@ -18,6 +18,8 @@ import { spawn, spawnSync } from "node:child_process"; import { createRegistry } from "./registry.mjs"; import { cliVerb, aiVerb } from "./cli.mjs"; import { ingestApproval, pollApproval } from "./notify.mjs"; +import { capture, killSession, sendPrompt } from "./herd.mjs"; +import { herdStart, roster, waitFor } from "./herd-cli.mjs"; // The moshcoding pit-anthem playlist. mosh() blasts this URL and, on a desktop // with a GUI, tries to open it in the default browser. @@ -221,6 +223,103 @@ const COMMANDS = [ }, }, + // The herd (PRD 0009 R12). These are local rather than cliVerbs on purpose: + // a cliVerb returns { ok, code }, and the whole reason a script wants the + // herd is to fan work out and then read what came back. `herdRead()` has to + // hand back a string and `herdList()` an array, which shelling out cannot do. + // + // const a = herdStart("claude", { name: "api" }); + // const b = herdStart("codex", { name: "web" }); + // herdPrompt("api", "port the auth routes"); + // herdPrompt("web", "port the dashboard"); + // await herdWait("api"); await herdWait("web"); // both, in parallel + // say(herdRead("api", { lines: 20 })); + { + name: "herdStart", + summary: "start an agent session that outlives this script", + usage: 'herdStart(engine, { name, cwd, agent })', + detail: "returns { ok, name }; the session keeps running after the script ends", + run(ctx, engine, opts = {}) { + if (!engine) throw new Error("moshscript: herdStart(engine) requires an engine name"); + const argv = [String(engine), "--json"]; + if (opts.name) argv.push("--name", String(opts.name)); + if (opts.cwd) argv.push("--cwd", String(opts.cwd)); + if (opts.agent) argv.push("--agent"); + if (ctx.dryRun) { + ctx.out(` 🐑 herdStart(${engine}) → would run: moshcode herd start ${argv.join(" ")}`); + return { ok: true, name: opts.name || String(engine), dryRun: true }; + } + let captured = ""; + const code = herdStart(argv, { write: (s) => { captured += `${s}\n`; } }); + if (code !== 0) { ctx.out(` ✗ herdStart(${engine}) → ${captured.trim()}`); return { ok: false, name: null }; } + const name = JSON.parse(captured).name; + ctx.out(` 🐑 herdStart(${engine}) → ${name}`); + return { ok: true, name }; + }, + }, + { + name: "herdPrompt", + summary: "type a prompt into a herd session", + usage: "herdPrompt(name, text)", + detail: "returns { ok }; does not wait — use herdWait() to join", + run(ctx, name, ...words) { + const text = words.join(" "); + if (!name || !text) throw new Error("moshscript: herdPrompt(name, text) requires both"); + if (ctx.dryRun) { ctx.out(` 💬 herdPrompt(${name}) → would send: ${text}`); return { ok: true, dryRun: true }; } + ctx.out(` 💬 herdPrompt(${name}) → ${text.slice(0, 60)}${text.length > 60 ? "…" : ""}`); + const sent = sendPrompt(String(name), text); + return { ok: Boolean(sent.ok) }; + }, + }, + { + name: "herdWait", + summary: "BLOCK until a herd session is blocked, done, or idle", + usage: "herdWait(name, { states, timeout })", + detail: "returns the state it reached. needs await", + async run(ctx, name, opts = {}) { + if (!name) throw new Error("moshscript: herdWait(name) requires a session name"); + const states = opts.states || ["blocked", "done", "idle"]; + if (ctx.dryRun) { ctx.out(` ⏳ herdWait(${name}) → would wait for ${states.join("/")}`); return "idle"; } + ctx.out(` ⏳ herdWait(${name}) → waiting for ${states.join("/")}…`); + const result = await waitFor(String(name), states, opts.timeout ? { timeoutMs: Number(opts.timeout) } : {}); + ctx.out(` ${result.outcome === "matched" ? "✅" : "⌛"} ${name} is ${result.state}`); + return result.state; + }, + }, + { + name: "herdRead", + summary: "read a herd session's screen as a string", + usage: "herdRead(name, { lines })", + detail: "returns the last `lines` rows of its screen (default 60)", + run(ctx, name, opts = {}) { + if (!name) throw new Error("moshscript: herdRead(name) requires a session name"); + if (ctx.dryRun) { ctx.out(` 📖 herdRead(${name}) → would read its screen`); return ""; } + return capture(String(name), { lines: Number(opts.lines) || 60 }); + }, + }, + { + name: "herdList", + summary: "every herd session and its state", + usage: "herdList()", + detail: "returns [{ name, engine, state, cwd, alive }, …]", + run(ctx) { + if (ctx.dryRun) { ctx.out(" 🐑 herdList() → would list the herd"); return []; } + return roster().map(({ name, engine, state, cwd, alive }) => ({ name, engine, state, cwd, alive })); + }, + }, + { + name: "herdKill", + summary: "end a herd session", + usage: "herdKill(name)", + detail: "returns { ok }", + run(ctx, name) { + if (!name) throw new Error("moshscript: herdKill(name) requires a session name"); + if (ctx.dryRun) { ctx.out(` ⏹ herdKill(${name}) → would end it`); return { ok: true, dryRun: true }; } + ctx.out(` ⏹ herdKill(${name})`); + return { ok: Boolean(killSession(String(name)).ok) }; + }, + }, + // CLI verbs — each is `moshcode ...args`. This is the whole point: // scripting the CLI. Add a capability by adding a line here. // @@ -231,6 +330,8 @@ const COMMANDS = [ // shortcut: ai() runs an engine headlessly and RETURNS its output (see PRD R17) aiVerb, cliVerb("agents", "launch an autonomous agent session (moshcode agents )"), + cliVerb("herd", "drive the herd (moshcode herd ) — see herdStart/herdWait for values"), + cliVerb("ps", "print the herd roster"), cliVerb("start", "raw-launch an engine (moshcode start )"), cliVerb("install", "install an engine or workflow tool"), cliVerb("upgrade", "upgrade moshcode, engines, and tools"), diff --git a/src/engines.mjs b/src/engines.mjs index 437e30e..8b10bf2 100644 --- a/src/engines.mjs +++ b/src/engines.mjs @@ -10,6 +10,19 @@ // `agentArgs` — an autonomous session with native approvals // bypassed/auto-approved. Do not use a machine-readable, one-shot list command // as an agents view: `/agents` promises to hand the terminal to a live session. +// +// `state` (optional) is how the herd reads this engine's screen when it has no +// authoritative hook to go on (PRD 0009 R7). It lives here, next to the install +// spec, so a new engine ships its detection rules with itself rather than in a +// table somewhere else that nobody remembers to update. Shared patterns — bare +// y/n prompts, "esc to interrupt" — are in src/herd-state.mjs and do not need +// repeating; only put a pattern here when it is this engine's own wording. +// Every pattern is matched against the bottom of the screen with ANSI stripped. +// +// `resume` (optional) is the argv that reopens this engine's last conversation, +// used by `moshcode restore --resume` after a reboot. Omit it rather than guess: +// a session that starts fresh is a small disappointment, and one that starts +// with a flag the engine does not have is a crash. import { spawn } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; @@ -24,6 +37,10 @@ export const ENGINES = { agentArgs: ["--auto"], install: { cmd: "bash", args: ["-c", "curl -fsSL https://opencode.ai/install | bash"] }, upgrade: { cmd: "opencode", args: ["upgrade"] }, + resume: ["--continue"], + state: { + blocked: [/\bpermission (?:request|required)\b/i, /\ballow this (?:command|tool)\b/i], + }, // The installer appends this directory to a shell profile. The moshcode // process that ran it cannot see that PATH change, so search it directly. binDirs: [path.join(homedir(), ".opencode", "bin")], @@ -35,6 +52,11 @@ export const ENGINES = { agentArgs: ["--auto"], install: { cmd: "sh", args: ["-c", "curl -fsSL https://getprivacycode.com/install | sh"] }, binDirs: [path.join(homedir(), ".privacycode", "bin")], + // Same lineage, so the same screen wording and the same resume flag. + resume: ["--continue"], + state: { + blocked: [/\bpermission (?:request|required)\b/i, /\ballow this (?:command|tool)\b/i], + }, // Deliberately no native updater. `privacycode upgrade` is opencode's, and // it works out how to update itself by recognising where it was installed — // it knows opencode's own locations, not this fork's ~/.privacycode/bin. It @@ -59,18 +81,37 @@ export const ENGINES = { "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_SESSION_ID", "CLAUDE_CODE_CHILD_SESSION", ], + resume: ["--continue"], + state: { + // The permission dialog's own heading, and the selector on its first + // option — the generic numbered-menu pattern would catch the second only + // if the cursor happened to be resting there. + blocked: [/\bdo you want to (?:proceed|make this edit|create)\b/i, /^\s*❯\s*1\.\s*yes/im], + // Claude Code parks "? for shortcuts" under the composer when it is + // waiting on you and nothing else, which is as close to an explicit + // "idle" as it publishes. + idle: [/\?\s+for shortcuts/i], + }, }, codex: { desc: "Codex — OpenAI's coding CLI", bin: "codex", agentArgs: ["--dangerously-bypass-approvals-and-sandbox"], install: { cmd: "npm", args: ["install", "-g", "@openai/codex"] }, + resume: ["resume", "--last"], + state: { + blocked: [/\ballow (?:this )?command\b/i, /\bapprove this (?:command|edit|change)\b/i], + working: [/\besc to interrupt\b/i, /^\s*working\b/im], + }, }, gemini: { desc: "Gemini CLI — Google's agentic CLI", bin: "gemini", agentArgs: ["--approval-mode=yolo"], install: { cmd: "npm", args: ["install", "-g", "@google/gemini-cli"] }, + state: { + blocked: [/\bapply this change\?/i, /\ballow execution\b/i], + }, }, kimi: { desc: "Kimi Code — Moonshot AI's agentic CLI", @@ -131,6 +172,11 @@ export const ENGINES = { agentArgs: ["--yes-always"], install: { cmd: "bash", args: ["-c", "curl -LsSf https://aider.chat/install.sh | sh"] }, upgrade: { cmd: "aider", args: ["--upgrade"] }, + state: { + // aider asks in prose and answers in (Y)es/(N)o, which the shared y/n + // pattern misses because of the parentheses around the letters. + blocked: [/\((?:Y\)es|N\)o)/, /\badd .* to the chat\?/i], + }, }, }; diff --git a/src/herd-cli.mjs b/src/herd-cli.mjs new file mode 100644 index 0000000..006774a --- /dev/null +++ b/src/herd-cli.mjs @@ -0,0 +1,665 @@ +// `moshcode herd` — the command surface over src/herd.mjs (PRD 0009 R5, R10–R12). +// +// There is no second API. herdr's framing is that "the cli and socket api are +// the same surface agents drive"; moshcode's version of that is simpler, +// because there is only ever one surface — every verb here takes `--json`, and +// that is what a machine reads. A moshscript verb, a Claude Code session +// spawning a helper, and a person typing at the pit all go through this file. +import fs from "node:fs"; +import path from "node:path"; + +import { + attachSession, capture, defaultName, detectSubstrate, forgetSession, HERD_SOCKET, + herdDir, killSession, listSessions, readManifest, rememberSession, sendKeys, sendPrompt, + startSession, stopRuntime, substrateNote, validName, NAME_RE, +} from "./herd.mjs"; +import { clearReport, reportState, STATES, withState } from "./herd-state.mjs"; +import { ENGINES, resolveEngine, resolveExecutable, agentLaunchArgs } from "./engines.mjs"; +import { ingestApproval, pollApproval } from "./notify.mjs"; +import { acid, amber, ash, bone, danger, dim, err, info, ok, warn } from "./ui.mjs"; + +/** Distinct exit codes, because `wait` exists to be branched on (R10). */ +export const EXIT = { matched: 0, usage: 1, timeout: 2, gone: 3 }; + +const configFile = () => path.join(herdDir(), "config.json"); + +export function readConfig() { + try { + const raw = JSON.parse(fs.readFileSync(configFile(), "utf8")); + return { notify: { enabled: false, states: ["blocked"], ask: false, ...(raw?.notify || {}) } }; + } catch { + return { notify: { enabled: false, states: ["blocked"], ask: false } }; + } +} + +export function writeConfig(config) { + try { + fs.mkdirSync(herdDir(), { recursive: true, mode: 0o700 }); + fs.writeFileSync(configFile(), JSON.stringify(config, null, 2), { mode: 0o600 }); + return true; + } catch { return false; } +} + +/** "4m", "1h12m", "3d" — a column, not a sentence. */ +export function humanAge(ms) { + if (!Number.isFinite(ms) || ms < 0) return ""; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h${m % 60 ? `${m % 60}m` : ""}`; + return `${Math.floor(h / 24)}d`; +} + +const tilde = (p) => { + const home = process.env.HOME || ""; + return home && p.startsWith(home) ? `~${p.slice(home.length)}` : p; +}; + +/** + * The state column, coloured. + * + * `blocked` is the only one that gets a warning colour, because it is the only + * one that is asking for something. A roster where four things are shouting is + * a roster nobody reads. + */ +export function paintState(state) { + if (state === "blocked") return amber("blocked"); + if (state === "working") return acid("working"); + if (state === "done") return bone("done"); + if (state === "gone") return danger("gone"); + return ash(state); +} + +/** + * The roster. Shared by `moshcode ps`, `/ps`, and the pit's own front door, so + * they cannot drift into three different answers to the same question. + */ +export function renderRoster(rows, { indent = " " } = {}) { + if (!rows.length) return ""; + const w = (key, min) => Math.max(min, ...rows.map((r) => String(r[key] ?? "").length)); + const nameW = w("name", 4); + const engineW = w("engine", 6); + return rows.map((r) => [ + indent, + bone(r.name.padEnd(nameW)), + " ", + ash(String(r.engine).padEnd(engineW)), + " ", + paintState(r.state).padEnd(9 + (paintState(r.state).length - r.state.length)), + " ", + ash(tilde(r.cwd || "").padEnd(24)), + " ", + dim(humanAge(r.age)), + ].join("")).join("\n"); +} + +/** Every session, with state attached. The one place that assembles both. */ +export function roster(options = {}) { + return withState(listSessions(options), options); +} + +// --------------------------------------------------------------------------- +// Verbs +// --------------------------------------------------------------------------- + +function requireSubstrate(write) { + const substrate = detectSubstrate(); + if (substrate) return substrate; + write(err("the herd needs somewhere to run.")); + write(info(substrateNote(null))); + return null; +} + +function findSession(name, options) { + return roster(options).find((s) => s.name === name) || null; +} + +/** + * Start a session and hand the prompt straight back. + * + * The absolute path matters: the runtime's environment is whatever created the + * server, which may predate an engine installer appending its bin directory to + * a shell profile. resolveExecutable already knows every engine's extra + * directories, so resolving here means `herd start opencode` works in the same + * session that installed opencode — the exact case that bit the foreground + * path first. + */ +export function herdStart(argv, { write = console.log } = {}) { + const substrate = requireSubstrate(write); + if (!substrate) return EXIT.usage; + + const flags = { name: null, cwd: process.cwd(), agent: false, json: false }; + const rest = []; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--name") flags.name = argv[++i]; + else if (a.startsWith("--name=")) flags.name = a.slice(7); + else if (a === "--cwd") flags.cwd = path.resolve(argv[++i] || "."); + else if (a === "--agent") flags.agent = true; + else if (a === "--json") flags.json = true; + else rest.push(a); + } + + const target = rest.shift(); + const resolved = target && resolveEngine(target); + if (!resolved) { + write(err(`usage: moshcode herd start [--name ] [--agent] [args…]`)); + write(info(`engines: ${Object.keys(ENGINES).join(", ")}`)); + return EXIT.usage; + } + const [key, engine] = resolved; + + const taken = listSessions().map((s) => s.name); + const name = flags.name || defaultName(key, flags.cwd, taken); + if (!validName(name)) { + write(err(`invalid name ${JSON.stringify(name)} — must match ${NAME_RE}`)); + return EXIT.usage; + } + + const bin = resolveExecutable(engine.bin, engine.binDirs || []) || engine.bin; + const args = flags.agent ? agentLaunchArgs(engine, rest) : rest; + const started = startSession({ + name, engine: key, bin, args, stripEnv: engine.stripEnv || [], cwd: flags.cwd, substrate, + }); + + if (!started.ok) { + write(err(String(started.error?.message || started.error))); + return EXIT.usage; + } + rememberSession(name, { agent: flags.agent }); + + if (flags.json) { + write(JSON.stringify({ name, engine: key, cwd: flags.cwd, substrate, agent: flags.agent }, null, 2)); + return EXIT.matched; + } + write(ok(`${bone(name)} — ${key} running in the herd. the prompt is yours.`)); + if (flags.agent) write(warn("agent mode: native approvals are bypassed or auto-approved.")); + write(info(`attach: ${acid(`moshcode attach ${name}`)} · roster: ${acid("moshcode ps")}`)); + const note = substrateNote(substrate); + if (note) write(info(note)); + return EXIT.matched; +} + +/** + * Pull the herd flags out of an engine launch (PRD 0009 R3). + * + * Opt-in, never the default: `moshcode start claude` and `/start claude` have + * to keep feeling exactly as they do today, or this is a regression wearing a + * roster. `--name` implies `--detach`, because naming a session you were about + * to sit inside is a request for one you can come back to. + * + * Shared by the CLI and the pit so the two cannot drift on what `-d` means. + */ +export function splitDetachArgs(args = []) { + const rest = []; + let detach = false, name = null; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "--detach" || a === "-d") detach = true; + else if (a === "--name") { name = args[++i]; detach = true; } + else if (a.startsWith("--name=")) { name = a.slice(7); detach = true; } + else rest.push(a); + } + return { detach, name, rest }; +} + +export function herdPs(argv, { write = console.log } = {}) { + const rows = roster(); + if (argv.includes("--json")) { + write(JSON.stringify(rows.map(({ name, engine, state, authority, cwd, age, alive, attached, substrate }) => ({ + name, engine, state, authority, cwd, ageMs: age, alive, attached, substrate, + })), null, 2)); + return EXIT.matched; + } + if (!rows.length) { + write(info("the herd is empty — `moshcode herd start claude` puts something in it.")); + const note = substrateNote(); + if (note) write(info(note)); + return EXIT.matched; + } + write(renderRoster(rows)); + const blocked = rows.filter((r) => r.state === "blocked"); + if (blocked.length) { + write(""); + write(warn(`${blocked.length} waiting on you — ${acid(`moshcode attach ${blocked[0].name}`)}`)); + } + return EXIT.matched; +} + +export async function herdAttach(argv, { write = console.log } = {}) { + const name = argv.find((a) => !a.startsWith("-")); + if (!name) { write(err("usage: moshcode attach ")); return EXIT.usage; } + const session = findSession(name); + if (!session) { write(err(`no session named ${JSON.stringify(name)} — ${acid("moshcode ps")}`)); return EXIT.gone; } + if (!session.alive) { + write(err(`${name} is not running — ${acid("moshcode restore")} rebuilds it.`)); + return EXIT.gone; + } + // A finished session has nothing to type into. Show what it ended on rather + // than dropping someone into a terminal that will not answer. + if (session.exited) { + write(info(`${bone(name)} has finished — this is where it stopped:`)); + write(capture(name, { lines: 40 })); + write(info(`${acid(`moshcode restore`)} to start it again · ${acid(`moshcode kill ${name}`)} to drop it`)); + return EXIT.matched; + } + + // Say how to get out before taking the terminal. The single worst outcome of + // this whole feature is someone quitting a session they meant to leave + // running, and the only defence is telling them the key first. + const substrate = detectSubstrate(); + write(info(substrate === "tmux" ? "detach with Ctrl-b d — the session keeps running." : "detach with Ctrl-] — the session keeps running.")); + + const result = await attachSession(name, { substrate }); + if (!result.ok) { write(err(String(result.error?.message || result.error))); return EXIT.usage; } + + const after = findSession(name); + if (after?.alive) write(info(`detached — ${bone(name)} still ${after.state}. ${acid(`moshcode attach ${name}`)} to come back.`)); + else write(info(`${bone(name)} ended.`)); + return EXIT.matched; +} + +export function herdKill(argv, { write = console.log } = {}) { + const all = argv.includes("--all"); + const names = all ? roster().map((s) => s.name) : argv.filter((a) => !a.startsWith("-")); + if (!names.length) { write(err("usage: moshcode kill | --all")); return EXIT.usage; } + let failed = 0; + for (const name of names) { + const result = killSession(name); + clearReport(name); + if (result.ok) write(ok(`${name} ended.`)); + else { write(err(`${name}: ${result.error?.message || "no such session"}`)); failed++; } + } + return failed && failed === names.length ? EXIT.gone : EXIT.matched; +} + +/** + * Drop sessions the runtime no longer has. Only ever removes bookkeeping — a + * `prune` that could end running work would be a `kill` with a friendlier name. + */ +export function herdPrune(argv, { write = console.log } = {}) { + const gone = roster().filter((s) => !s.alive); + for (const s of gone) { forgetSession(s.name); clearReport(s.name); } + write(gone.length ? ok(`forgot ${gone.length} session(s) the runtime no longer has.`) : info("nothing to prune.")); + return EXIT.matched; +} + +export function herdRead(argv, { write = console.log } = {}) { + const positional = []; + let lines = 60, json = false; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--lines") lines = Number(argv[++i]) || 60; + else if (a.startsWith("--lines=")) lines = Number(a.slice(8)) || 60; + else if (a === "--json") json = true; + else if (!a.startsWith("-")) positional.push(a); + } + const name = positional[0]; + if (!name) { write(err("usage: moshcode herd read [--lines N]")); return EXIT.usage; } + const session = findSession(name); + if (!session?.alive) { write(err(`no live session named ${JSON.stringify(name)}`)); return EXIT.gone; } + const screen = capture(name, { lines }); + write(json ? JSON.stringify({ name, state: session.state, screen }, null, 2) : screen); + return EXIT.matched; +} + +/** + * Deliberately NOT unref'd. + * + * Everywhere else in moshcode a timer is unref'd so a background nicety — the + * mirror, a follow — can never hold the process open. Here that instinct is + * exactly backwards: `wait` and `watch` exist to keep the process alive, and an + * unref'd timer means node finds nothing pending between polls and exits. It + * does not hang; it is worse than that. `moshcode wait api --timeout 1h` + * returns in a millisecond, exit 0, having waited for nothing. + */ +const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); }); + +/** + * Block until a session reaches one of `states`, or the timeout runs out. + * + * Polling, not an event stream, and deliberately so: neither substrate can push + * a state change, and a one-second poll against a `capture-pane` is cheaper + * than the machinery that would be needed to pretend otherwise. What matters is + * that the *caller* stops polling and gets to just wait. + */ +export async function waitFor(name, states, { + timeoutMs = 30 * 60 * 1000, + intervalMs = 1000, + now = () => Date.now(), + look = (n) => findSession(n), +} = {}) { + const wanted = new Set(states); + const deadline = now() + timeoutMs; + for (;;) { + const session = look(name); + if (!session) return { outcome: "gone", state: "gone" }; + if (wanted.has(session.state)) return { outcome: "matched", state: session.state }; + // A session that ended can never reach `blocked`; waiting the full timeout + // for something impossible is a hang, not a wait. + if (!session.alive || session.state === "done") { + return wanted.has("done") && session.state === "done" + ? { outcome: "matched", state: session.state } + : { outcome: "ended", state: session.state }; + } + if (now() >= deadline) return { outcome: "timeout", state: session.state }; + await sleep(intervalMs); + } +} + +function parseDuration(raw, fallback) { + const m = /^(\d+)(ms|s|m|h)?$/.exec(String(raw || "").trim()); + if (!m) return fallback; + const n = Number(m[1]); + return { ms: n, s: n * 1000, m: n * 60000, h: n * 3600000 }[m[2] || "s"]; +} + +export async function herdWait(argv, { write = console.log } = {}) { + const positional = []; + let states = ["blocked", "done"], timeoutMs = 30 * 60 * 1000, json = false; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--state") states = String(argv[++i] || "").split(",").filter(Boolean); + else if (a.startsWith("--state=")) states = a.slice(8).split(",").filter(Boolean); + else if (a === "--timeout") timeoutMs = parseDuration(argv[++i], timeoutMs); + else if (a.startsWith("--timeout=")) timeoutMs = parseDuration(a.slice(10), timeoutMs); + else if (a === "--json") json = true; + else if (!a.startsWith("-")) positional.push(a); + } + const name = positional[0]; + if (!name) { write(err("usage: moshcode wait [--state blocked,done] [--timeout 30m]")); return EXIT.usage; } + const unknown = states.filter((s) => !STATES.includes(s)); + if (unknown.length) { write(err(`unknown state ${unknown[0]} — one of ${STATES.join(", ")}`)); return EXIT.usage; } + + const result = await waitFor(name, states, { timeoutMs }); + if (json) write(JSON.stringify({ name, ...result }, null, 2)); + else if (result.outcome === "matched") write(ok(`${name} is ${result.state}.`)); + else if (result.outcome === "timeout") write(warn(`${name} is still ${result.state} after the timeout.`)); + else if (result.outcome === "gone") write(err(`no session named ${JSON.stringify(name)}`)); + else write(info(`${name} ended (${result.state}) without reaching ${states.join("/")}.`)); + + if (result.outcome === "matched") return EXIT.matched; + if (result.outcome === "timeout") return EXIT.timeout; + return EXIT.gone; +} + +/** + * Type a prompt into a running session, optionally waiting for it to land. + * + * `--wait` is the composite that makes agent-to-agent work practical: submit, + * then block until the session stops working. The grace period before that is + * not decoration — an engine takes a moment to notice input, and without it the + * wait would see the still-idle screen and return instantly, reporting success + * before the agent had read a word. + */ +export async function herdPrompt(argv, { write = console.log } = {}) { + const positional = []; + let wait = false, timeoutMs = 30 * 60 * 1000, json = false; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--wait") wait = true; + else if (a === "--timeout") timeoutMs = parseDuration(argv[++i], timeoutMs); + else if (a.startsWith("--timeout=")) timeoutMs = parseDuration(a.slice(10), timeoutMs); + else if (a === "--json") json = true; + else positional.push(a); + } + const [name, ...words] = positional; + const text = words.join(" "); + if (!name || !text) { write(err('usage: moshcode herd prompt "" [--wait]')); return EXIT.usage; } + const session = findSession(name); + if (!session?.alive) { write(err(`no live session named ${JSON.stringify(name)}`)); return EXIT.gone; } + + const sent = sendPrompt(name, text); + if (!sent.ok) { write(err(String(sent.error?.message || sent.error))); return EXIT.usage; } + if (!wait) { + if (json) write(JSON.stringify({ name, sent: true }, null, 2)); + else write(ok(`sent to ${bone(name)}.`)); + return EXIT.matched; + } + + await waitFor(name, ["working"], { timeoutMs: 8000, intervalMs: 500 }); + const result = await waitFor(name, ["blocked", "done", "idle"], { timeoutMs }); + if (json) write(JSON.stringify({ name, sent: true, ...result }, null, 2)); + else if (result.outcome === "matched") write(ok(`${name} is ${result.state}.`)); + else write(warn(`${name}: ${result.outcome} (${result.state})`)); + return result.outcome === "matched" ? EXIT.matched : result.outcome === "timeout" ? EXIT.timeout : EXIT.gone; +} + +export function herdSendKeys(argv, { write = console.log } = {}) { + const positional = argv.filter((a) => a !== "--json"); + const [name, ...keys] = positional; + if (!name || !keys.length) { write(err("usage: moshcode herd send-keys ")); return EXIT.usage; } + const session = findSession(name); + if (!session?.alive) { write(err(`no live session named ${JSON.stringify(name)}`)); return EXIT.gone; } + const sent = sendKeys(name, keys); + if (!sent.ok) { write(err(String(sent.error?.message || sent.error))); return EXIT.usage; } + write(ok(`sent ${keys.join(" ")} to ${name}.`)); + return EXIT.matched; +} + +export function herdReport(argv, { write = console.log } = {}) { + const positional = []; + let ttl; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--ttl") ttl = parseDuration(argv[++i]); + else if (a.startsWith("--ttl=")) ttl = parseDuration(a.slice(6)); + else if (!a.startsWith("-")) positional.push(a); + } + const [name, state] = positional; + if (!name || !state) { + write(err(`usage: moshcode herd report <${STATES.join("|")}> [--ttl 15m]`)); + return EXIT.usage; + } + const result = reportState(name, state, ttl ? { ttl } : {}); + if (!result.ok) { write(err(String(result.error?.message || result.error))); return EXIT.usage; } + write(ok(`${name} → ${state} (authoritative)`)); + return EXIT.matched; +} + +export function herdStatus(argv, { write = console.log } = {}) { + const substrate = detectSubstrate(); + const rows = roster(); + const model = { + substrate, + socket: substrate === "tmux" ? HERD_SOCKET : null, + dir: herdDir(), + sessions: rows.length, + live: rows.filter((r) => r.alive).length, + blocked: rows.filter((r) => r.state === "blocked").length, + notify: readConfig().notify, + }; + if (argv.includes("--json")) { write(JSON.stringify(model, null, 2)); return EXIT.matched; } + write(`${bone("substrate")} ${substrate || danger("none")}${substrate === "tmux" ? ash(` (socket ${HERD_SOCKET})`) : ""}`); + write(`${bone("sessions")} ${model.live} live${model.sessions - model.live ? ash(`, ${model.sessions - model.live} remembered`) : ""}`); + write(`${bone("notify")} ${model.notify.enabled ? acid(`on → ${model.notify.states.join(",")}`) : ash("off")}`); + const note = substrateNote(substrate); + if (note) write(info(note)); + return EXIT.matched; +} + +export function herdNotify(argv, { write = console.log } = {}) { + const verb = argv.find((a) => !a.startsWith("-")); + const config = readConfig(); + if (!verb || verb === "status") { + write(config.notify.enabled + ? ok(`notifications on for ${config.notify.states.join(", ")}${config.notify.ask ? " (replies typed back into the session)" : ""}`) + : info("notifications off — `moshcode herd notify on`")); + return EXIT.matched; + } + if (verb === "on" || verb === "off") { + config.notify.enabled = verb === "on"; + if (argv.includes("--ask")) config.notify.ask = true; + if (argv.includes("--no-ask")) config.notify.ask = false; + const at = argv.indexOf("--state"); + if (at >= 0 && argv[at + 1]) config.notify.states = argv[at + 1].split(",").filter((s) => STATES.includes(s)); + writeConfig(config); + write(verb === "on" + ? ok(`notifications on for ${config.notify.states.join(", ")} — run ${acid("moshcode herd watch")} in the herd to deliver them.`) + : ok("notifications off.")); + return EXIT.matched; + } + write(err("usage: moshcode herd notify [--state blocked,done] [--ask]")); + return EXIT.usage; +} + +/** + * The watcher: the piece that turns a state change into a phone buzzing. + * + * It runs *inside* the herd (`moshcode herd watch` started as its own session), + * which is the only placement that makes sense — a watcher in the pit would + * stop watching the moment you closed the pit, which is precisely when you + * needed it. This is also the part herdr structurally cannot do: it can colour + * a pane, and moshcode can reach the human who is not looking at one. + */ +/** + * Is this state change worth a human's attention? + * + * Only a *transition into* a watched state. Three things this rules out, each + * of which would kill the feature on its own: + * - a session that sits blocked for an hour paging every five seconds; + * - the first sighting of an already-blocked session, which is history, not + * news — the watcher has just started and everything looks new; + * - any transition *out of* a watched state, which is the good news nobody + * needs a text about. + */ +export function shouldNotify(previous, current, interesting) { + if (previous === undefined) return false; + if (previous === current) return false; + return interesting.has(current); +} + +export async function herdWatch(argv, { write = console.log, once = false } = {}) { + const intervalMs = (() => { + const at = argv.indexOf("--interval"); + return at >= 0 ? parseDuration(argv[at + 1], 5000) : 5000; + })(); + const config = readConfig(); + if (!config.notify.enabled && !argv.includes("--force")) { + write(info("notifications are off — `moshcode herd notify on` first (or --force to watch anyway).")); + return EXIT.usage; + } + const interesting = new Set(config.notify.states); + write(ok(`watching the herd every ${Math.round(intervalMs / 1000)}s for ${[...interesting].join(", ")} 🤘`)); + + const seen = new Map(); + for (;;) { + // One roster per tick, not one per session: this loop runs forever, and + // re-reading the herd inside the cleanup pass made a watcher on six + // sessions shell out dozens of times every five seconds, all night. + const current = roster(); + for (const session of current) { + const previous = seen.get(session.name); + seen.set(session.name, session.state); + if (!shouldNotify(previous, session.state, interesting)) continue; + await deliver(session, config, write); + } + const present = new Set(current.map((s) => s.name)); + for (const name of [...seen.keys()]) if (!present.has(name)) seen.delete(name); + if (once) return EXIT.matched; + await sleep(intervalMs); + } +} + +async function deliver(session, config, write) { + const tail = capture(session.name, { lines: 30 }).split("\n").slice(-12).join("\n"); + const message = `${session.name} (${session.engine}) is ${session.state} in ${tilde(session.cwd)}\n\n${tail}`; + if (!config.notify.ask) { + const r = await ingestApproval({ message, kind: "notify", script: "herd", session: session.name }); + write(r.ok ? info(`notified: ${session.name} → ${session.state}`) : warn(`notify failed (${r.error || r.status}) — run \`moshcode login\``)); + return; + } + const r = await ingestApproval({ message, kind: "ask", script: "herd", session: session.name }); + if (!r.ok) { write(warn(`ask failed (${r.error || r.status}) — run \`moshcode login\``)); return; } + write(info(`asked: ${r.url}`)); + const reply = await pollApproval(r.id); + if (reply == null) { write(info(`no reply for ${session.name} — leaving it be`)); return; } + const sent = sendPrompt(session.name, reply); + write(sent.ok ? ok(`answered ${session.name}: ${reply}`) : warn(`could not type the reply into ${session.name}`)); +} + +/** + * Rebuild the herd from the manifest. + * + * What comes back is the *shape* — the sessions, in their directories, on their + * engines. The processes are gone and no amount of bookkeeping brings them + * back, so the wording here never says "restored your work". `--resume` is the + * separate, explicit act of asking each engine to reopen its own conversation, + * and only the engines that actually have a resume flag get one. + */ +export function herdRestore(argv, { write = console.log } = {}) { + const substrate = requireSubstrate(write); + if (!substrate) return EXIT.usage; + const resume = argv.includes("--resume"); + const dryRun = argv.includes("--dry-run"); + + const manifest = readManifest(); + // Only a session that is actually *running* is one there is nothing to do + // about. A finished one is a fair thing to bring back — it is on the roster + // reading `done`, and restoring it is how you pick the work back up. + const live = new Set(listSessions().filter((s) => s.alive && !s.exited).map((s) => s.name)); + const candidates = Object.entries(manifest.sessions).filter(([name]) => !live.has(name)); + if (!candidates.length) { write(info("nothing to restore — everything remembered is already running.")); return EXIT.matched; } + + let restored = 0; + for (const [name, meta] of candidates) { + const engine = ENGINES[meta.engine]; + if (!engine) { write(warn(`${name}: unknown engine ${meta.engine} — skipped`)); continue; } + if (!fs.existsSync(meta.cwd || "")) { write(warn(`${name}: ${tilde(meta.cwd || "")} is gone — skipped`)); continue; } + + const resumeArgs = resume ? engine.resume || null : null; + if (resume && !resumeArgs) write(info(`${name}: ${meta.engine} has no resume flag — starting fresh`)); + const args = resumeArgs || meta.args || []; + if (dryRun) { write(info(`would restore ${bone(name)} — ${meta.engine} in ${tilde(meta.cwd)}${resumeArgs ? " (resumed)" : ""}`)); restored++; continue; } + + const bin = resolveExecutable(engine.bin, engine.binDirs || []) || engine.bin; + const started = startSession({ name, engine: meta.engine, bin, args, stripEnv: engine.stripEnv || [], cwd: meta.cwd, substrate }); + if (!started.ok) { write(err(`${name}: ${started.error?.message || started.error}`)); continue; } + clearReport(name); + write(ok(`${bone(name)} — ${meta.engine} in ${tilde(meta.cwd)}${resumeArgs ? ash(" (asked to resume)") : ""}`)); + restored++; + } + if (restored && !dryRun) { + write(""); + write(info("the shape is back; the processes are new. anything that was mid-task is not still running it.")); + } + return EXIT.matched; +} + +export function herdStop(argv, { write = console.log } = {}) { + const rows = roster().filter((s) => s.alive); + if (rows.length && !argv.includes("--yes") && !argv.includes("-y")) { + write(err(`this ends ${rows.length} running session(s). re-run with --yes.`)); + write(renderRoster(rows)); + return EXIT.usage; + } + stopRuntime(); + write(ok("the herd is stopped.")); + return EXIT.matched; +} + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +const VERBS = { + ps: herdPs, list: herdPs, status: herdStatus, + start: herdStart, attach: herdAttach, kill: herdKill, prune: herdPrune, + read: herdRead, prompt: herdPrompt, "send-keys": herdSendKeys, + wait: herdWait, restore: herdRestore, report: herdReport, + notify: herdNotify, watch: herdWatch, stop: herdStop, +}; + +export async function herdCommand(argv = [], { write = console.log } = {}) { + const [verb, ...rest] = argv; + if (!verb || verb === "--json") return herdPs(argv, { write }); + const run = VERBS[verb]; + if (!run) { + write(err(`unknown herd verb ${JSON.stringify(verb)}`)); + write(info(`verbs: ${Object.keys(VERBS).join(", ")}`)); + return EXIT.usage; + } + return run(rest, { write }); +} diff --git a/src/herd-state.mjs b/src/herd-state.mjs new file mode 100644 index 0000000..49a955d --- /dev/null +++ b/src/herd-state.mjs @@ -0,0 +1,227 @@ +// Semantic state for herd sessions (PRD 0009 R6–R8). +// +// The roster's whole value is the state column. Everything else it shows — +// name, engine, cwd — you already knew when you started the session; "which one +// stopped to ask me something" is the thing you cannot get any other way. +// +// ONE AUTHORITY PER SESSION. herdr's rule, adopted because the failure it +// prevents is real: an engine hook that reports `working` and a screen rule +// that reads `blocked` cannot both be right, and a roster that flickers between +// them is worse than one that says `unknown`. So a session with a live hook +// report is read from the hook and the screen rules are not consulted at all. +// +// Screen rules are the fallback, and they are the part that rots — engines +// change their prompts between releases and nothing tells us. Three things make +// that survivable: rules ship next to each engine's install spec in +// src/engines.mjs so they version together, `unknown` is always a safe answer +// and never blocks anything, and a user can add or override a pattern in +// ~/.moshcode/herd/rules.json without waiting for a release. +import fs from "node:fs"; +import path from "node:path"; + +import { ENGINES } from "./engines.mjs"; +import { capture, herdDir, sessionExited } from "./herd.mjs"; + +/** The vocabulary the roster, notifications, and `wait` all share. */ +export const STATES = ["working", "blocked", "done", "idle", "unknown"]; + +/** + * `gone` is deliberately not in STATES: it is not a state an agent is in, it is + * the absence of one. It exists so the roster can show what a reboot took and + * `moshcode restore` has something to rebuild from. + */ +export const ALL_STATES = [...STATES, "gone"]; + +/** How long a hook's report stays authoritative before the screen takes over. */ +export const HOOK_TTL_MS = 15 * 60 * 1000; + +const statusDir = () => path.join(herdDir(), "status"); +const statusFile = (name) => path.join(statusDir(), `${name}.json`); + +/** + * Terminal escapes have to go before anything is matched. + * + * tmux's capture-pane already hands back plain text, but the pty substrate's + * transcript is the raw stream — every colour change, cursor move and + * alternate-screen switch still in it. A rule like /Do you want to/ will miss + * when the engine coloured half the sentence. + */ +export function stripAnsi(text) { + return String(text) + // CSI, OSC and the two-character escapes, in that order. + .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") + .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "") + .replace(/\x1b[@-Z\\-_]/g, "") + .replace(/\r(?!\n)/g, "\n"); +} + +/** + * Patterns that hold across engines. + * + * Every one of these is a *terminal-shaped* question — a y/n, a numbered menu + * selector, a "press enter" — rather than a word that happens to appear in + * agent output. "Approve" on its own would match an agent writing about an + * approvals feature; `[y/N]` at the end of a screen would not. + */ +export const COMMON_RULES = { + blocked: [ + /\[y\/n\]/i, + /\((?:y(?:es)?\/n(?:o)?)\)\s*[:?]?\s*$/im, + /\bdo you want to\b/i, + /\bpress (?:enter|return) to continue\b/i, + // The cursor on a numbered menu. Engines do not agree on the glyph — + // Claude Code draws ❯, Codex draws › — and the plain > is there for the + // ones that use ASCII. Observed, not guessed. + /^\s*[❯›▸>]\s*\d+\.\s+\S/m, + /\bwaiting for (?:your )?(?:approval|confirmation)\b/i, + ], + working: [ + /\besc(?:ape)? to interrupt\b/i, + /\bctrl\+c to (?:stop|cancel|interrupt)\b/i, + /\bpress esc to cancel\b/i, + ], + idle: [], +}; + +/** + * User overrides, so a rule that rots can be fixed on the box it rots on. + * + * Shape mirrors the engine table: { "": { blocked: ["…"], … } }, with + * patterns as strings because JSON has no regex literal. `common` is accepted + * as an engine name to extend the shared set. Never throws — a malformed rules + * file must not take down the roster. + */ +export function loadUserRules(file = path.join(herdDir(), "rules.json")) { + let raw; + try { raw = JSON.parse(fs.readFileSync(file, "utf8")); } + catch { return {}; } + if (!raw || typeof raw !== "object") return {}; + const out = {}; + for (const [engine, group] of Object.entries(raw)) { + if (!group || typeof group !== "object") continue; + const compiled = {}; + for (const state of ["blocked", "working", "idle"]) { + const patterns = Array.isArray(group[state]) ? group[state] : []; + compiled[state] = patterns.flatMap((p) => { + try { return [new RegExp(p, "im")]; } + catch { return []; } // one bad pattern loses that pattern, not the file + }); + } + out[engine] = compiled; + } + return out; +} + +/** The rule set for one engine: user overrides, then its own, then the shared. */ +export function rulesFor(engine, { userRules = loadUserRules() } = {}) { + const own = ENGINES[engine]?.state || {}; + const user = userRules[engine] || {}; + const common = userRules.common || {}; + const merge = (state) => [ + ...(user[state] || []), + ...(own[state] || []), + ...(common[state] || []), + ...(COMMON_RULES[state] || []), + ]; + return { blocked: merge("blocked"), working: merge("working"), idle: merge("idle") }; +} + +/** + * Classify a screen. + * + * Order is not arbitrary. `blocked` is checked first because it is the only + * state that costs the user something to miss, and because a blocked engine's + * screen frequently still carries the "esc to interrupt" hint from the work it + * was doing a moment ago. `idle` last, and only on a positive match, so a quiet + * screen nobody has written a rule for reports `unknown` instead of a + * confident lie. + */ +export function classify(screen, rules) { + const text = stripAnsi(screen); + if (!text.trim()) return "unknown"; + // Only the bottom of the screen decides. An agent that printed a y/n prompt + // twenty lines ago and moved on is not blocked, and scrollback is full of + // sentences that look like prompts. + const lines = text.split("\n"); + const tail = lines.slice(Math.max(0, lines.length - 25)).join("\n"); + for (const state of ["blocked", "working", "idle"]) { + if ((rules[state] || []).some((re) => re.test(tail))) return state; + } + return "unknown"; +} + +// --------------------------------------------------------------------------- +// Tier 1: the hook report +// --------------------------------------------------------------------------- + +/** + * Record an authoritative state, written by an engine's own lifecycle hook via + * `moshcode herd report`. `ttl` is in milliseconds and bounded: a hook that + * claims authority forever would leave a crashed agent reading `working` until + * someone noticed by hand. + */ +export function reportState(name, state, { ttl = HOOK_TTL_MS, now = Date.now() } = {}) { + if (!STATES.includes(state)) return { ok: false, error: new Error(`unknown state ${JSON.stringify(state)} — one of ${STATES.join(", ")}`) }; + try { + fs.mkdirSync(statusDir(), { recursive: true, mode: 0o700 }); + const file = statusFile(name); + fs.writeFileSync(file, JSON.stringify({ state, at: now, ttl: Math.min(Number(ttl) || HOOK_TTL_MS, HOOK_TTL_MS) }), { mode: 0o600 }); + fs.chmodSync(file, 0o600); + return { ok: true, state }; + } catch (error) { + return { ok: false, error }; + } +} + +/** The live hook report for a session, or null when there is none worth trusting. */ +export function hookReport(name, { now = Date.now() } = {}) { + let raw; + try { raw = JSON.parse(fs.readFileSync(statusFile(name), "utf8")); } + catch { return null; } + if (!raw || !STATES.includes(raw.state)) return null; + const ttl = Math.min(Number(raw.ttl) || HOOK_TTL_MS, HOOK_TTL_MS); + if (!Number.isFinite(raw.at) || now - raw.at > ttl) return null; + return { state: raw.state, at: raw.at }; +} + +export function clearReport(name) { + try { fs.rmSync(statusFile(name), { force: true }); return true; } + catch { return false; } +} + +// --------------------------------------------------------------------------- +// The answer +// --------------------------------------------------------------------------- + +/** + * The state of one session, and where that answer came from. + * + * `authority` is returned alongside the state on purpose: when a rule rots, the + * first useful question is "was anything even reading the screen?", and a + * roster that cannot answer it sends people to read this file instead. + */ +export function sessionState(session, { now = Date.now(), userRules = loadUserRules(), read = capture } = {}) { + const name = typeof session === "string" ? session : session.name; + const meta = typeof session === "string" ? {} : session; + + if (meta.alive === false) return { state: "gone", authority: "runtime" }; + + // A finished process is done, and no screen rule gets a vote on that. This is + // the one thing the runtime knows for certain. + const exited = meta.exited ?? sessionExited(name); + if (exited === true) return { state: "done", authority: "runtime" }; + if (exited === null && meta.alive === undefined) return { state: "gone", authority: "runtime" }; + + const hook = hookReport(name, { now }); + if (hook) return { state: hook.state, authority: "hook" }; + + const screen = read(name); + if (!screen) return { state: "unknown", authority: "screen" }; + return { state: classify(screen, rulesFor(meta.engine, { userRules })), authority: "screen" }; +} + +/** listSessions() output, each row carrying its state. */ +export function withState(sessions, options = {}) { + const userRules = options.userRules ?? loadUserRules(); + return sessions.map((s) => ({ ...s, ...sessionState(s, { ...options, userRules }) })); +} diff --git a/src/herd.mjs b/src/herd.mjs new file mode 100644 index 0000000..690cfc8 --- /dev/null +++ b/src/herd.mjs @@ -0,0 +1,746 @@ +// The herd — a persistent runtime for agent sessions (PRD 0009). +// +// Everything else in moshcode opens an engine with `stdio: "inherit"`: the +// child takes the terminal, you wait, and when it exits you get the prompt +// back. That is why an engine feels native, and it is also why the pit can only +// ever be doing one thing, and why closing the terminal kills the work. +// +// The herd inverts it. A session runs inside a runtime that outlives the pit, +// so starting one hands the prompt straight back and you carry on. `ps` shows +// the roster, `attach` puts you inside one, and detaching leaves it running. +// +// TWO SUBSTRATES, ONE INTERFACE. Persisting an interactive program means +// something other than your terminal has to own its pty: +// +// "tmux" — a single named tmux server (socket `moshcode`, not the per-pid one +// src/tabs.mjs opens). Full fidelity: real resizing, scrollback, native +// attach. This is the recommended path. +// +// "pty" — no tmux on the box. `script(1)` allocates the pty (the same +// capability detection src/pty.mjs already does), the child is detached +// with its stdin on a FIFO, and `attach` replays the transcript and relays +// keystrokes. Works everywhere script(1) does. Its one real limit: nothing +// outside the pty can ioctl the master, so the size is fixed at launch (to +// the starting terminal, via stty from inside) and a later resize does not +// reach it. Honest and useful, not equal. +// +// null — neither. Callers fall back to today's foreground passthrough and say +// so once. moshcode does not harden a soft dependency into a hard one. +// +// Metadata (engine, cwd, argv) lives in a 0600 manifest rather than in the +// substrate, because the manifest is needed anyway to rebuild the herd after a +// reboot, and because tmux user-options are a 3.0+ feature we would rather not +// require. +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { followFile, scriptFlavor, shQuote, stripScriptBanner } from "./pty.mjs"; + +/** tmux server socket. Deliberately stable — the whole point is outliving pits. */ +export const HERD_SOCKET = process.env.MOSHCODE_HERD_SOCKET || "moshcode"; + +/** Where the manifest, transcripts, FIFOs and hook reports live. */ +export function herdDir() { + return process.env.MOSHCODE_HERD_DIR || path.join(os.homedir(), ".moshcode", "herd"); +} +const manifestPath = () => path.join(herdDir(), "sessions.json"); + +/** + * Session names are a handle typed at a prompt, embedded in a tmux target, and + * used as a filename. herdr's shape, and for the same reasons: anything looser + * would let a name mean one thing to tmux (which reads `:` and `.` as target + * separators) and another to the filesystem. + */ +export const NAME_RE = /^[a-z][a-z0-9_-]{0,31}$/; +export const validName = (name) => NAME_RE.test(String(name || "")); + +/** Turn any string into something NAME_RE accepts, for auto-generated names. */ +export function slugifyName(input) { + const slug = String(input || "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/^[^a-z]+/, "") + .slice(0, 32); + return slug || "agent"; +} + +/** + * The default name for a session: `-`, suffixed on collision. + * `taken` is whatever is already in the herd, so two claudes in two repos get + * distinguishable names without anyone typing `--name`. + */ +export function defaultName(engine, cwd, taken = []) { + const base = slugifyName(`${engine}-${path.basename(cwd || "") || "pit"}`); + const used = new Set(taken); + if (!used.has(base)) return base; + for (let n = 2; n < 1000; n++) { + const candidate = slugifyName(`${base}-${n}`); + if (!used.has(candidate)) return candidate; + } + return slugifyName(`${base}-${process.pid}`); +} + +// --------------------------------------------------------------------------- +// Manifest — the metadata that has to survive the runtime, not just the pit. +// --------------------------------------------------------------------------- + +function ensureDir() { + const dir = herdDir(); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + return dir; +} + +/** + * Read the manifest. Never throws: a corrupt or absent manifest means "no + * remembered sessions", which is recoverable, and the live substrate is still + * the authority on what is actually running. + */ +export function readManifest() { + try { + const raw = JSON.parse(fs.readFileSync(manifestPath(), "utf8")); + if (!raw || typeof raw !== "object" || typeof raw.sessions !== "object") return { version: 1, sessions: {} }; + return { version: 1, sessions: raw.sessions || {} }; + } catch { + return { version: 1, sessions: {} }; + } +} + +/** + * Write the manifest at 0600. + * + * The same reasoning as .moshcode_history in src/tui.mjs, one step harder: this + * records the argv an engine was launched with, and an engine is regularly + * launched with a flag carrying a token. `mode` only applies on create, so + * chmod every write to fix installs that predate this. + */ +export function writeManifest(manifest) { + try { + ensureDir(); + const file = manifestPath(); + fs.writeFileSync(file, JSON.stringify({ version: 1, sessions: manifest.sessions || {} }, null, 2), { mode: 0o600 }); + fs.chmodSync(file, 0o600); + return true; + } catch { + return false; + } +} + +export function rememberSession(name, entry) { + const manifest = readManifest(); + manifest.sessions[name] = { ...(manifest.sessions[name] || {}), ...entry }; + writeManifest(manifest); +} + +export function forgetSession(name) { + const manifest = readManifest(); + if (!(name in manifest.sessions)) return false; + delete manifest.sessions[name]; + writeManifest(manifest); + return true; +} + +// --------------------------------------------------------------------------- +// Substrate detection +// --------------------------------------------------------------------------- + +let substrateCache; + +/** + * Which substrate this machine can run the herd on: "tmux", "pty", or null. + * + * Probed once and cached, like scriptFlavor() — every roster render would + * otherwise fork a `tmux -V`. MOSHCODE_HERD=off forces the honest degradation + * path, which is how the fallback gets tested on a box that has tmux. + */ +export function detectSubstrate({ runner = spawnSync, env = process.env, force = false } = {}) { + if (!force && substrateCache !== undefined) return substrateCache; + const chosen = (() => { + if (env.MOSHCODE_HERD === "off") return null; + if (env.MOSHCODE_HERD !== "pty") { + try { + const r = runner("tmux", ["-V"], { encoding: "utf8" }); + if (!r?.error && r?.status === 0) return "tmux"; + } catch { /* fall through */ } + } + if (env.MOSHCODE_HERD === "tmux") return null; + // The pty substrate needs a script(1) we understand AND a mkfifo, because + // the FIFO is how a detached child keeps a stdin that never sees EOF. + if (!scriptFlavor({ runner })) return null; + try { + const r = runner("mkfifo", ["--version"], { encoding: "utf8" }); + // BSD mkfifo has no --version and exits non-zero on it; a usage message + // still proves the binary is there, which is all this needs to know. + if (r?.error?.code === "ENOENT") return null; + } catch { return null; } + return "pty"; + })(); + if (!force) substrateCache = chosen; + return chosen; +} + +/** Test seam: drop the memoised substrate. */ +export function resetSubstrate() { substrateCache = undefined; } + +/** One line explaining what the user loses, printed once when it matters. */ +export function substrateNote(substrate = detectSubstrate()) { + if (substrate === "tmux") return null; + if (substrate === "pty") { + return "no tmux — sessions run under script(1). they work, but their size is fixed when they start. install tmux to make them resizable."; + } + const how = process.platform === "darwin" ? "brew install tmux" : "sudo apt install tmux (or your package manager)"; + return `no tmux and no usable script(1) — sessions will run in the foreground and end with this terminal. ${how}`; +} + +// --------------------------------------------------------------------------- +// tmux substrate +// --------------------------------------------------------------------------- + +const tmuxArgs = (args) => ["-L", HERD_SOCKET, ...args]; + +export function tmux(args, { runner = spawnSync, env = process.env, encoding = "utf8" } = {}) { + try { + const r = runner("tmux", tmuxArgs(args), { encoding, env }); + if (r?.error) return { ok: false, error: r.error, stdout: "", stderr: "" }; + return { + ok: r.status === 0, + code: r.status, + stdout: String(r.stdout || ""), + stderr: String(r.stderr || ""), + }; + } catch (error) { + return { ok: false, error, stdout: "", stderr: "" }; + } +} + +/** + * The shell-command tmux runs for a session. + * + * A single quoted string rather than an argv, matching src/tabs.mjs: tmux's + * `shell-command` is one argument in every version we care about, and quoting + * it ourselves is the only way an argument containing a space survives. + * + * `env -u` rather than tmux's `-e`: engines like claude need variables *removed* + * (an inherited ANTHROPIC_API_KEY hijacks its stored login — see ENGINES), and + * `-e KEY=` sets an empty value, which is not the same as unset. + */ +export function sessionCommand({ bin, args = [], stripEnv = [], exec = true }) { + const unset = stripEnv.flatMap((key) => ["-u", key]); + const command = [bin, ...args].map(shQuote).join(" "); + const withEnv = unset.length ? `env ${unset.map(shQuote).join(" ")} ${command}` : command; + // `exec` so the engine replaces the shell rather than sitting under it — one + // less process between a signal and the thing meant to receive it. The pty + // substrate passes exec:false because it needs the shell to outlive the + // engine by exactly one command, to record that it finished. + return exec ? `exec ${withEnv}` : withEnv; +} + +/** + * Argv that creates a detached session. Split out from the spawn so the + * safety-sensitive part is testable without starting a real server. + * + * `-f /dev/null` for the same reason src/tabs.mjs does it: this server is + * moshcode's, and the detach key we print has to be the one that works even + * when the user's own tmux.conf rebinds prefix. + */ +export function tmuxStartPlan({ name, cwd, command }) { + // ONE tmux invocation, not two. A finished agent must stay readable — "which + // one is done?" is half the reason the roster exists, and a session that + // evaporates on exit can only ever answer "gone". But a short-lived command + // can finish before a second `tmux set-option` process has even started, and + // then the option lands on a session that is already gone. tmux takes `;` as + // its own argument to mean "and then", which closes the race by never letting + // the session exist without the option. + return [ + "-f", "/dev/null", + "new-session", "-d", "-s", name, "-c", cwd, command, + ";", "set-option", "-t", name, "remain-on-exit", "on", + ]; +} + +// --------------------------------------------------------------------------- +// pty substrate — detached script(1) + a FIFO for stdin +// --------------------------------------------------------------------------- + +const ptyPaths = (name) => ({ + transcript: path.join(herdDir(), `${name}.transcript`), + fifo: path.join(herdDir(), `${name}.stdin`), + meta: path.join(herdDir(), `${name}.pid`), + // Written by the session itself on the way out. A dead pid alone cannot tell + // "the agent finished" from "the box rebooted while it was working", and + // those are different answers: one is `done`, the other is something + // `restore` should bring back. + exit: path.join(herdDir(), `${name}.exit`), +}); + +/** Is this pid still ours and alive? signal 0 asks without sending anything. */ +export function pidAlive(pid) { + if (!pid) return false; + try { process.kill(pid, 0); return true; } + catch (e) { return e.code === "EPERM"; } +} + +/** + * Start a session with no tmux in sight. + * + * The FIFO is opened O_RDWR *before* the spawn and handed to the child as fd 0. + * That detail is load-bearing: a FIFO opened read-only returns EOF the moment + * the last writer closes, so a child whose stdin is a plain reader would die as + * soon as the pit that started it exited — the exact failure this whole module + * exists to prevent. Holding it O_RDWR makes the child its own writer, so it + * never sees EOF and waits for input forever, which is what an idle agent + * should do. + */ +function ptyStart({ name, cwd, bin, args, stripEnv, env, spawner = spawn, runner = spawnSync, size = {} }) { + ensureDir(); + const cols = Number(size.cols) || Number(env.COLUMNS) || process.stdout.columns || 80; + const rows = Number(size.rows) || Number(env.LINES) || process.stdout.rows || 24; + const { transcript, fifo, meta, exit } = ptyPaths(name); + for (const file of [transcript, fifo, meta, exit]) { + try { fs.rmSync(file, { force: true }); } catch { /* first run */ } + } + + const made = runner("mkfifo", ["-m", "600", fifo], { encoding: "utf8" }); + if (made?.error || made?.status !== 0) { + return { ok: false, error: new Error(`could not create the input pipe: ${made?.stderr?.trim() || made?.error?.message || "mkfifo failed"}`) }; + } + fs.writeFileSync(transcript, "", { mode: 0o600 }); + + const flavor = scriptFlavor({ runner }); + // script(1) sizes the pty from its own stdout, and ours is /dev/null, so the + // child would otherwise start on a 0x0 terminal — which full-screen engines + // do not survive. Nothing outside the pty can ioctl its master, but `stty` + // running *inside* it can, so the session sizes itself on the way in. The + // size is whatever the terminal that started it had; a later resize cannot + // reach it, which is the pty substrate's one honest limitation. + // Not `exec`: the shell has to outlive the engine by exactly one command, so + // that a session which finishes on its own leaves proof it finished. + const command = [ + `stty rows ${rows} cols ${cols} 2>/dev/null`, + sessionCommand({ bin, args, stripEnv, exec: false }), + `printf '%s' "$?" > ${shQuote(exit)}`, + ].join("; "); + // Reuse ptySpec's flag knowledge rather than re-deriving it: util-linux and + // BSD disagree on both the flags and the argument order. + const spec = flavor === "util-linux" + ? { cmd: "script", args: ["-q", "-e", "-f", "-c", command, transcript] } + : { cmd: "script", args: ["-q", "-F", transcript, "sh", "-c", command] }; + + let stdin; + try { stdin = fs.openSync(fifo, fs.constants.O_RDWR); } + catch (error) { return { ok: false, error }; } + + let child; + try { + child = spawner(spec.cmd, spec.args, { + cwd, + // Belt and braces with the stty above: some toolkits read COLUMNS/LINES + // before they ever ask the terminal. + env: { ...env, COLUMNS: String(cols), LINES: String(rows), MOSHCODE_HERD_SESSION: name }, + stdio: [stdin, "ignore", "ignore"], + detached: true, + }); + } catch (error) { + try { fs.closeSync(stdin); } catch { /* already gone */ } + return { ok: false, error }; + } + // Cut every tie to the pit: its own process group so a Ctrl-C in the pit does + // not reach it, and unref'd so node will exit without waiting for it. + child.unref(); + try { fs.closeSync(stdin); } catch { /* the child holds its own */ } + + try { fs.writeFileSync(meta, JSON.stringify({ pid: child.pid }), { mode: 0o600 }); } + catch { /* liveness falls back to the manifest pid */ } + return { ok: true, pid: child.pid }; +} + +function ptyPid(name) { + try { return JSON.parse(fs.readFileSync(ptyPaths(name).meta, "utf8")).pid || null; } + catch { return null; } +} + +/** Did this session's own shell record an exit? */ +function ptyFinished(name) { + try { return fs.existsSync(ptyPaths(name).exit); } + catch { return false; } +} + +function ptyCleanup(name) { + const { transcript, fifo, meta, exit } = ptyPaths(name); + for (const file of [transcript, fifo, meta, exit]) { + try { fs.rmSync(file, { force: true }); } catch { /* best effort */ } + } +} + +/** + * Everything the pty substrate has of a session's screen: its transcript. + * + * script(1)'s own header goes first. `-q` silences it on the terminal but still + * writes it to the file, and it is not harmless bookkeeping here — it contains + * the fully quoted command line, so leaving it in would put an engine's argv + * (flags, tokens and all) at the top of every `read` and every notification. + */ +function ptyCapture(name, lines) { + try { + const text = stripScriptBanner(fs.readFileSync(ptyPaths(name).transcript, "utf8"), true); + const all = text.split(/\r?\n/); + return all.slice(Math.max(0, all.length - lines)).join("\n"); + } catch { + return ""; + } +} + +function ptyWrite(name, data) { + let fd; + try { + // O_WRONLY on a FIFO blocks until a reader shows up; the child is that + // reader and it is already there, so this returns immediately. It also + // means writing to a session whose child has died fails fast rather than + // hanging, which is the behaviour we want. + fd = fs.openSync(ptyPaths(name).fifo, fs.constants.O_WRONLY | fs.constants.O_NONBLOCK); + fs.writeSync(fd, data); + return { ok: true }; + } catch (error) { + return { ok: false, error }; + } finally { + if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* closed */ } } + } +} + +// --------------------------------------------------------------------------- +// The interface the rest of moshcode uses +// --------------------------------------------------------------------------- + +/** Names the substrate says are live right now. */ +export function liveNames({ substrate = detectSubstrate(), runner = spawnSync } = {}) { + if (substrate === "tmux") { + const r = tmux(["list-sessions", "-F", "#{session_name}"], { runner }); + if (!r.ok) return []; // no server yet is not an error, it is an empty herd + return r.stdout.split("\n").map((s) => s.trim()).filter(Boolean); + } + if (substrate === "pty") { + // A finished session is still one the runtime has: it stays on the roster + // reading `done` until someone kills or prunes it, exactly as a dead tmux + // pane does. What drops off is a session whose process is gone *without* + // having recorded an exit — which is what a reboot looks like. + return Object.keys(readManifest().sessions).filter((name) => pidAlive(ptyPid(name)) || ptyFinished(name)); + } + return []; +} + +/** + * Is the session's process finished? A finished agent is `done`, and that is a + * fact about the process, not about what is on the screen — so it is answered + * here and not by the classifier. + */ +export function sessionExited(name, { substrate = detectSubstrate(), runner = spawnSync } = {}) { + if (substrate === "tmux") { + const r = tmux(["list-panes", "-t", name, "-F", "#{pane_dead}"], { runner }); + if (!r.ok) return null; // gone entirely, not exited-but-present + return r.stdout.split("\n").some((line) => line.trim() === "1"); + } + if (substrate === "pty") { + if (ptyFinished(name)) return true; + const pid = ptyPid(name); + if (!pid) return null; + return !pidAlive(pid); + } + return null; +} + +/** + * Everything the roster needs from tmux, in two calls instead of two per + * session. + * + * The roster renders on every pit start and on every poll of `wait`. Asking + * tmux for one session's attached-count and one session's dead-pane status + * meant a fork per field per row, so a herd of six cost thirteen processes to + * draw one screen. tmux will format the whole server in one pass. + */ +function tmuxSnapshot({ runner = spawnSync } = {}) { + const sessions = tmux(["list-sessions", "-F", "#{session_name}\t#{session_attached}"], { runner }); + const attached = new Map(); + if (sessions.ok) { + for (const line of sessions.stdout.split("\n")) { + if (!line.trim()) continue; + const [name, count] = line.split("\t"); + attached.set(name, Number(count) || 0); + } + } + // `-a` is every pane on the server. A session is finished when it has no pane + // that is still alive. + const panes = tmux(["list-panes", "-a", "-F", "#{session_name}\t#{pane_dead}"], { runner }); + const anyLive = new Map(); + if (panes.ok) { + for (const line of panes.stdout.split("\n")) { + if (!line.trim()) continue; + const [name, dead] = line.split("\t"); + anyLive.set(name, (anyLive.get(name) || false) || dead.trim() !== "1"); + } + } + return { attached, anyLive }; +} + +/** + * Start a session in the herd and return immediately. + * + * This is the whole point of the module: the caller gets its prompt back while + * the engine keeps running. Returns { ok, name } or { ok:false, error }. + */ +export function startSession({ + name, + engine, + bin, + args = [], + stripEnv = [], + cwd = process.cwd(), + substrate = detectSubstrate(), + env = process.env, + runner = spawnSync, + spawner = spawn, +} = {}) { + if (!substrate) return { ok: false, error: new Error("no herd substrate — install tmux") }; + if (!validName(name)) return { ok: false, error: new Error(`invalid session name ${JSON.stringify(name)} — ${NAME_RE}`) }; + if (liveNames({ substrate, runner }).includes(name)) { + return { ok: false, error: new Error(`a session named "${name}" is already running — moshcode attach ${name}`) }; + } + + const entry = { + engine, + bin, + args, + cwd, + substrate, + created: Date.now(), + stripEnv, + }; + + if (substrate === "tmux") { + const command = sessionCommand({ bin, args, stripEnv }); + const started = tmux(tmuxStartPlan({ name, cwd, command }), { runner, env }); + if (!started.ok) { + return { ok: false, error: new Error(started.stderr.trim() || started.error?.message || "tmux could not start the session") }; + } + rememberSession(name, entry); + return { ok: true, name, substrate }; + } + + const started = ptyStart({ name, cwd, bin, args, stripEnv, env, spawner, runner }); + if (!started.ok) return started; + rememberSession(name, { ...entry, pid: started.pid }); + return { ok: true, name, substrate, pid: started.pid }; +} + +/** The last `lines` rows of a session's screen — what the classifier reads. */ +export function capture(name, { lines = 60, substrate = detectSubstrate(), runner = spawnSync } = {}) { + if (substrate === "tmux") { + const r = tmux(["capture-pane", "-p", "-t", name, "-S", `-${Math.max(0, lines)}`], { runner }); + return r.ok ? r.stdout.replace(/\n+$/, "") : ""; + } + if (substrate === "pty") return ptyCapture(name, lines); + return ""; +} + +/** Raw key relay. `keys` is passed through to tmux's own key vocabulary. */ +export function sendKeys(name, keys, { substrate = detectSubstrate(), runner = spawnSync } = {}) { + if (substrate === "tmux") { + const r = tmux(["send-keys", "-t", name, ...(Array.isArray(keys) ? keys : [keys])], { runner }); + return r.ok ? { ok: true } : { ok: false, error: new Error(r.stderr.trim() || "send-keys failed") }; + } + if (substrate === "pty") { + const literal = (Array.isArray(keys) ? keys : [keys]) + .map((k) => (k === "Enter" ? "\r" : k === "Escape" ? "\x1b" : k)) + .join(""); + return ptyWrite(name, literal); + } + return { ok: false, error: new Error("no herd substrate") }; +} + +/** + * Type a prompt into a session and press Enter. + * + * Deliberately two calls with the text sent literally (`-l`): a prompt is user + * text and regularly contains `;`, `$` or a bare `Enter`, all of which tmux + * would otherwise read as key names rather than characters. + */ +export function sendPrompt(name, text, { substrate = detectSubstrate(), runner = spawnSync } = {}) { + if (substrate === "tmux") { + const typed = tmux(["send-keys", "-t", name, "-l", String(text)], { runner }); + if (!typed.ok) return { ok: false, error: new Error(typed.stderr.trim() || "send-keys failed") }; + const entered = tmux(["send-keys", "-t", name, "Enter"], { runner }); + return entered.ok ? { ok: true } : { ok: false, error: new Error(entered.stderr.trim() || "send-keys failed") }; + } + if (substrate === "pty") return ptyWrite(name, `${String(text)}\r`); + return { ok: false, error: new Error("no herd substrate") }; +} + +/** End a session and forget it. */ +export function killSession(name, { substrate = detectSubstrate(), runner = spawnSync } = {}) { + if (substrate === "tmux") { + const r = tmux(["kill-session", "-t", name], { runner }); + forgetSession(name); + return r.ok ? { ok: true } : { ok: false, error: new Error(r.stderr.trim() || "no such session") }; + } + if (substrate === "pty") { + const pid = ptyPid(name); + let killed = false; + if (pid && pidAlive(pid)) { + // Negative pid: script(1) is a process group leader (detached), and the + // engine is its child. Signalling the leader alone regularly leaves the + // engine running with no way left to reach it. + try { process.kill(-pid, "SIGTERM"); killed = true; } + catch { try { process.kill(pid, "SIGTERM"); killed = true; } catch { /* already gone */ } } + } + ptyCleanup(name); + forgetSession(name); + return killed ? { ok: true } : { ok: false, error: new Error("no such session") }; + } + return { ok: false, error: new Error("no herd substrate") }; +} + +/** Stop the whole runtime. Every session in it goes too — hence the name. */ +export function stopRuntime({ substrate = detectSubstrate(), runner = spawnSync } = {}) { + if (substrate === "tmux") { + const r = tmux(["kill-server"], { runner }); + writeManifest({ sessions: {} }); + return { ok: r.ok }; + } + if (substrate === "pty") { + for (const name of Object.keys(readManifest().sessions)) killSession(name, { substrate, runner }); + return { ok: true }; + } + return { ok: false }; +} + +/** + * Attach the current terminal to a session, resolving when the user detaches + * or the session ends. This is the one call that takes the terminal. + */ +export async function attachSession(name, { + substrate = detectSubstrate(), + env = process.env, + spawner = spawn, + stdin = process.stdin, + stdout = process.stdout, +} = {}) { + if (substrate === "tmux") { + return new Promise((resolve) => { + let child; + try { child = spawner("tmux", tmuxArgs(["attach-session", "-t", name]), { stdio: "inherit", env }); } + catch (error) { resolve({ ok: false, error }); return; } + child.on("error", (error) => resolve({ ok: false, error })); + child.on("exit", (code, signal) => resolve({ ok: code === 0, code, signal })); + }); + } + if (substrate === "pty") return ptyAttachSession(name, { stdin, stdout }); + return { ok: false, error: new Error("no herd substrate") }; +} + +/** The byte that detaches a pty-substrate session: Ctrl-]. */ +export const PTY_DETACH_KEY = "\x1d"; + +/** + * Attach without tmux: replay what is on screen, then relay. + * + * Everything typed goes to the FIFO and everything appended to the transcript + * comes back out, which is a terminal in the only sense that matters here. The + * replay is what makes it usable at all — an engine in its alternate screen + * will not redraw for us, so without pushing the tail back you attach to a + * blank rectangle. + */ +export function ptyAttachSession(name, { stdin = process.stdin, stdout = process.stdout } = {}) { + return new Promise((resolve) => { + if (!pidAlive(ptyPid(name))) { resolve({ ok: false, error: new Error(`no session named "${name}"`) }); return; } + + // Note the size *before* printing the context, and start the follow there. + // Following from zero would replay everything the session has ever printed + // on top of the tail we just showed — for an agent that has been running + // for hours that is megabytes of scrollback. Anything written between the + // stat and the follow starting is inside [size, …) and still arrives; at + // worst a line or two is shown twice, which beats both a gap and a replay. + let size = 0; + try { size = fs.statSync(ptyPaths(name).transcript).size; } catch { /* first read */ } + stdout.write(ptyCapture(name, 200)); + stdout.write(`\n\x1b[2m— attached to ${name} · Ctrl-] to detach —\x1b[22m\n`); + + const wasRaw = Boolean(stdin.isRaw); + try { stdin.setRawMode?.(true); } catch { /* not a tty; relay still works */ } + stdin.resume(); + + let done = false; + const cleanupTimers = []; + const stopFollow = followFile(ptyPaths(name).transcript, (chunk) => stdout.write(chunk), { + intervalMs: 40, startOffset: size, + }); + + const finish = (result) => { + if (done) return; + done = true; + for (const timer of cleanupTimers) clearInterval(timer); + stdin.off("data", onData); + try { stdin.setRawMode?.(wasRaw); } catch { /* not a tty */ } + stdin.pause(); + stopFollow(); + stdout.write("\n"); + resolve(result); + }; + + const onData = (buf) => { + if (buf.includes(PTY_DETACH_KEY)) { + const before = buf.subarray(0, buf.indexOf(PTY_DETACH_KEY)); + if (before.length) ptyWrite(name, before); + finish({ ok: true, detached: true }); + return; + } + const written = ptyWrite(name, buf); + if (!written.ok) finish({ ok: true, ended: true }); + }; + stdin.on("data", onData); + + // The child can exit while you are watching it; nothing else would notice. + // + // Not unref'd, for the same reason the poll timer in herd-cli is not: an + // attach is a foreground act whose entire purpose is to stay. The follow + // timer is unref'd (pty.mjs), so if this one were too, an attach whose + // stdin did not hold the loop open would exit the instant it started. + // finish() clears it, so it never outlives the attach either. + const liveness = setInterval(() => { + if (!pidAlive(ptyPid(name))) { finish({ ok: true, ended: true }); } + }, 500); + cleanupTimers.push(liveness); + }); +} + +/** + * The roster: every session the herd knows about, live or remembered. + * + * Remembered-but-not-live entries are kept rather than swept, because "the box + * rebooted and these are what you were running" is exactly the question + * `moshcode restore` answers. + */ +export function listSessions({ substrate = detectSubstrate(), runner = spawnSync, now = Date.now() } = {}) { + const manifest = readManifest(); + const snapshot = substrate === "tmux" ? tmuxSnapshot({ runner }) : null; + const live = new Set(snapshot ? snapshot.attached.keys() : liveNames({ substrate, runner })); + const names = [...new Set([...live, ...Object.keys(manifest.sessions)])].sort(); + return names.map((name) => { + const meta = manifest.sessions[name] || {}; + const alive = live.has(name); + const exited = !alive ? null + : snapshot ? !snapshot.anyLive.get(name) + : sessionExited(name, { substrate, runner }); + return { + name, + engine: meta.engine || "?", + cwd: meta.cwd || "", + created: meta.created || null, + age: meta.created ? now - meta.created : null, + alive, + exited, + attached: alive && snapshot ? snapshot.attached.get(name) || 0 : 0, + substrate: meta.substrate || substrate, + }; + }); +} diff --git a/src/pty.mjs b/src/pty.mjs index e862baf..ff2a832 100644 --- a/src/pty.mjs +++ b/src/pty.mjs @@ -92,9 +92,13 @@ export function ptySpec(cmd, args = [], transcript, flavor) { * decoding each slice independently turns them into U+FFFD in the mirror. The * decoder holds the incomplete tail back until the rest of it arrives. */ -export function followFile(file, onChunk, { intervalMs = 100 } = {}) { +export function followFile(file, onChunk, { intervalMs = 100, startOffset = 0 } = {}) { let fd = null; - let offset = 0; + // Callers that already have the earlier bytes — the herd's `attach` has just + // printed the tail of the transcript for context — pass the offset they got + // to, so following a session that has been running for hours costs the new + // bytes rather than a full replay of everything it ever printed. + let offset = Number(startOffset) || 0; let stopped = false; let decoder = new StringDecoder("utf8"); diff --git a/src/tui.mjs b/src/tui.mjs index 5e716ef..8612541 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -27,6 +27,8 @@ import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs"; import { RENAMED_COMMANDS, findPitCommand, pitHelpModel, renderPitCommand, suggest, wantsHelp } from "./help.mjs"; import { openNewTab } from "./tabs.mjs"; +import { herdCommand, herdStart, renderRoster, roster, splitDetachArgs } from "./herd-cli.mjs"; +import { detectSubstrate, substrateNote } from "./herd.mjs"; const PROMPT = () => acid("mosh ") + dim("▸ "); @@ -148,6 +150,44 @@ function printEngines(json = false) { } } +/** + * The herd, on the pit's front door. + * + * Printed before the prompt because "what is already running, and does any of + * it want me?" is the first question on opening the pit, and until now the only + * way to answer it was to remember. Silent when the herd is empty — a heading + * over nothing is noise on every cold start. + */ +function printHerd() { + const rows = roster(); + if (!rows.length) return; + const blocked = rows.filter((r) => r.state === "blocked").length; + console.log(bone(" herd") + ash(` — ${rows.length} session${rows.length === 1 ? "" : "s"} · attach with `) + acid("/attach ")); + console.log(renderRoster(rows, { indent: " " })); + if (blocked) console.log(" " + warn(`${blocked} waiting on you`)); +} + +/** + * `-d` / `--name` on `/agents` and `/start`: run it in the herd instead of + * handing over the terminal. + * + * Returns { taken, args }. `taken` means the herd has it and the caller should + * skip its passthrough path; `args` is always the engine's own arguments with + * the herd flags removed, so a box with no substrate falls back to a normal + * foreground launch instead of passing `-d` on to an engine that has never + * heard of it. + */ +function detachedLaunch(key, args, { agentMode = false } = {}) { + const { detach, name, rest } = splitDetachArgs(args); + if (!detach) return { taken: false, args: rest }; + if (!detectSubstrate()) { + console.log(warn(substrateNote(null))); + return { taken: false, args: rest }; + } + herdStart([key, ...(name ? ["--name", name] : []), ...(agentMode ? ["--agent"] : []), ...rest]); + return { taken: true, args: rest }; +} + function printTools() { // Named generically rather than listing every tool: the roster grows, and a // hardcoded list here silently goes stale the moment TOOLS gains an entry. @@ -476,7 +516,9 @@ export async function tui() { printEngines(); console.log(); printTools(); - console.log("\n" + ash(" /help for commands · /new for a tab · /quit to leave") + "\n"); + console.log(); + printHerd(); + console.log("\n" + ash(" /help for commands · /ps for the herd · /new for a tab · /quit to leave") + "\n"); const ad = await motd; if (ad) console.log(dim(ad) + "\n"); @@ -610,6 +652,23 @@ export async function tui() { rl = mkrl(); continue; } + // The herd (PRD 0009). These never close the readline interface, because + // that is the entire point of them: the pit keeps its prompt while the + // sessions run somewhere that outlives it. + if (cmd === "herd") { await herdCommand(rest); continue; } + if (cmd === "ps") { await herdCommand(["ps", ...rest]); continue; } + if (cmd === "kill") { await herdCommand(["kill", ...rest]); continue; } + if (cmd === "wait") { await herdCommand(["wait", ...rest]); continue; } + if (cmd === "restore") { await herdCommand(["restore", ...rest]); continue; } + // `/attach` is the exception — it hands over the terminal like an engine + // session does, so readline has to let go of stdin first or the two fight + // over every keystroke. + if (cmd === "attach") { + rl.close(); + await herdCommand(["attach", ...rest]); + rl = mkrl(); + continue; + } if (cmd === "agents" || cmd === "agent" || cmd === "engines") { if (!rest[0] || (rest.length === 1 && rest[0] === "--json")) { printEngines(rest[0] === "--json"); @@ -618,23 +677,27 @@ export async function tui() { const resolved = resolveEngine(rest[0]); if (!resolved) { console.log(err(`unknown engine "${rest[0]}". try: ${Object.keys(ENGINES).join(", ")}`)); continue; } const [key, engine] = resolved; + const detached = detachedLaunch(key, rest.slice(1), { agentMode: true }); + if (detached.taken) continue; rl.close(); await openEngine( key, { ...engine, installed: engineStatus().find((e) => e.key === key)?.installed }, - rest.slice(1), + detached.args, { agentMode: true }, ); rl = mkrl(); continue; } if (cmd === "start") { - if (!rest[0]) { console.log(err("usage: /start [args…]")); continue; } + if (!rest[0]) { console.log(err("usage: /start [args…] [-d]")); continue; } const resolved = resolveEngine(rest[0]); if (!resolved) { console.log(err(`unknown engine "${rest[0]}". try: ${Object.keys(ENGINES).join(", ")}`)); continue; } const [key, engine] = resolved; + const detached = detachedLaunch(key, rest.slice(1)); + if (detached.taken) continue; rl.close(); - await openEngine(key, { ...engine, installed: engineStatus().find((e) => e.key === key)?.installed }, rest.slice(1)); + await openEngine(key, { ...engine, installed: engineStatus().find((e) => e.key === key)?.installed }, detached.args); rl = mkrl(); continue; } diff --git a/test/herd-cli.test.mjs b/test/herd-cli.test.mjs new file mode 100644 index 0000000..486e390 --- /dev/null +++ b/test/herd-cli.test.mjs @@ -0,0 +1,184 @@ +// The command surface: the exit codes `wait` exists to produce, the flags that +// turn a launch into a herd session, and the rules that keep notifications from +// becoming the thing everyone switches off. +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + EXIT, humanAge, paintState, renderRoster, shouldNotify, splitDetachArgs, waitFor, +} from "../src/herd-cli.mjs"; + +const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + +const session = (extra = {}) => ({ + name: "api", engine: "claude", cwd: "/x/api", age: 60000, alive: true, state: "working", ...extra, +}); + +/* ---------------------------------------------------------- detach parsing */ + +test("a launch stays in the foreground unless it is asked not to", () => { + // The load-bearing default. If `-d` were implied, this whole feature would be + // a regression wearing a roster. + assert.deepEqual(splitDetachArgs(["--model", "opus"]), { detach: false, name: null, rest: ["--model", "opus"] }); +}); + +test("--name implies --detach", () => { + // Naming a session you were about to sit inside is a request for one you can + // come back to. + const parsed = splitDetachArgs(["--name", "api"]); + assert.equal(parsed.detach, true); + assert.equal(parsed.name, "api"); +}); + +test("the herd flags never reach the engine", () => { + // An engine has never heard of `-d`, and passing it on turns a detach into an + // argument error from something that is not moshcode. + const parsed = splitDetachArgs(["-d", "--model", "opus", "--name=api", "-p", "hi"]); + assert.deepEqual(parsed.rest, ["--model", "opus", "-p", "hi"]); + assert.equal(parsed.name, "api"); +}); + +test("--name=value is the same as --name value", () => { + assert.equal(splitDetachArgs(["--name=api"]).name, "api"); + assert.equal(splitDetachArgs(["--name", "api"]).name, "api"); +}); + +/* -------------------------------------------------------------- exit codes */ + +test("wait's outcomes are distinguishable", () => { + // The whole reason `wait` exists is to be branched on, so the codes have to + // mean different things — a script cannot tell "it blocked" from "it never + // did" if both exit 0. + assert.notEqual(EXIT.matched, EXIT.timeout); + assert.notEqual(EXIT.timeout, EXIT.gone); + assert.equal(EXIT.matched, 0, "success is 0 or no shell believes it"); +}); + +test("wait returns as soon as the state is reached", async () => { + let looks = 0; + const result = await waitFor("api", ["blocked"], { + intervalMs: 1, + look: () => session({ state: ++looks >= 3 ? "blocked" : "working" }), + }); + assert.deepEqual(result, { outcome: "matched", state: "blocked" }); +}); + +test("wait gives up rather than hanging on a state that cannot arrive", async () => { + // A finished session will never reach `blocked`. Waiting the full timeout for + // something impossible is a hang, not a wait. + const result = await waitFor("api", ["blocked"], { + intervalMs: 1, + look: () => session({ state: "done", alive: true }), + }); + assert.equal(result.outcome, "ended"); +}); + +test("waiting for done is satisfied by done", async () => { + const result = await waitFor("api", ["done"], { intervalMs: 1, look: () => session({ state: "done" }) }); + assert.deepEqual(result, { outcome: "matched", state: "done" }); +}); + +test("wait on a session that does not exist says so instead of timing out", async () => { + const result = await waitFor("nope", ["blocked"], { intervalMs: 1, look: () => null }); + assert.equal(result.outcome, "gone"); +}); + +test("wait holds the process open between polls", () => { + // This has to run in its own process with nothing else pending, because the + // test runner itself keeps the event loop alive and hides the bug entirely. + // + // The failure it guards against is not a hang, it is the opposite and much + // worse: with an unref'd poll timer node finds nothing scheduled between + // polls and simply exits, so `moshcode wait api --timeout 1h` returns in a + // millisecond, exit 0, having waited for nothing. CI caught it; a green local + // suite did not. + const source = ` + const { waitFor } = await import(${JSON.stringify(path.join(ROOT, "src", "herd-cli.mjs"))}); + const started = Date.now(); + const result = await waitFor("nobody", ["blocked"], { + intervalMs: 50, timeoutMs: 600, + look: () => ({ name: "nobody", state: "working", alive: true }), + }); + console.log(JSON.stringify({ outcome: result.outcome, elapsed: Date.now() - started })); + `; + const run = spawnSync(process.execPath, ["--input-type=module", "-e", source], { encoding: "utf8", cwd: ROOT }); + assert.equal(run.status, 0, `the wait process died instead of waiting: ${run.stderr}`); + const out = JSON.parse(run.stdout.trim()); + assert.equal(out.outcome, "timeout", "waitFor must settle, not leave the process to exit under it"); + assert.ok(out.elapsed >= 500, `waited only ${out.elapsed}ms of 600ms — the poll timer is not holding the loop open`); +}); + +test("wait times out on a session that just keeps working", async () => { + let clock = 0; + const result = await waitFor("api", ["blocked"], { + intervalMs: 1, + timeoutMs: 5, + now: () => (clock += 10), + look: () => session({ state: "working" }), + }); + assert.equal(result.outcome, "timeout"); + assert.equal(result.state, "working"); +}); + +/* ----------------------------------------------------------- notifications */ + +test("only a transition into a watched state notifies", () => { + const watched = new Set(["blocked"]); + assert.equal(shouldNotify("working", "blocked", watched), true); + // A session sitting blocked must not page every poll — that is the failure + // mode that gets the feature switched off within a day. + assert.equal(shouldNotify("blocked", "blocked", watched), false); + // Leaving a watched state is good news nobody needs a text about. + assert.equal(shouldNotify("blocked", "working", watched), false); +}); + +test("the first sighting of a session is history, not news", () => { + // The watcher starting up sees everything for the first time. Without this, + // restarting it pages the operator once for every already-blocked session. + assert.equal(shouldNotify(undefined, "blocked", new Set(["blocked"])), false); +}); + +test("states nobody asked to watch stay quiet", () => { + assert.equal(shouldNotify("working", "done", new Set(["blocked"])), false); + assert.equal(shouldNotify("working", "done", new Set(["blocked", "done"])), true); +}); + +/* --------------------------------------------------------------- rendering */ + +test("the roster puts one session on one line", () => { + const text = renderRoster([session(), session({ name: "web", engine: "codex" })]); + assert.equal(text.split("\n").length, 2); + assert.match(text, /api/); + assert.match(text, /web/); +}); + +test("an empty herd renders nothing rather than a heading over nothing", () => { + assert.equal(renderRoster([]), ""); +}); + +test("the home directory is abbreviated so the cwd column stays readable", () => { + const previous = process.env.HOME; + process.env.HOME = "/home/x"; + try { + assert.match(renderRoster([session({ cwd: "/home/x/src/api" })]), /~\/src\/api/); + } finally { process.env.HOME = previous; } +}); + +test("every state renders as its own name", () => { + // The roster is read at a glance; a state that renders as something else, or + // as nothing, is a state nobody can act on. + for (const state of ["working", "blocked", "done", "idle", "unknown", "gone"]) { + assert.match(paintState(state), new RegExp(state)); + } +}); + +test("ages read as durations, not milliseconds", () => { + assert.equal(humanAge(5000), "5s"); + assert.equal(humanAge(4 * 60000), "4m"); + assert.equal(humanAge(72 * 60000), "1h12m"); + assert.equal(humanAge(50 * 3600000), "2d"); + assert.equal(humanAge(null), "", "an unknown age is blank, not NaN"); +}); diff --git a/test/herd-state.test.mjs b/test/herd-state.test.mjs new file mode 100644 index 0000000..da1ca5b --- /dev/null +++ b/test/herd-state.test.mjs @@ -0,0 +1,212 @@ +// Semantic state: what the classifier reads, what it refuses to guess, and the +// one-authority-per-session rule that keeps a hook and a screen rule from +// disagreeing in public. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + classify, clearReport, COMMON_RULES, hookReport, HOOK_TTL_MS, loadUserRules, + reportState, rulesFor, sessionState, STATES, stripAnsi, withState, +} from "../src/herd-state.mjs"; + +function withHerdDir(fn) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-state-test-")); + const previous = process.env.MOSHCODE_HERD_DIR; + process.env.MOSHCODE_HERD_DIR = dir; + try { return fn(dir); } + finally { + if (previous === undefined) delete process.env.MOSHCODE_HERD_DIR; + else process.env.MOSHCODE_HERD_DIR = previous; + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +const live = (extra = {}) => ({ name: "s", engine: "claude", alive: true, exited: false, ...extra }); + +/* ------------------------------------------------------------------- ANSI */ + +test("escape sequences are stripped before anything is matched", () => { + // tmux hands back plain text, but the pty substrate's transcript is the raw + // stream. A rule like /do you want to/ misses when the engine coloured half + // the sentence. + const coloured = "\x1b[1;32mDo you want\x1b[0m to proceed?"; + assert.match(stripAnsi(coloured), /^Do you want to proceed\?$/); +}); + +test("an OSC title sequence does not eat the rest of the screen", () => { + const screen = "\x1b]0;claude — api\x07Do you want to proceed?"; + assert.match(stripAnsi(screen), /Do you want to proceed\?/); +}); + +/* --------------------------------------------------------------- classify */ + +test("a trailing y/n prompt reads as blocked", () => { + const rules = rulesFor("codex", { userRules: {} }); + assert.equal(classify("running tests…\nOverwrite config? [y/N]", rules), "blocked"); +}); + +test("a numbered selector reads as blocked, whichever glyph the engine draws", () => { + // Both of these are screens real engines actually put up: Claude Code's + // first-run theme picker and Codex's directory-trust prompt. They disagree + // about the cursor character, which is why the rule takes a set. + const claude = "Choose the text style\n\n 1. Auto\n ❯ 2. Dark mode\n 3. Light mode"; + assert.equal(classify(claude, rulesFor("claude", { userRules: {} })), "blocked"); + const codex = "Do you trust the contents of this directory?\n\n› 1. Yes, continue\n 2. No, quit"; + assert.equal(classify(codex, rulesFor("codex", { userRules: {} })), "blocked"); +}); + +test("blocked beats working when both markers are on screen", () => { + // An engine that stops to ask regularly still has the "esc to interrupt" + // hint from the work it was doing a moment ago. Reporting `working` there is + // the one mistake that costs the user something. + const screen = "thinking… (esc to interrupt)\nDo you want to proceed?"; + assert.equal(classify(screen, rulesFor("claude", { userRules: {} })), "blocked"); +}); + +test("only the bottom of the screen decides", () => { + // Scrollback is full of sentences that look like prompts. An agent that + // answered a question thirty lines ago and moved on is not blocked. + const screen = ["Do you want to proceed?", ...Array(40).fill("writing file…")].join("\n"); + assert.equal(classify(screen, rulesFor("claude", { userRules: {} })), "unknown"); +}); + +test("a quiet screen nobody wrote a rule for is unknown, not idle", () => { + // R8. A confident wrong answer is worse than an honest absent one, because + // `unknown` sends you to look and `idle` tells you not to bother. + assert.equal(classify("some ordinary build output\ndone in 3.4s", rulesFor("codex", { userRules: {} })), "unknown"); +}); + +test("an empty screen is unknown", () => { + assert.equal(classify("", rulesFor("claude", { userRules: {} })), "unknown"); + assert.equal(classify(" \n \n", rulesFor("claude", { userRules: {} })), "unknown"); +}); + +test("prose about approvals is not a blocked agent", () => { + // The rules match terminal-shaped questions, not words. An agent writing + // about an approvals feature must not page anyone. + const screen = "I've added the approve button and the Approve endpoint to the router.\nNext I'll wire the tests."; + assert.equal(classify(screen, rulesFor("codex", { userRules: {} })), "unknown"); +}); + +test("every shared pattern is anchored to something a terminal draws", () => { + // A rule that matches a bare English word will fire on agent output. Each + // one has to carry punctuation, a bracket, or a line anchor. + for (const [state, patterns] of Object.entries(COMMON_RULES)) { + for (const re of patterns) { + assert.match(re.source, /[[\]()\\^$?]/, `${state} rule ${re} is too loose to be safe`); + } + } +}); + +/* ---------------------------------------------------------------- authority */ + +test("a fresh hook report wins and the screen is not consulted", () => { + withHerdDir(() => { + reportState("s", "working"); + let read = 0; + const state = sessionState(live(), { read: () => { read++; return "Do you want to proceed?"; } }); + assert.equal(state.state, "working"); + assert.equal(state.authority, "hook"); + // Two sources of truth is the failure herdr calls out: a roster flickering + // between a hook and a rule is worse than one that commits. + assert.equal(read, 0, "the screen must not be read when a hook has authority"); + }); +}); + +test("an expired hook report hands authority back to the screen", () => { + withHerdDir(() => { + reportState("s", "working", { now: Date.now() - HOOK_TTL_MS - 1000 }); + const state = sessionState(live(), { read: () => "Do you want to proceed?" }); + assert.equal(state.state, "blocked"); + assert.equal(state.authority, "screen", "a crashed agent must not read `working` forever"); + }); +}); + +test("a hook cannot claim authority beyond the cap", () => { + withHerdDir(() => { + reportState("s", "working", { ttl: 10 * 365 * 24 * 3600 * 1000 }); + const raw = JSON.parse(fs.readFileSync(path.join(process.env.MOSHCODE_HERD_DIR, "status", "s.json"), "utf8")); + assert.ok(raw.ttl <= HOOK_TTL_MS, "an unbounded ttl would strand a dead session"); + }); +}); + +test("a hook report is written owner-only", () => { + withHerdDir((dir) => { + reportState("s", "blocked"); + assert.equal(fs.statSync(path.join(dir, "status", "s.json")).mode & 0o777, 0o600); + }); +}); + +test("only real states can be reported", () => { + withHerdDir(() => { + assert.equal(reportState("s", "confused").ok, false); + assert.equal(hookReport("s"), null); + for (const state of STATES) assert.equal(reportState("s", state).ok, true); + }); +}); + +test("clearing a report gives the screen its vote back", () => { + withHerdDir(() => { + reportState("s", "working"); + clearReport("s"); + assert.equal(sessionState(live(), { read: () => "Do you want to proceed?" }).state, "blocked"); + }); +}); + +/* -------------------------------------------------------------- the runtime */ + +test("a finished process is done, whatever is on its screen", () => { + withHerdDir(() => { + // The one thing the runtime knows for certain, so no rule gets a vote. + const state = sessionState(live({ exited: true }), { read: () => "Do you want to proceed?" }); + assert.deepEqual(state, { state: "done", authority: "runtime" }); + }); +}); + +test("a session the runtime no longer has is gone, not unknown", () => { + withHerdDir(() => { + // `gone` is what `restore` reads; collapsing it into `unknown` would lose + // the difference between "I can't tell" and "the box rebooted". + assert.deepEqual(sessionState({ name: "s", alive: false }), { state: "gone", authority: "runtime" }); + }); +}); + +/* --------------------------------------------------------------- overrides */ + +test("a user rule can fix a rotted pattern without a release", () => { + withHerdDir((dir) => { + fs.writeFileSync(path.join(dir, "rules.json"), JSON.stringify({ codex: { blocked: ["shall i continue"] } })); + const rules = rulesFor("codex", { userRules: loadUserRules(path.join(dir, "rules.json")) }); + assert.equal(classify("Shall I continue", rules), "blocked"); + }); +}); + +test("one bad pattern loses that pattern, not the whole file", () => { + withHerdDir((dir) => { + const file = path.join(dir, "rules.json"); + fs.writeFileSync(file, JSON.stringify({ codex: { blocked: ["(unclosed", "shall i continue"] } })); + const rules = rulesFor("codex", { userRules: loadUserRules(file) }); + assert.equal(classify("Shall I continue", rules), "blocked"); + }); +}); + +test("a malformed rules file leaves the built-in rules working", () => { + withHerdDir((dir) => { + const file = path.join(dir, "rules.json"); + fs.writeFileSync(file, "not json at all"); + assert.deepEqual(loadUserRules(file), {}); + assert.equal(classify("Overwrite? [y/N]", rulesFor("codex", { userRules: loadUserRules(file) })), "blocked"); + }); +}); + +test("withState leaves the roster rows intact and adds to them", () => { + withHerdDir(() => { + const rows = withState([live({ name: "a", cwd: "/x" })], { read: () => "Do you want to proceed?" }); + assert.equal(rows[0].name, "a"); + assert.equal(rows[0].cwd, "/x", "the row must survive the annotation"); + assert.equal(rows[0].state, "blocked"); + }); +}); diff --git a/test/herd-survival.test.mjs b/test/herd-survival.test.mjs new file mode 100644 index 0000000..2ee8644 --- /dev/null +++ b/test/herd-survival.test.mjs @@ -0,0 +1,117 @@ +// The claim the whole PRD rests on: a session outlives the process that +// started it. Everything else in the herd is bookkeeping around this. +// +// Run against both substrates, because "it works on my box, which has tmux" is +// exactly the assumption R2 exists to stop. Each substrate skips itself when +// the machine cannot provide it, so this passes on a bare container and still +// means something on a developer laptop. +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const HERD = path.join(ROOT, "src", "herd.mjs"); + +/** + * A private runtime per test: its own socket so this cannot touch the sessions + * a developer is actually running, and its own directory for the manifest. + */ +function isolated(substrate) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-survival-")); + return { + dir, + env: { + ...process.env, + MOSHCODE_HERD: substrate, + MOSHCODE_HERD_DIR: dir, + MOSHCODE_HERD_SOCKET: `moshcode-test-${process.pid}-${path.basename(dir)}`, + }, + }; +} + +/** Run a snippet in a throwaway node process, with the herd module available. */ +function inChildProcess(env, source) { + const result = spawnSync(process.execPath, ["--input-type=module", "-e", source], { + env, encoding: "utf8", cwd: ROOT, + }); + return `${result.stdout || ""}${result.stderr || ""}`.trim(); +} + +for (const substrate of ["tmux", "pty"]) { + test(`a ${substrate} session keeps running after the process that started it exits`, (t) => { + const { dir, env } = isolated(substrate); + try { + // One process starts the session and exits. Nothing is left holding it. + const started = inChildProcess(env, ` + const herd = await import(${JSON.stringify(HERD)}); + const r = herd.startSession({ + name: "survivor", engine: "test", bin: "sh", + args: ["-c", "echo READY; while read line; do echo got:$line; done"], + cwd: process.cwd(), + }); + console.log(r.ok ? "started" : "failed:" + (r.error && r.error.message)); + `); + if (/no herd substrate/.test(started) || started === "") { + t.skip(`no ${substrate} substrate on this machine`); + return; + } + assert.match(started, /^started$/m, `could not start a ${substrate} session: ${started}`); + + // A second, unrelated process finds it still running and can talk to it. + const seen = inChildProcess(env, ` + const herd = await import(${JSON.stringify(HERD)}); + await new Promise((r) => setTimeout(r, 1200)); + console.log("LIVE:" + JSON.stringify(herd.liveNames())); + herd.sendPrompt("survivor", "ping"); + await new Promise((r) => setTimeout(r, 800)); + console.log("SCREEN:" + JSON.stringify(herd.capture("survivor", { lines: 20 }))); + `); + assert.match(seen, /LIVE:\["survivor"\]/, `the session did not survive: ${seen}`); + assert.match(seen, /got:ping/, `input did not reach the surviving session: ${seen}`); + } finally { + inChildProcess(env, ` + const herd = await import(${JSON.stringify(HERD)}); + herd.killSession("survivor"); + herd.stopRuntime(); + `); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test(`a ${substrate} session reports done once its process ends`, (t) => { + const { dir, env } = isolated(substrate); + try { + const started = inChildProcess(env, ` + const herd = await import(${JSON.stringify(HERD)}); + const r = herd.startSession({ + name: "finisher", engine: "test", bin: "sh", args: ["-c", "echo working; exit 0"], cwd: process.cwd(), + }); + console.log(r.ok ? "started" : "failed"); + `); + if (started !== "started") { t.skip(`no ${substrate} substrate on this machine`); return; } + + const after = inChildProcess(env, ` + const herd = await import(${JSON.stringify(HERD)}); + const state = await import(${JSON.stringify(path.join(ROOT, "src", "herd-state.mjs"))}); + await new Promise((r) => setTimeout(r, 1500)); + const row = herd.listSessions().find((s) => s.name === "finisher"); + console.log("STATE:" + state.sessionState(row).state); + `); + // A finished agent has to stay visible and readable — "which one is + // done?" is half the reason the roster exists, and a session that + // evaporates on exit can only ever answer "gone". + assert.match(after, /STATE:done/, `expected done, got: ${after}`); + } finally { + inChildProcess(env, ` + const herd = await import(${JSON.stringify(HERD)}); + herd.killSession("finisher"); + herd.stopRuntime(); + `); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +} diff --git a/test/herd.test.mjs b/test/herd.test.mjs new file mode 100644 index 0000000..b9b531b --- /dev/null +++ b/test/herd.test.mjs @@ -0,0 +1,186 @@ +// The herd runtime: naming, the argv that reaches tmux, the manifest, and the +// capability detection that decides whether any of it runs at all. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + defaultName, detectSubstrate, forgetSession, NAME_RE, readManifest, rememberSession, + resetSubstrate, sessionCommand, slugifyName, substrateNote, tmuxStartPlan, validName, + writeManifest, +} from "../src/herd.mjs"; + +/** Each test gets its own herd dir; the module reads the env var every call. */ +function withHerdDir(fn) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-herd-test-")); + const previous = process.env.MOSHCODE_HERD_DIR; + process.env.MOSHCODE_HERD_DIR = dir; + try { return fn(dir); } + finally { + if (previous === undefined) delete process.env.MOSHCODE_HERD_DIR; + else process.env.MOSHCODE_HERD_DIR = previous; + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +/* ------------------------------------------------------------------ naming */ + +test("a session name is safe as a tmux target and as a filename", () => { + assert.ok(validName("api")); + assert.ok(validName("api-refactor_2")); + // tmux reads `:` and `.` as target separators, so a name carrying either + // would address a different window than the one it names. + assert.ok(!validName("api:2"), "a colon is a tmux target separator"); + assert.ok(!validName("api.2"), "a dot is a tmux target separator"); + assert.ok(!validName("../escape"), "a name is also a filename"); + assert.ok(!validName("2fast"), "must start with a letter"); + assert.ok(!validName("")); + assert.ok(!validName("a".repeat(33)), "32 characters is the cap"); +}); + +test("slugifyName always produces something NAME_RE accepts", () => { + for (const input of ["My Repo!", "2024-report", "---", "", "ünïcödé", "a".repeat(80)]) { + assert.match(slugifyName(input), NAME_RE, `slugify(${JSON.stringify(input)}) must be usable`); + } +}); + +test("default names distinguish the same engine in two repos", () => { + assert.equal(defaultName("claude", "/home/x/src/coinpay"), "claude-coinpay"); + assert.equal(defaultName("claude", "/home/x/src/ugig.net"), "claude-ugig-net"); +}); + +test("a default name that is taken gets a suffix rather than colliding", () => { + // Starting a second agent in the same repo is the common case, not the edge + // case, and silently reusing the name would address the first session. + assert.equal(defaultName("claude", "/x/api", ["claude-api"]), "claude-api-2"); + assert.equal(defaultName("claude", "/x/api", ["claude-api", "claude-api-2"]), "claude-api-3"); +}); + +/* -------------------------------------------------------------- the launch */ + +test("the session command quotes arguments that would otherwise split", () => { + const command = sessionCommand({ bin: "claude", args: ["--prompt", "fix the build; now"] }); + assert.match(command, /'fix the build; now'/, "a bare semicolon would end the command"); + assert.ok(command.startsWith("exec "), "the engine should replace the shell, not sit under it"); +}); + +test("stripEnv unsets variables rather than blanking them", () => { + // tmux's own `-e KEY=` sets an empty value, and an empty ANTHROPIC_API_KEY is + // not the same as an absent one — claude reads the empty string as a key and + // abandons the subscription login it should have used. + const command = sessionCommand({ bin: "claude", args: [], stripEnv: ["ANTHROPIC_API_KEY"] }); + assert.match(command, /exec env '-u' 'ANTHROPIC_API_KEY'/); + assert.ok(!/ANTHROPIC_API_KEY=/.test(command), "must unset, never assign empty"); +}); + +test("a shell metacharacter in a binary path cannot escape the command", () => { + const command = sessionCommand({ bin: "/opt/my agent/bin/claude", args: ["$(id)"] }); + assert.match(command, /'\/opt\/my agent\/bin\/claude'/); + assert.match(command, /'\$\(id\)'/, "command substitution must stay literal"); +}); + +test("the start plan keeps finished sessions readable", () => { + const plan = tmuxStartPlan({ name: "api", cwd: "/x", command: "exec claude" }); + assert.deepEqual(plan.slice(0, 2), ["-f", "/dev/null"], "moshcode's server, not the user's config"); + assert.ok(plan.includes("-d"), "the session must start detached — that is the whole point"); + // Without remain-on-exit the pane vanishes when the agent finishes, and the + // roster can only ever say "gone" where it should say "done". + assert.ok(plan.includes("remain-on-exit") && plan.includes("on")); +}); + +test("the session and its remain-on-exit are set in one tmux call", () => { + // Two calls is a race: a command that finishes fast is gone before the second + // process starts, and the option lands on a session that no longer exists — + // which is exactly how a finished agent came to report `gone` instead of + // `done`. + const plan = tmuxStartPlan({ name: "api", cwd: "/x", command: "exec true" }); + const separator = plan.indexOf(";"); + assert.ok(separator > 0, "tmux takes `;` as its own argument to mean `and then`"); + assert.ok(plan.slice(0, separator).includes("new-session")); + assert.ok(plan.slice(separator).includes("remain-on-exit")); +}); + +/* ----------------------------------------------------------- the manifest */ + +test("the manifest is written owner-only", () => { + withHerdDir(() => { + rememberSession("api", { engine: "claude", cwd: "/x", args: ["--token", "sk-secret"] }); + const file = path.join(process.env.MOSHCODE_HERD_DIR, "sessions.json"); + // It records the argv an engine was launched with, and engine argv + // regularly carries a token. Same reasoning as .moshcode_history. + assert.equal(fs.statSync(file).mode & 0o777, 0o600); + }); +}); + +test("an existing manifest is tightened on write, not just on create", () => { + withHerdDir((dir) => { + const file = path.join(dir, "sessions.json"); + fs.writeFileSync(file, JSON.stringify({ version: 1, sessions: {} }), { mode: 0o644 }); + rememberSession("api", { engine: "claude" }); + assert.equal(fs.statSync(file).mode & 0o777, 0o600, "installs predating this must get fixed too"); + }); +}); + +test("a corrupt manifest reads as an empty herd instead of throwing", () => { + withHerdDir((dir) => { + fs.writeFileSync(path.join(dir, "sessions.json"), "{not json"); + // The live substrate is the authority on what is running; losing the + // metadata must never take down the roster that reads it. + assert.deepEqual(readManifest(), { version: 1, sessions: {} }); + }); +}); + +test("remembering merges rather than replacing", () => { + withHerdDir(() => { + rememberSession("api", { engine: "claude", cwd: "/x" }); + rememberSession("api", { agent: true }); + assert.deepEqual(readManifest().sessions.api, { engine: "claude", cwd: "/x", agent: true }); + }); +}); + +test("forgetting is idempotent and scoped to one session", () => { + withHerdDir(() => { + writeManifest({ sessions: { a: { engine: "claude" }, b: { engine: "codex" } } }); + assert.equal(forgetSession("a"), true); + assert.equal(forgetSession("a"), false, "a second forget is not an error"); + assert.deepEqual(Object.keys(readManifest().sessions), ["b"]); + }); +}); + +/* ------------------------------------------------------ capability detection */ + +test("MOSHCODE_HERD=off degrades instead of failing", () => { + // R2: moshcode must not harden a soft dependency into a hard one. This is + // also how the no-tmux path gets exercised on a box that has tmux. + const previous = process.env.MOSHCODE_HERD; + process.env.MOSHCODE_HERD = "off"; + try { + assert.equal(detectSubstrate({ force: true }), null); + assert.match(substrateNote(null), /foreground/, "the note has to say what is lost"); + } finally { + if (previous === undefined) delete process.env.MOSHCODE_HERD; + else process.env.MOSHCODE_HERD = previous; + resetSubstrate(); + } +}); + +test("tmux is preferred, and its absence falls through rather than erroring", () => { + const runner = (cmd) => (cmd === "tmux" + ? { error: Object.assign(new Error("ENOENT"), { code: "ENOENT" }) } + : { status: 0, stdout: "util-linux" }); + const substrate = detectSubstrate({ force: true, runner, env: {} }); + assert.ok(substrate === "pty" || substrate === null, "no tmux must not throw"); +}); + +test("a tmux that answers -V wins", () => { + const runner = (cmd) => (cmd === "tmux" ? { status: 0, stdout: "tmux 3.4" } : { status: 1 }); + assert.equal(detectSubstrate({ force: true, runner, env: {} }), "tmux"); +}); + +test("the substrate note names a fix rather than only a problem", () => { + assert.equal(substrateNote("tmux"), null, "nothing to say when everything works"); + assert.match(substrateNote("pty"), /tmux/, "say what would make it better"); + assert.match(substrateNote(null), /install tmux|apt|brew/, "and how to get it"); +});