Skip to content

Commit 8e9bb3c

Browse files
ralyodioclaude
andcommitted
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>
1 parent a947b98 commit 8e9bb3c

16 files changed

Lines changed: 2874 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)