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");
+});