Run a fully headless relay
m8shift.py is a passive coordinator. wait blocks a process; it cannot wake an interactive agent UI. The only path to a hands-off relay is to drive a headless agent CLI in a loop — for example claude -p "<prompt>" or codex exec "<prompt>" — where each invocation performs exactly one turn (claim → work → append).
The command names are examples. Use the equivalent Gemini, Vibe, or other cooperative agent CLI as long as each invocation performs exactly one M8Shift turn.
The repository ships a reference loop, examples/headless_runner.py, that runs one agent. Run one instance per headless agent; if the other side is an interactive UI, a human still resumes that side.
examples/headless_runner.py claude \
--cmd claude -p "Apply M8SHIFT.protocol.md: take your turn (claim, work, append)." \
--start-on-idle --interval 30 --max-retries 3For supervised automation or tests, add --once: the runner executes one eligible turn and exits. Each launched turn receives M8SHIFT_RUN_ID in the child environment and appends lifecycle events to .m8shift/runtime/runs.jsonl. If your agent appends --field x_run_id=$M8SHIFT_RUN_ID, the runtime event and the M8Shift turn can be correlated without changing the core lock.
Hardened v3.26 runner behavior
The reference runner also writes an immutable local run plan, verifies the post-run core LOCK instead of trusting process exit status, and leaves recovery to the normal M8Shift rules. Runtime events remain advisory sidecars.
🟣 claim & run agent · ⚪ wait · 🟢 done → stop · 🔴 leave pen for manual recovery
What a naïve while wait; do … loop gets wrong
The reference runner exists because the obvious loop has three bugs:
waitreturns0for both "your turn" andDONE. A naïve loop relaunches the agent forever once the relay is finished. The runner reads the lockstatedirectly instead.- Two agents both starting from
IDLE. A single designated starter (--start-on-idle) breaks the tie. - A crashed turn. If the agent exits while the pen is still
WORKING_<me>(it claimed then died withoutappend), that is a crash → retry up to a cap, then stop and leave the pen for manual recovery. The runner never force-steals the pen. - A long turn. If a single turn can run past the 30-minute TTL, the wrapper should re-run
python3 m8shift.py claim <me>periodically to refreshexpires— a manual heartbeat; M8Shift never refreshes the lock for you. - No auditable runtime id. The runner emits
M8SHIFT_RUN_IDandruns.jsonlso humans can correlate a process run with the turn the agent eventually appends. - Mutable plan drift. The runner stores a run plan before launch so later reports can compare what was intended with what happened.
- Exit status confusion. The runner checks the
LOCKafter the child exits; a zero process exit is not considered proof that the relay advanced.
It also uses bounded backoff and a static argv (no shell evaluation of the agent command).
The status-guard and interactive honesty (v3.45)
The runner above is the headless half of the story. The other half is what an interactive agent must admit: a chat UI agent is resumed by a human between turns — wait blocks a process, it does not wake a chat window. An interactive agent that says "I'm waiting on the relay" is describing an intention, not a running loop.
Since v3.45 that honesty is enforced, not just documented:
- Status-guard — now in the generated protocol core (every anchor and
M8SHIFT.protocol.md, regenerated byinit): never claim you hold the pen or reachedDONEfrom memory. Re-runstatus --for <agent>before ending a turn or asserting relay state — a checkpoint from earlier in the turn is already stale by the time you answer. - Interactive honesty (agents-guide): when the relay is not
DONEand no headless runner is active, say so plainly — the agent does not stay in autonomous listening in a chat interface, and it will re-readstatusat the next resume before acting. Do not imply a loop that is not running.
To keep several open terminals distinguishable, status and watch also surface the project name, cwd, and relay root in their header (human output, --json, and the watch banner). The label prefers the operator's init --name, falling back to the relay-root folder name.
Shipped in v3.46.0 — runner final-state enforcement (RFC 047 Phase A)
Since v3.46.0 the reference runner enforces final-state honesty: after each provider turn it re-reads the relay and only counts the run as a success if the relay is DONE or the agent actually authored a turn above the pre-run turn — a provider that exits 0 while the relay still awaits it is non_completion, retried with backoff. Long turns heartbeat with claim <agent> --refresh (refused unless the agent already holds its own WORKING lock), never a plain claim. See the runner exit codes.
Since v3.47.0 the listener lifecycle companion is shipped: python3 m8shift-runtime.py listener start --agent <name> --cmd-file <profile> runs a supervised headless lane — zero model spend while polling, exactly one bounded runner turn per wake, visible HALTED after the retry budget, OS service backends (launchd/systemd/schtasks) with safe local fallbacks, and nine listener.* doctor findings. See the runtime module reference.
When to use it
- Cron jobs, CI steps, or any unattended automation.
- A headless ↔ headless relay (both sides automated).
- A headless ↔ interactive mix, where one side is a CLI and the other a human-driven UI.
For interactive editor sessions, use the VS Code guide instead.