Repository navigation
Description
User story
As the owner, I want a headless agent to keep running when the ssh link drops or relay restarts, so that losing a connection never loses the agent.
Acceptance criteria
- A headless session on a Unix host survives a dropped ssh link, with no host setting.
- Moved to Local headless sessions run under the file-channel runner and survive a relay restart #536: a headless local session survives a relay restart.
- A send returns success only after the message moves to
sent/. - A reader reconnecting at a saved cursor receives each line once, in order.
- Moved to Codex and pi sessions run under the file-channel runner #537: codex and pi sessions run under the same runner. This issue moves Claude.
- A host without tmux still runs headless sessions under the runner alone.
- The runner writes one normalized event line per state change: turn started, turn ended, asking, exited.
- The runner's protocol is the owner's
agenttool protocol, or replaces it. There is not a third implementation.
What I'd notice
A headless session on a host stays alive across a laptop sleep or a relay update.
Depends on
barelyworkingcode/relay526 (design note) merged.
Behaviour change: the detached runner becomes the default. The run report lists it as an intent entry.
Parent
#525. The design is in docs/session-adoption.md (relay child "Design note", below).
Tasks
- Planner: write the
## Contract. - Extract the detached host runner into a protocol-agnostic runner (
inbox/,sent/,out.jsonl,runner.json). - Make it the default for headless sessions on Unix, local and host; keep
ssh -Tonly as a fallback. - Settle the permission-hook ancestry question (design note, item 6) and record it here.
- Playwright or e2e tests from the criteria; update the feature map row. Verification task, last.
Contract
Planner contract, 2026-10-20. Base branch epic/525-session-adoption; head feat/528-runner. Design: docs/session-adoption.md.
Split
The issue needs three PRs. This issue is the first. The story does not change; two criteria move.
- relay#528 (this): the runner and its protocol, host Claude sessions on it by default, the
ssh -Tfallback, event lines, theagenttool decision. Proves criteria 1, 3, 4, 6, 7 and 8. - relay#536: local Claude sessions under the runner; a local session survives a relay restart (criterion 2). Records the permission-hook root (design note, vendor answer 6).
- relay#537: codex and pi sessions under the runner (criterion 5).
Merge order: One file-channel runner for local and host headless sessions #528, then Local headless sessions run under the file-channel runner and survive a relay restart #536, then Codex and pi sessions run under the file-channel runner #537. Codex and pi sessions run under the file-channel runner #537 goes before relay#531 (the pi adopter resumes pi under the runner).
Decisions
- The
agenttool (relay#525, question 7): reuse its protocol. Relay's runner speaks the owner'sagenttool's file channel. A sender writesinbox/.<id>.tmpand renames it intoinbox/. The runner writes messages in name order and moves each tosent/after the write. The move is the receipt.out.jsonlgets every stdout line. - Relay does not run the Python tool. It ships in no bundle, speaks Claude only, and needs python3, which a stock Mac host lacks. Relay's host runner must need only POSIX
sh. - The new runner replaces relay's own
in.jsonlrunner. Two programs then speak one protocol. There is no third implementation. - The runner does not use the tool's
state.json,meta.json, heartbeat,holdorstopfiles. Relay keeps session state in relay-sessions and stops by process group. relay#529 may addholdwith the tool's meaning. - One runner program for host and local:
internal/sessions/runner/runner.sh, POSIXshplusawk,tail,mkfifo,psandbase64. It needs no perl, python, tmux orsetsid(1). - The runner knows no protocol. It moves lines and matches two fixed strings the provider passes at start. It parses nothing else. Agent output bytes reach
out.jsonlunchanged. - Default on Unix hosts. A Claude session on a Unix host runs under the runner when
hosts[].detach_sessionsis absent ortrue. ssh -Tfallback.hosts[].detach_sessions: falseruns the session underssh -T. A Windows host always usesssh -T. A runner that fails to start ends the start with an error that names the host and the key. Relay never falls back on its own.- No tmux on the host path. The runner never calls tmux. A host without tmux runs headless sessions under the runner alone. tmux joins only in relay#529, to host a pane.
- Codex and pi are not in this PR. They move in relay#537.
- Event lines (relay#525, the eventing seam). The runner appends one line to
events.jsonlper state change:turn_started,turn_ended,asking,exited. "Transcript offset" is the byte offset inout.jsonl. The runner knows no agent's own transcript path; the Adopter (relay#527) maps one. - Event order. The runner appends an event line before the line that causes it reaches
out.jsonl.turn_startedgoes before the receipt.exitedgoes before theexitfile. A reader that saw the cause reads the event with no wait. - Old runs. A generation with
run.jsonand norunner.jsonreadslegacy. Relay-sessions stops a live legacy run at its startup attach or at the first resume, after the start-time check. The session reads dormant and resumes on its conversation. - Credentials (relay#525, question 6). Host sessions carry no relay credential and no Claude token, before and after. This PR changes no credential path. The local path is recorded in relay#536.
Run directory
Host: ~/.relay/agents/<session id>/<generation>/, mode 0700, as today. Local (relay#536): <config dir>/sessions/runners/<session id>/<generation>/.
| Entry | Holds |
|---|---|
inbox/ |
Pending messages. <id>.json starts a turn. <id>.ctl starts none (a control_response, a JSON-RPC call). Names starting with . are ignored. |
sent/ |
Each message after the runner wrote it to the agent's stdin: the receipt. |
out.jsonl |
The agent's stdout, appended unchanged. |
events.jsonl |
One event line per state change. |
runner.json |
The runner's state, rewritten by temp file and rename. |
stderr.log, exit, agent.sh |
As today; exit is {"code":N,"at":"RFC3339"}. |
Message id: <unix nanoseconds, 19 digits>-<6 lowercase hex>. A file holds one JSON value. The runner writes its bytes unchanged and adds a newline when it lacks one.
Wire shapes
runner.json:
{"v":1,"pid":4242,"pgid":4242,"started":"Tue Oct 20 14:03:07 2026","session":"<relay session id>","generation":"g1","delivered":"<id of the last message moved to sent/, or empty>"}started is ps -o lstart= of pid, spaces squeezed, as run.json has it today.
events.jsonl, one object per line:
{"v":1,"type":"turn_started","session":"<id>","generation":"g1","out":10422,"msg":"<message id>","at":"2026-10-20T14:03:07Z"}
{"v":1,"type":"turn_ended","session":"<id>","generation":"g1","out":18800,"at":"2026-10-20T14:03:19Z"}
{"v":1,"type":"asking","session":"<id>","generation":"g1","out":15011,"at":"2026-10-20T14:03:12Z"}
{"v":1,"type":"exited","session":"<id>","generation":"g1","out":19004,"code":0,"at":"2026-10-20T14:03:30Z"}out is always a line start: the start of the line that caused the event; for turn_started, the size of out.jsonl at the write; for exited, its final size. session is relay's session id. A runner ended by TERM writes exited with the agent's code; one ended by KILL writes none.
Interfaces
// package runner (internal/sessions/runner), new.
//go:embed runner.sh
var Script string
const (
InboxDir = "inbox"; SentDir = "sent"; OutFile = "out.jsonl"; EventsFile = "events.jsonl"
StateFile = "runner.json"; ExitFile = "exit"; StderrFile = "stderr.log"
)
type MessageKind string
const (
MessageTurn MessageKind = "json" // starts a turn; emits turn_started
MessageControl MessageKind = "ctl" // starts no turn
)
// NewMessageID returns "<unix ns, 19 digits>-<6 lowercase hex>".
func NewMessageID(now time.Time) string
// Patterns are fixed strings the runner looks for in each stdout line. Empty never matches.
type Patterns struct {
TurnEnded string // claude: `"type":"result"`
Asking string // claude on a host: `"subtype":"can_use_tool"`
}// package sshhost (internal/sshhost/agentrun.go), changed signatures.
func RunnerStartIn(hostOS, dir string, d RunDir, argv []string, env map[string]string, p runner.Patterns) string
// RunnerSend writes stdin's line as inbox/<id>.<kind> and exits 0 only once it is in sent/.
// Exit 5: no run, or the runner is gone (the file is removed). Exit 7: no receipt within 25 s
// while the runner lives; the file stays queued.
func RunnerSend(hostOS string, d RunDir, id string, kind runner.MessageKind) string
const RunLegacy RunPhase = "legacy" // RunnerStatus: a live runner from before runner.jsonRelay maps exit 5 to "the agent is not running", exit 7 and an ambiguous ssh exit to ErrSendUnknown. The ssh command's own timeout stays detachedSendTimeout (30 s), above the 25 s receipt wait.
// package config (internal/config/models.go)
DetachSessions *bool `json:"detach_sessions,omitempty"` // nil or true: runner; false: ssh -TbuildHostSpec (cmd/relay/session_launch.go) sets HostSpec.Detach to (DetachSessions == nil || *DetachSessions) && !sshhost.IsWindowsOS(os).
Paths
- Simple: nothing to set. eve shows the host session as before. A dropped link or a relay update does not end it.
- Power:
hosts[].detach_sessions: falseinsettings.jsonruns that host's sessions underssh -T.relay session events --after <cursor>reads the agent's lines.
File ownership
| Task | Owns |
|---|---|
| T1 dev | internal/sessions/runner/runner.sh (new), internal/sessions/runner/runner.go (new), internal/sshhost/agentrun.go, internal/sessions/provider/claude_detached.go, internal/sessions/provider/claude.go (host branch, only if the patterns need it), internal/config/models.go, cmd/relay/session_launch.go, docs/ssh-hosts.md, docs/session-host.md, docs/session-adoption.md, docs/THREAT-MODEL.md, docs/cli.md, docs/FEATURES.md |
| T2 test writer | e2e/features/g11_detached_host_test.go, e2e/features/g11_detached_reattach_test.go, e2e/features/bug468_runner_cleanup_test.go, e2e/features/g11_runner_test.go (new), e2e/fakes/fakeagent/** (only if a pinned line shape needs it), cmd/devboxverify/journey_detached_host.go, cmd/devboxverify/journeys_screen.go, cmd/devboxverify/journeys.go, cmd/devboxverify/README.md |
| T3 verification | none; runs in the close child relay#532 |
T1 writes the test names below into docs/FEATURES.md; T2 uses exactly those names.
Amendments (coordinator, 2026-10-20, logged as issue comments): T1 also owns internal/sessions/hostapi/server.go (the error text naming detach_sessions only) and internal/sshhost/sshhost.go (the ControlPath budget only, #538).
Docs and feature map (T1)
G11.13: claim says a Unix host session keeps its agent by default;hosts[].detach_sessions: falsegivesssh -T; a Windows host always usesssh -T. Power door stayssettings.jsonhosts[].detach_sessions. Adde2e:TestHostSessionRunsUnderRunnerByDefaulte2e:TestHostSessionSSHFallbackWhenDetachOffe2e:TestHostRunnerWithoutTmux.G11.14:in.jsonlwording becomessent/; add the legacy sentence ande2e:TestDetachedLegacyRunStoppedAndResumed.G1.40: a send returns once the runner wrote the message to the agent (the receipt). Adde2e:TestRunnerSendReturnsAfterReceipte2e:TestRunnerCursorAcrossDropEachLineOnce.- New
G1.48"Runner event lines": doorsbg:session runner, proofevent:session.state=ok#to, teste2e:TestRunnerWritesEventLines. Simple and power doorn/a. docs/ssh-hosts.md"Detached sessions": layout table, default, fallback, legacy, receipt.docs/session-host.md"Detached on a host".docs/session-adoption.md: generation directory,.ctl,events.jsonl, theagenttool decision, the transport table.docs/THREAT-MODEL.md3e:in.jsonlbecomesinbox/; the promise does not change.docs/cli.mdrelay session send: the receipt.
Acceptance mapping
| Criterion | Task, interface | Test |
|---|---|---|
| 1 Host session survives a dropped link, no host setting | T1 buildHostSpec default, runner |
TestHostSessionRunsUnderRunnerByDefault: a host with no detach_sessions key; the link drops and returns, a message is answered, one generation. Existing tests keep their true seed |
| 2 Local session survives a relay restart | relay#536 | relay#536 |
3 Send succeeds only after sent/ |
T1 RunnerSend |
TestRunnerSendReturnsAfterReceipt |
| 4 Reader at a saved cursor gets each line once, in order | T1 cursor, unchanged reader | TestRunnerCursorAcrossDropEachLineOnce, existing TestDetachedViewerGetsDropEventsOnce |
| 5 Claude, codex, pi under one runner | relay#537 | relay#537 |
| 6 Host without tmux runs headless sessions | T1 runner | TestHostRunnerWithoutTmux: the stub host's PATH has no tmux; no ssh call names tmux |
| 7 One event line per state change | T1 runner, Patterns |
TestRunnerWritesEventLines: turn_started, turn_ended, asking (a permission prompt), exited (after stop) |
8 Protocol is the agent tool's or replaces it |
Decisions 1 to 4 | review against this contract |
| Fallback (task 3) | T1 | TestHostSessionSSHFallbackWhenDetachOff: no run directory; the ssh call is the -T claude command |
| Legacy (decision 13) | T1 | TestDetachedLegacyRunStoppedAndResumed |
Test surface
- e2e (
go test -race ./features/ine2e/, ssh stub host): every row above. - Journey
session-detached-host, the simple path. Same id. It drops thedetach_sessionsedit and moves from the screen phase to the api phase, so it runs on the verification stack. Its agent uses thehaikualias, which a stack maps to its local model endpoint. A host session does not inherit relay's environment, so the journey starts the session by an agent entry whose command is a launcher it writes. The launcher readsclaude_base_url,claude_modelandclaude_token_filefrom the stack file and execsclaude. The token file is read at exec; its bytes go into no file, log or detail. Noclaude-*id and no hosted model. The phase move is a harness change: the coordinator reports it under gate changes. - Real-app check: on the test machine, Relay.app and eve built from the epic head, agent on the local endpoint. In eve, start a headless session in a loopback host project with no host setting. Ask for a long answer, run
relay host disconnect, and watch the reply finish in eve with no error and no Resume button.relay session eventsshows no gap. - Break-and-restore: move the message to
sent/before the write;TestRunnerSendReturnsAfterReceiptgoes red. - No Playwright: no screen changes.
Waits
- T1 tests:
relay session startreturns afterrunner.jsonexists.relay session sendandmessagereturn after the receipt. A link drop or return:session.host_linklostorrestored(WaitEvent). A restart:service.manifest.registerafterRestart(). A stop: the CLI exit, thenkill(2)on the group. An event line: read once its cause is visible (decision 12), no wait. - Journey:
Waits: none possible for the settings import: relay's file watch emits no event.It keeps its bounded retry ofrelay session startuntilrunner.jsonexists. Every other step waits on a CLI exit, a/wsframe orsession.host_link.
Budget
- T1: 12 files, about 900 changed lines (runner.sh and runner.go new, about 300; agentrun.go and claude_detached.go about 350; docs about 250).
- T2: 9 files, about 700 changed lines (new tests about 450; journey about 150; edits to existing tests about 100).
More files or about 1.5x the lines is a question for the coordinator.
Open questions
- INSIDE. "Headless" means every provider-hosted session (stream-json, rpc, app-server), not only
settings.headless. Recommendation: yes; this PR moves host Claude, relay#536 local Claude, relay#537 codex and pi. - INSIDE. "Transcript offset" is the byte offset in
out.jsonl. Recommendation: yes; the agent's own transcript is the Adopter's (relay#527). - INSIDE. A runner start failure never falls back to
ssh -Tsilently. Recommendation: yes (decision 8); a silent fallback hides the loss of survival. - INSIDE. An agent under the old
in.jsonlrunner is stopped once and resumes. Recommendation: yes (decision 13); keeping both protocols makes three. - BEYOND. Make the
agenttool a client of relay's runner, so one supervisor remains. Recommendation: a follow-on outside this repo, not this epic. - BEYOND. The api-phase journey needs a loopback host project with no presence prompt. Recommendation: the journey writes the host, project and agent entry into the stack's
settings.json(the power door). Fallback: a loopback host project in the devboxWorld stack seed, a test-fixture change. - BEYOND. Event lines match fixed strings, so a nested object holding the same pair adds a stray event. Recommendation: accept; relay's result count already matches this way, and no relay decision reads
events.jsonlyet.
Activity
barelyworkingcode commentedon Oct 20, 2026
Coordinator approval: inherits the approved epic relay#525 (a listed batch), owner asked 2026-10-20. Not a security story: the user story is survival of a lost link. The permission-hook identity rule from relay#526 is a trust-boundary change inside it; review runs on the default model.
barelyworkingcode commentedon Oct 20, 2026
Split by the contract: criterion 2 moves to #536, criterion 5 to #537. The story does not change. This PR proves criteria 1, 3, 4, 6, 7 and 8.
barelyworkingcode commentedon Oct 20, 2026
Contract amendment (coordinator, 2026-10-20): T1 also owns
internal/sessions/hostapi/server.go, only to update the error text that says detached sessions needdetach_sessions. Decision 13: a live legacy run is stopped at the first resume; the startup attach leaves it dormant.barelyworkingcode commentedon Oct 20, 2026
Contract amendment (coordinator, 2026-10-20): T1 also owns
internal/sshhost/sshhost.go(the ControlPath budget only, #538) and its sentence indocs/ssh-hosts.md. Reason: a stack's config dir is long enough that no host session starts, which blocks thesession-detached-hostjourney on a--stackrun. The journey also seeds a short-lived proxy-class credential and the host's terminal templates into the stack'ssettings.json, and it waits on the runner's receipt insent/before it drops the link.barelyworkingcode commentedon Oct 20, 2026
Merged into epic/525-session-adoption by #539 (472a5d6). The close child closes this issue.
barelyworkingcode commentedon Oct 21, 2026
Done by #539, merged into epic/525-session-adoption. Verified on the epic head b6f530d (see #532).