Skip to content

feat(herd): sessions that outlive your terminal (PRD 0009) - #342

Merged
ralyodio merged 2 commits into
mainfrom
herd-runtime
Aug 9, 2026
Merged

feat(herd): sessions that outlive your terminal (PRD 0009)#342
ralyodio merged 2 commits into
mainfrom
herd-runtime

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Implements PRD 0009.

The change

moshcode start claude -d runs the engine in a runtime that outlives the pit and hands your prompt straight back. The pit stops being a one-thing-at-a-time shell:

moshcode start claude -d --name api    # prompt returns immediately
moshcode ps                            # who is running, and who wants you
moshcode attach api                    # step in; Ctrl-b d steps back out

Close the terminal, drop the SSH link, come back tomorrow — moshcode ps still answers. In the pit: /ps, /attach, /kill, and the roster prints on the way in.

Opt-in throughout. moshcode start claude with no -d behaves exactly as it does today.

Two substrates, because "requires tmux" was worth removing rather than documenting

tmux one 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. attach replays the transcript and relays keystrokes. Sized from inside via stty; a later resize cannot reach it.
neither foreground passthrough exactly as today, said once, with the command that would fix it.

MOSHCODE_HERD=pty forces the fallback, which is how it gets tested on a box that has tmux.

Semantic state

working / blocked / done / idle / unknown, with herdr's rule that each session has exactly 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 — a test enforces that — and are overridable in ~/.moshcode/herd/rules.json when they rot.

blocked can page you through the existing notify()/ask() fan-out, with your reply typed back into the session that was waiting. Only transitions notify, so a session sitting blocked doesn't page every five seconds.

One surface for humans and agents

Every verb takes --json. wait exits 0 matched / 2 timed out / 3 gone so scripts can branch. moshscript gets herdStart / herdPrompt / herdWait / herdRead / herdList / herdKill as values rather than exit codes, which is what makes fan-out practical:

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

Two bugs the survival test caught

Both would have shipped as "finished agents report gone":

  • tmux's remain-on-exit was set in a second call, which a fast command finishes before — now one invocation using tmux's ; argument.
  • the pty substrate couldn't tell a finished agent from a rebooted box (both are a dead pid) — now the session's own shell records its exit code on the way out.

Naming

herd, not the runtime / agent <verb> the PRD first proposed. agent is already a registered alias of agents and test/help.test.mjs pins suggest("agent") === "agents"; runtime is what src/runtime.mjs already means. The five most-used verbs are top-level anyway. PRD updated to record the change and why.

Verification

  • 1456 tests pass, 0 fail (up from 1394 — 62 new).
  • Integration tests start a session in one process, exit that process, and talk to the session from another — run against both substrates, each skipping itself when the machine can't provide it.
  • Live-checked against real Claude Code and Codex: detached start, immediate prompt return, send-keys, read, kill. Both engines' first-run prompts were detected as blocked — true positives. Codex's selector glyph came out of that run and is now in the rules with a test.

Deferred, and stated in the PRD

Auto-installing the status hook into each engine's config (the protocol ships and works, so tier 1 is opt-in for now); pointing console.mjs's ttyd at a real session; scrollback replay across reboots (P2, opt-in by design).

🤖 Generated with Claude Code

…ys 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>
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

91 finding(s)

HIGH/CRITICAL: 2 | MEDIUM: 41 | LOW: 48

Severity Rule Location
HIGH manifest-typosquat apps/pwa/package.json:19
HIGH js-ssrf-outbound-request apps/pwa/public/sw.js:45
MEDIUM tls-verification-disabled apps/pwa/src/lib/moshpit-gateway.mjs:299
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:61
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:75
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:101
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:265
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:269
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:314
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:499
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:675
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:677
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:736
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:782
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:852
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:955
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1063
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1199
MEDIUM js-unescaped-html-sink apps/pwa/src/routes/moshpit.mjs:1419
MEDIUM js-dynamic-code-execution apps/pwa/test/apikey-mask.test.mjs:129
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:111
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:131
MEDIUM sql-template-interpolation apps/pwa/test/moshpit-terms.test.mjs:192
MEDIUM sql-template-interpolation src/dns.mjs:2439
MEDIUM sql-template-interpolation src/selfupdate.mjs:166
MEDIUM sql-template-interpolation src/selfupdate.mjs:170
MEDIUM sql-template-interpolation src/selfupdate.mjs:208
MEDIUM sql-template-interpolation src/selfupdate.mjs:209
MEDIUM insecure-temp-file test/dns-disable-restore.test.mjs:93
MEDIUM insecure-temp-file test/dns-disable-restore.test.mjs:310
MEDIUM insecure-temp-file test/plugins.test.mjs:152
MEDIUM insecure-temp-file test/pty.test.mjs:28
MEDIUM insecure-temp-file test/pty.test.mjs:31
MEDIUM insecure-temp-file test/pty.test.mjs:40
MEDIUM insecure-temp-file test/pty.test.mjs:42
MEDIUM insecure-temp-file test/pty.test.mjs:47
MEDIUM insecure-temp-file test/pty.test.mjs:48
MEDIUM insecure-temp-file test/pty.test.mjs:49
MEDIUM insecure-temp-file test/tabs.test.mjs:8
MEDIUM insecure-temp-file test/tabs.test.mjs:13
MEDIUM insecure-temp-file test/tabs.test.mjs:14
MEDIUM insecure-temp-file test/tabs.test.mjs:22
MEDIUM insecure-temp-file test/trust.test.mjs:240
LOW secret-generic-credential apps/pwa/test/apikey-bearer-scheme.test.mjs:30
LOW secret-generic-credential apps/pwa/test/apikey-mask.test.mjs:38
LOW secret-generic-credential apps/pwa/test/apikey-reveal.test.mjs:35
LOW secret-generic-credential apps/pwa/test/approvals-context.test.mjs:28
LOW secret-generic-credential apps/pwa/test/approvals-credits.test.mjs:28
LOW secret-generic-credential apps/pwa/test/approvals-notify.test.mjs:26
LOW secret-generic-credential apps/pwa/test/approvals-resolve-race.test.mjs:20

…and 41 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio marked this pull request as ready for review August 9, 2026 17:07
…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>
@ralyodio
ralyodio merged commit 7762702 into main Aug 9, 2026
4 checks passed
@ralyodio ralyodio mentioned this pull request Aug 9, 2026
@ralyodio
ralyodio deleted the herd-runtime branch August 9, 2026 18:05
ralyodio added a commit that referenced this pull request Aug 9, 2026
Bump to v0.33.0, releasing the herd (#342) — agent sessions that outlive
the terminal that started them — along with the PRD behind it (#341) and
the moshpit pinned-TLS proxy fix (#343), all of which have been sitting on
main unreleased.

Minor rather than patch: #342 adds six commands (herd, ps, attach, kill,
wait, restore) and six moshscript verbs, and changes none of the existing
ones. `moshcode start claude` with no -d behaves exactly as it did.

This release is what makes any of it reachable. install.sh serves the
latest release tarball rather than main, so until a release carries it
every installed machine answers `unknown command "ps"` — and the npm
channel only moves when publish.yml sees a published GitHub release.

No plugin bumps: stocks and crypto are untouched, and neither names a
command that moved.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant