Skip to content

One file-channel runner for local and host headless sessions #528

Closed

Description

@barelyworkingcode

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 agent tool 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 -T only 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.

Decisions

  1. The agent tool (relay#525, question 7): reuse its protocol. Relay's runner speaks the owner's agent tool's file channel. A sender writes inbox/.<id>.tmp and renames it into inbox/. The runner writes messages in name order and moves each to sent/ after the write. The move is the receipt. out.jsonl gets every stdout line.
  2. 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.
  3. The new runner replaces relay's own in.jsonl runner. Two programs then speak one protocol. There is no third implementation.
  4. The runner does not use the tool's state.json, meta.json, heartbeat, hold or stop files. Relay keeps session state in relay-sessions and stops by process group. relay#529 may add hold with the tool's meaning.
  5. One runner program for host and local: internal/sessions/runner/runner.sh, POSIX sh plus awk, tail, mkfifo, ps and base64. It needs no perl, python, tmux or setsid(1).
  6. 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.jsonl unchanged.
  7. Default on Unix hosts. A Claude session on a Unix host runs under the runner when hosts[].detach_sessions is absent or true.
  8. ssh -T fallback. hosts[].detach_sessions: false runs the session under ssh -T. A Windows host always uses ssh -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.
  9. 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.
  10. Codex and pi are not in this PR. They move in relay#537.
  11. Event lines (relay#525, the eventing seam). The runner appends one line to events.jsonl per state change: turn_started, turn_ended, asking, exited. "Transcript offset" is the byte offset in out.jsonl. The runner knows no agent's own transcript path; the Adopter (relay#527) maps one.
  12. Event order. The runner appends an event line before the line that causes it reaches out.jsonl. turn_started goes before the receipt. exited goes before the exit file. A reader that saw the cause reads the event with no wait.
  13. Old runs. A generation with run.json and no runner.json reads legacy. 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.
  14. 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.json

Relay 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 -T

buildHostSpec (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: false in settings.json runs that host's sessions under ssh -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: false gives ssh -T; a Windows host always uses ssh -T. Power door stays settings.json hosts[].detach_sessions. Add e2e:TestHostSessionRunsUnderRunnerByDefault e2e:TestHostSessionSSHFallbackWhenDetachOff e2e:TestHostRunnerWithoutTmux.
  • G11.14: in.jsonl wording becomes sent/; add the legacy sentence and e2e:TestDetachedLegacyRunStoppedAndResumed.
  • G1.40: a send returns once the runner wrote the message to the agent (the receipt). Add e2e:TestRunnerSendReturnsAfterReceipt e2e:TestRunnerCursorAcrossDropEachLineOnce.
  • New G1.48 "Runner event lines": doors bg:session runner, proof event:session.state=ok#to, test e2e:TestRunnerWritesEventLines. Simple and power door n/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, the agent tool decision, the transport table. docs/THREAT-MODEL.md 3e: in.jsonl becomes inbox/; the promise does not change. docs/cli.md relay 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/ in e2e/, ssh stub host): every row above.
  • Journey session-detached-host, the simple path. Same id. It drops the detach_sessions edit and moves from the screen phase to the api phase, so it runs on the verification stack. Its agent uses the haiku alias, 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 reads claude_base_url, claude_model and claude_token_file from the stack file and execs claude. The token file is read at exec; its bytes go into no file, log or detail. No claude-* 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 events shows no gap.
  • Break-and-restore: move the message to sent/ before the write; TestRunnerSendReturnsAfterReceipt goes red.
  • No Playwright: no screen changes.

Waits

  • T1 tests: relay session start returns after runner.json exists. relay session send and message return after the receipt. A link drop or return: session.host_link lost or restored (WaitEvent). A restart: service.manifest.register after Restart(). A stop: the CLI exit, then kill(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 of relay session start until runner.json exists. Every other step waits on a CLI exit, a /ws frame or session.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

  1. 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.
  2. INSIDE. "Transcript offset" is the byte offset in out.jsonl. Recommendation: yes; the agent's own transcript is the Adopter's (relay#527).
  3. INSIDE. A runner start failure never falls back to ssh -T silently. Recommendation: yes (decision 8); a silent fallback hides the loss of survival.
  4. INSIDE. An agent under the old in.jsonl runner is stopped once and resumes. Recommendation: yes (decision 13); keeping both protocols makes three.
  5. BEYOND. Make the agent tool a client of relay's runner, so one supervisor remains. Recommendation: a follow-on outside this repo, not this epic.
  6. 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.
  7. 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.jsonl yet.

Activity

  1. barelyworkingcode commented on Oct 20, 2026

    @barelyworkingcode
    OwnerAuthor

    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.

  2. barelyworkingcode commented on Oct 20, 2026

    @barelyworkingcode
    OwnerAuthor

    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.

  3. barelyworkingcode commented on Oct 20, 2026

    @barelyworkingcode
    OwnerAuthor

    Contract amendment (coordinator, 2026-10-20): T1 also owns internal/sessions/hostapi/server.go, only to update the error text that says detached sessions need detach_sessions. Decision 13: a live legacy run is stopped at the first resume; the startup attach leaves it dormant.

  4. barelyworkingcode commented on Oct 20, 2026

    @barelyworkingcode
    OwnerAuthor

    Contract amendment (coordinator, 2026-10-20): T1 also owns internal/sshhost/sshhost.go (the ControlPath budget only, #538) and its sentence in docs/ssh-hosts.md. Reason: a stack's config dir is long enough that no host session starts, which blocks the session-detached-host journey on a --stack run. The journey also seeds a short-lived proxy-class credential and the host's terminal templates into the stack's settings.json, and it waits on the runner's receipt in sent/ before it drops the link.

  5. barelyworkingcode commented on Oct 20, 2026

    @barelyworkingcode
    OwnerAuthor

    Merged into epic/525-session-adoption by #539 (472a5d6). The close child closes this issue.

  6. barelyworkingcode commented on Oct 21, 2026

    @barelyworkingcode
    OwnerAuthor

    Done by #539, merged into epic/525-session-adoption. Verified on the epic head b6f530d (see #532).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesttaskOne batch of an epic

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions