Skip to content

Commit 7762702

Browse files
ralyodioclaude
andauthored
feat(herd): sessions that outlive your terminal (PRD 0009) (#342)
* feat(herd): sessions that outlive your terminal, and a roster that says which one wants you Implements PRD 0009. `moshcode start claude -d` now runs the engine in a runtime that outlives the pit and hands the prompt straight back, so the pit stops being a one-thing-at-a-time shell: `/ps` shows what is running and what state each session is in, `/attach` steps into one, detaching leaves it running. Two substrates behind one interface, because "requires tmux" was the wart worth removing rather than documenting: tmux a single named server (socket `moshcode`, not tabs.mjs's per-pid one). Full fidelity — resizing, scrollback, native attach. pty no tmux: script(1) allocates the pty, the child is detached with its stdin on a FIFO held O_RDWR so it never sees EOF when the pit exits, and attach replays the transcript and relays keystrokes. Sized from inside via stty; a later resize cannot reach it. none foreground passthrough, exactly as today, said once. Semantic state (working/blocked/done/idle/unknown) with herdr's rule that each session has ONE authority: a live hook report suppresses screen classification entirely, and expires so a crashed agent cannot read `working` forever. Screen rules ship beside each engine's install spec, are anchored to things a terminal draws rather than English words, and are overridable in ~/.moshcode/herd/rules.json when they rot. `blocked` can page the operator through the existing notify()/ask() fan-out, with the reply typed back into the session that was waiting — the part herdr structurally cannot do. Only transitions notify, so a session sitting blocked does not page every five seconds. One surface for humans and agents: every verb takes --json, `wait` exits 0/2/3 so scripts can branch, and moshscript gets herdStart/herdPrompt/ herdWait/herdRead/herdList/herdKill as values rather than exit codes. Two bugs the survival test caught, both of which would have shipped as "finished agents report gone": tmux's remain-on-exit was set in a second call that a fast command could beat, now one invocation via tmux's `;`; and the pty substrate could not tell a finished agent from a rebooted box, now the session's own shell records its exit. Named `herd`, not the `runtime`/`agent <verb>` the PRD first proposed — `agent` is already an alias of `agents` and a test pins that, and `runtime` is what src/runtime.mjs already means. PRD updated to match. Deferred and stated in the PRD: auto-installing the status hook into each engine (the protocol ships and works), pointing ttyd at a real session, and scrollback replay across reboots. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(herd): stop `wait` exiting instantly — an unref'd poll timer let node exit under it CI caught this; a green local suite did not. Thirteen tests in herd-cli.test.mjs came back `cancelledByParent`, which was the test runner's way of reporting that the file's process had died mid-run. The cause is a real bug in shipped behaviour, not a test artifact. The poll timer in waitFor() was unref'd, copying the instinct from mirror.mjs where a background nicety must never hold the process open. Here that is exactly backwards: `wait` and `watch` exist to keep the process alive. An unref'd timer means node finds nothing scheduled between polls and exits. It does not hang — it is worse. `moshcode wait api --timeout 1h` returned in a millisecond, exit 0, having waited for nothing, and any script branching on it would have read that as "the agent is ready". Measured before: waitFor resolved never and the process exited after 1ms. After: it waits the full duration and settles. End to end, `moshcode wait` now blocks the whole timeout and exits 2, exits 0 the moment the state matches, and exits 3 for a session that does not exist. Also un-unref's the liveness poller in the pty substrate's attach, which was the same trap one step from being sprung: the follow timer beside it is already unref'd, so an attach whose stdin did not hold the loop open would have exited the instant it started. It is cleared on detach, so it never outlives the attach either. The regression test runs waitFor in its own process with nothing else pending, because the test runner itself keeps the event loop alive and hides the bug completely. Verified it fails against the old code before keeping it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7f576a2 commit 7762702

16 files changed

Lines changed: 2922 additions & 19 deletions

README.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ or miss one that does. A test fails the build when it drifts.
2828
|---|---|---|
2929
| `moshcode agents` | engines | list engines or launch one autonomously |
3030
| `moshcode start` | engines | launch an engine with its native defaults |
31+
| `moshcode herd` | runtime | run agent sessions that outlive this terminal |
32+
| `moshcode ps` | runtime | list herd sessions and what each one is doing |
33+
| `moshcode attach` | runtime | attach this terminal to a herd session |
34+
| `moshcode kill` | runtime | end a herd session |
35+
| `moshcode wait` | runtime | block until a session is blocked, done, or idle |
36+
| `moshcode restore` | runtime | rebuild the herd's sessions after a reboot |
3137
| `moshcode install` | engines | install an engine or workflow tool |
3238
| `moshcode uninstall` <br>`remove` | engines | take an engine or workflow tool off this machine |
3339
| `moshcode upgrade` <br>`update` | engines | update moshcode, engines, or tools |
@@ -102,6 +108,103 @@ is shorthand for `moshcode start claude`. In the TUI, use `/agents <engine>` for
102108
autonomous mode or `/start <engine>` for raw mode. Running `moshcode agents` or
103109
`/agents` without an engine still lists engines and their install status.
104110

111+
## The herd — sessions that outlive your terminal
112+
113+
Every launch above hands an engine the whole terminal and waits. That is why
114+
they feel native, and it is also why the pit can only do one thing at a time and
115+
why closing the lid kills the work.
116+
117+
The herd inverts it. Add `-d` and the session runs in a runtime that outlives
118+
the pit, so you get your prompt back immediately:
119+
120+
```sh
121+
moshcode start claude -d --name api # runs in the background, prompt returns
122+
moshcode agents codex -d # autonomous, and still detached
123+
moshcode ps # who is running, and who wants you
124+
moshcode attach api # step in; Ctrl-b d steps back out
125+
moshcode kill api # end it
126+
```
127+
128+
Close the terminal, drop the SSH link, come back tomorrow — `moshcode ps` still
129+
answers, and `moshcode attach` puts you back inside. In the pit the same verbs
130+
are `/ps`, `/attach`, `/kill`, and the roster prints on the way in.
131+
132+
### Which one needs you
133+
134+
Every session carries a state: `working`, `blocked`, `done`, `idle`, or
135+
`unknown`. `blocked` means a human decision is the only thing missing.
136+
137+
```
138+
api claude blocked ~/src/coinpay 12m
139+
web codex working ~/src/ugig.net 4m
140+
audit opencode done ~/src/moshpit-dns 1h
141+
```
142+
143+
State comes from one authority per session, never two. An engine that reports
144+
through a lifecycle hook (`moshcode herd report <name> <state>`) is believed and
145+
its screen is not second-guessed; everything else is classified from the bottom
146+
of its screen. Nothing recognisable reads `unknown`, which is a safe answer —
147+
detection never gates a launch. Patterns that go stale can be fixed in
148+
`~/.moshcode/herd/rules.json` without waiting for a release.
149+
150+
Blocked can also come and find you, using the same notification fan-out as
151+
`notify()`/`ask()`:
152+
153+
```sh
154+
moshcode herd notify on --ask # email/SMS/Slack/Telegram/push
155+
moshcode herd start claude --name watch # then run `moshcode herd watch` in the herd
156+
```
157+
158+
With `--ask`, whatever you reply is typed into the session that was waiting.
159+
160+
### Driving it from a script or another agent
161+
162+
There is no second API — every verb takes `--json`, and that is what a machine
163+
reads. `wait` exists to be branched on: exit `0` matched, `2` timed out, `3` no
164+
such session.
165+
166+
```sh
167+
moshcode herd start claude --name api --json
168+
moshcode herd prompt api "port the auth routes" --wait
169+
moshcode herd read api --lines 40
170+
moshcode wait api --state blocked --timeout 1h
171+
```
172+
173+
moshscript gets the same surface as values rather than exit codes, which is what
174+
makes fan-out practical:
175+
176+
```js
177+
herdStart("claude", { name: "api" });
178+
herdStart("codex", { name: "web" });
179+
herdPrompt("api", "port the auth routes");
180+
herdPrompt("web", "port the dashboard");
181+
await herdWait("api"); await herdWait("web");
182+
say(herdRead("api", { lines: 20 }));
183+
```
184+
185+
### After a reboot
186+
187+
```sh
188+
moshcode restore --dry-run # what would come back
189+
moshcode restore --resume # and ask each engine to reopen its conversation
190+
```
191+
192+
This brings back the *shape* — the sessions, in their directories, on their
193+
engines. The processes are new. Work that was in flight is not still running,
194+
and `--resume` only reaches engines that have a resume flag of their own.
195+
196+
### What it runs on
197+
198+
`tmux` when the box has it: real resizing, scrollback, native attach. Without
199+
tmux, sessions run under `script(1)` with their input on a FIFO — they work and
200+
they persist, but their size is fixed when they start. With neither, launches
201+
stay in the foreground and say so once. moshcode does not turn a soft dependency
202+
into a hard one, so `-d` never fails; at worst it degrades and tells you what
203+
would fix it.
204+
205+
The session manifest and every transcript are written `0600`: engine argv and
206+
engine output both carry secrets.
207+
105208
### Parallel pit tabs
106209

107210
At the mosh prompt, `/new` opens and switches to another independent moshcode

bin/moshcode.mjs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import { createPrd, listPrds, authoringPrompt } from "../src/prd.mjs";
2828
import { loginAuto, whoami, logout } from "../src/auth.mjs";
2929
import { tui } from "../src/tui.mjs";
3030
import { consoleCommand } from "../src/console.mjs";
31+
import { herdCommand, herdStart, splitDetachArgs } from "../src/herd-cli.mjs";
32+
import { detectSubstrate, substrateNote } from "../src/herd.mjs";
3133
import { dnsCommand } from "../src/dns.mjs";
3234
import { templateCommand } from "../src/templates.mjs";
3335
import { serveCommand } from "../src/serve.mjs";
@@ -117,6 +119,23 @@ function printEngineStatus(json = false) {
117119
}
118120

119121
async function launchEngine(key, engine, args, { agentMode = false } = {}) {
122+
const { detach, name, rest: engineArgs } = splitDetachArgs(args);
123+
if (detach) {
124+
const substrate = detectSubstrate();
125+
if (substrate) {
126+
const code = herdStart([
127+
key, ...(name ? ["--name", name] : []), ...(agentMode ? ["--agent"] : []), ...engineArgs,
128+
]);
129+
if (code) process.exitCode = code;
130+
if (!process.stdin.isTTY || process.env.MOSHCODE_NESTED === "1") return;
131+
return tui();
132+
}
133+
// R2: degrade, loudly, once — and then still do the thing that was asked
134+
// for. A launch that refuses because the box has no tmux would be a worse
135+
// answer than a launch that works and ends with this terminal.
136+
console.error(`⚠ ${substrateNote(null)}`);
137+
}
138+
args = engineArgs;
120139
if (agentMode) {
121140
const note = `agent mode: ${key} ${agentLaunchArgs(engine).join(" ")}`;
122141
console.error(engine.agentsView
@@ -314,6 +333,18 @@ async function main() {
314333
const [key, engine] = resolved;
315334
return launchEngine(key, engine, rest.slice(1));
316335
}
336+
// The herd (PRD 0009). `herd` is the namespace; the five verbs people reach
337+
// for most often are also top-level, because `moshcode ps` is what someone
338+
// types when they want to know what is running and nobody should have to
339+
// learn a namespace to ask that.
340+
if (cmd === "herd") {
341+
process.exitCode = (await herdCommand(rest)) || 0;
342+
return;
343+
}
344+
if (["ps", "attach", "kill", "wait", "restore"].includes(cmd)) {
345+
process.exitCode = (await herdCommand([cmd === "ps" ? "ps" : cmd, ...rest])) || 0;
346+
return;
347+
}
317348
if (cmd === "tools") {
318349
const asJson = rest.includes("--json");
319350
printStatus(toolStatus(), asJson);

prd/0009-persistent-agent-runtime.md

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
openprd: "0.2"
33
id: "0009"
44
title: "Keep the herd alive — a persistent runtime, semantic agent state, and one control surface for humans and agents"
5-
status: Draft
5+
status: Accepted
66
authors:
77
- anthony@profullstack.com
88
created: 2026-08-09
99
updated: 2026-08-09
1010
repo: https://github.com/moshcoder/moshcode
11-
discussion:
12-
implementation:
11+
discussion: https://github.com/moshcoder/moshcode/pull/341
12+
implementation: src/herd.mjs, src/herd-state.mjs, src/herd-cli.mjs
1313
tags: [runtime, sessions, agents, tui, notify]
1414
supersedes:
1515
superseded-by:
@@ -203,17 +203,26 @@ entries in a new `runtime` group:
203203

204204
| command | what it does |
205205
|---|---|
206-
| `moshcode runtime` | start / inspect / stop the background runtime |
207-
| `moshcode ps` | list live sessions with state |
206+
| `moshcode herd` | the namespace: status, start, prompt, read, send-keys, report, notify, watch, prune, stop |
207+
| `moshcode ps` | list sessions with state |
208208
| `moshcode attach <name>` | attach to a session |
209209
| `moshcode kill <name>` | end a session |
210210
| `moshcode wait <name>` | block until a state transition |
211211
| `moshcode restore` | rebuild sessions from the manifest |
212-
| `moshcode agent <verb>` | start / prompt / read / send-keys / stop |
213212

214-
TUI equivalents follow the existing convention: `/ps`, `/attach <name>`,
215-
`/kill <name>`, `/restore`. `/agents <engine>` keeps its meaning and simply
216-
gains `--name` and a detachable session underneath it.
213+
The namespace is `herd`, not the `runtime` / `agent <verb>` this document first
214+
proposed. Two reasons, both found while building it. `agent` is already a
215+
registered alias of `agents` in `PIT_COMMANDS`, and a test pins
216+
`suggest("agent") === "agents"` — so `moshcode agent start` would have meant two
217+
different things depending on where it was typed. And `runtime` is what
218+
`src/runtime.mjs` already calls the moshscript interpreter. The five verbs
219+
people reach for most are top-level anyway, which is what the original table was
220+
really asking for: nobody should have to learn a namespace to ask what is
221+
running.
222+
223+
TUI equivalents follow the existing convention: `/herd`, `/ps`, `/attach <name>`,
224+
`/kill <name>`, `/wait`, `/restore`. `/agents <engine>` and `/start <engine>`
225+
keep their meaning and simply gain `-d` / `--name`.
217226

218227
**The pit's front door changes.** Today `moshcode` prints a banner and a prompt.
219228
With anything running it prints the herd first:
@@ -301,3 +310,53 @@ exactly like today. No repeated nagging, no failure.
301310
- **Scope.** Phases 1–3 are independently shippable and should ship that way.
302311
Phase 1 alone — sessions that survive the terminal — is the bulk of the value
303312
and does not require a single line of state detection.
313+
314+
## Implementation Notes
315+
316+
Written after the build, so the document and the code agree.
317+
318+
**A second substrate, which this PRD did not ask for.** R2 promised only to
319+
degrade gracefully without tmux. That was not good enough: `/new` already
320+
required tmux and it is the wart people notice. So there are two substrates
321+
behind one interface — tmux when the box has it, and otherwise `script(1)` with
322+
the session's stdin on a FIFO, reusing the capability detection `pty.mjs`
323+
already does. The FIFO is opened `O_RDWR` before the spawn so the child is its
324+
own writer and never sees EOF when the pit exits, which is the whole trick. Its
325+
one real limit: nothing outside a pty can ioctl its master, so the size is fixed
326+
at launch (set from inside by `stty`) and a later resize does not reach it.
327+
`MOSHCODE_HERD=pty` forces it, which is how the fallback is tested on a box that
328+
has tmux.
329+
330+
**Two bugs the survival test caught**, both of which would have shipped as
331+
"finished agents report `gone`". tmux's `remain-on-exit` was being set in a
332+
second call, and a fast command finishes before that process starts — fixed by
333+
making the session and its option one invocation using tmux's `;` argument. And
334+
the pty substrate could not tell "the agent finished" from "the box rebooted",
335+
since both are a dead pid — fixed by having the session's own shell record its
336+
exit code on the way out.
337+
338+
**Delivered:** R1–R12 and R14. Both substrates are covered by an integration
339+
test that starts a session in one process, exits it, and talks to the session
340+
from another.
341+
342+
**Not delivered, deliberately:**
343+
344+
- **R7 tier-1 hook installation.** The protocol ships and works —
345+
`moshcode herd report <name> <state>` takes authority, suppresses screen
346+
classification entirely while it is live, and expires so a crashed agent
347+
cannot read `working` forever. What is not built is auto-installing that call
348+
into each engine's hook config via the `plugins.mjs` / `skills.mjs` fan-out.
349+
Until then tier 1 is opt-in and tier 2 carries the roster.
350+
- **R13, the browser as a real client.** `console.mjs` still points ttyd at a
351+
shell rather than at `moshcode attach <name>`, and `mirror.mjs` keeps its
352+
documented blind spot. The runtime it would attach to now exists, so this is a
353+
small follow-up rather than a design question.
354+
- **R15, scrollback replay.** P2 and opt-in in this document; still the right
355+
call not to write engine output across a reboot by default.
356+
357+
**Rules will rot, and that is planned for.** The shipped patterns are
358+
conservative and anchored to things a terminal draws — brackets, selectors, line
359+
anchors — never bare English words, and a test asserts that. `unknown` is
360+
common and safe. `~/.moshcode/herd/rules.json` lets a rotted pattern be fixed on
361+
the box it rots on, and a malformed entry there loses that pattern rather than
362+
the file.

prd/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,5 @@ Start one with `moshcode prd "<idea>"` (TUI: `/prd`).
2424
| [0006](0006-help.md) | --help | Draft |
2525
| [0007](0007-profullstack-site-init.md) | Generate batteries-included Profullstack sites for Moshpit names | Draft |
2626
| [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 |
27-
| [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 |
27+
| [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 |
2828
<!-- PRD-INDEX:END -->

0 commit comments

Comments
 (0)