Skip to content

Epic: adopt an outside Claude session on an SSH host #588

Closed
2 of 2 issues completed
Closed

Description

@barelyworkingcode

Report: barelyworkingcode/claude-process#87

The coordinator adds the Report: line when the run starts.

Approval: the owner asked for this work (2026-10-22). Not a security story: it adds no reach. A host session already runs as the host account with the host project's policy, and adopt hands the conversation to that same kind of session.

Builds on #585, merged to main 2026-10-22 (#592, #593; eve's half barelyworkingcode/eve#623 merged as barelyworkingcode/eve#631). Create epic/588-host-adopt and eve's epic/626-host-adopt off main as it is now. The contract below names main's shapes.

User story

As the owner, I want to adopt a Claude session I started in a terminal on an SSH host, so that I can drive it from my phone like an adopted session on this Mac.

Acceptance criteria

  • Relay lists a live interactive command-line Claude row on a Unix SSH host, in a registered host project, as adoptable: true when its registry tests pass.
  • An adopt of that row ends the outside process on the host and resumes the same conversation headless on the host, in the same folder, in the host project.
  • On a host with tmux 3.0 or later, the adopted session runs in the tmux session relay-agent-<session id>. On a host without it, the session runs under the plain runner.
  • The transition runs as one detached script on the host. Relay's SSH connection only starts the script and reads its result.
  • The script checks the pid's start time before each signal. On a mismatch before the first signal, it sends nothing and answers 409 changed.
  • The script refuses a busy session with 409 busy and sends nothing, unless the request sets interrupt.
  • When the SSH connection drops during an adopt, relay answers 502 host_link_lost and lists the session as dormant with Resume, its transcript intact.
  • A resume of a dormant adopted host session continues the same conversation on the host. It attaches the agent the script started, or starts one.
  • A resume never starts a Claude while the outside process still runs with its pinned start time. It answers 409 changed, and the outside row stays adoptable.
  • A host adopt with no model in the body never answers model_unknown.
  • The adopted host session keeps the host project's policy, and relay lists it with no unsandboxed field.
  • The session_adopt and session_interrupt audit rows of a host adopt carry host_id.
  • After adopt, a Chief of Staff send to the session answers 202, and drop-in opens Claude on the same conversation on the host.
  • A send while the drop-in terminal holds the session answers 409 dropped_in.
  • A Claude row on a host with detach_sessions: false, or on a host project with no claude-code template, keeps adoptable: false with adoptReason: "host_unsupported". Relay lists no Pi or Codex row on a host, and a Windows host lists no rows.
  • The Chief of Staff scope still cannot reach the adopt door, and the Chief of Staff gets no adopt action.
  • The Agents board in eve shows Adopt, or Interrupt and adopt, on an adoptable host row (Epic (eve track): offer Adopt on terminal Claude rows on SSH hosts eve#626).

What I'd notice

An Adopt button on a terminal Claude row on an SSH host. After adopt, the agent shows on the Agents board with Drop in, and tmux ls on the host lists relay-agent-<id>.

Owner decisions (2026-10-22)

The owner's words: "I want to adopt agents on remote hosts. The logic holds as local; follow the same pattern; treat it the same as a local. Once adopted, the CoS can send it messages or we can drop in."

  • Same pattern as a local adopt. The launch is the host's existing headless Claude launch (the claude-code template rule and the relay-agent-<id> tmux session). No new template type.
  • Claude only. Pi and Codex rows on a host keep their refusal.
  • The whole transition (verify pid and start time, the idle check, the signal ladder, start the relay-agent tmux session with the runner and --resume) runs as one detached script on the host. A dropped SSH connection cannot leave a half state. The idle check runs inside the script, so the check and the signals are atomic. SSH only starts the script and reads its result.
  • A dropped SSH connection at any point ends as a running session (the next probe attaches it as a normal managed session) or a dormant session with Resume (transcript intact).
  • The resume carries the original model. No model derivation is built for hosts.
  • Host sessions keep the host project's policy. Host templates cannot be sandboxed. No new unsandboxed concept: the "Unsandboxed" chip of relay#585 does not show on host rows.
  • When the outside TUI ran in a tmux pane and ends, adopt starts a fresh relay-agent session and resumes there. The docs say so.
  • Windows hosts (status unsupported) and hosts without opt-in stay out. A host without tmux 3.0 uses the plain runner and is supported.
  • Audit: the same intent rows (session_adopt, session_interrupt, completion), with the host id.
  • The Chief of Staff gets no adopt action. After adopt, Chief of Staff sends work while the session is headless, and drop-in works. Sends while a terminal holds the session answer 409 dropped_in.
  • Out of scope: eve's resume of an ended outside row on a host (chief-of-staff-outside.js). Filed as Resume an ended outside Claude session on an SSH host in a terminal eve#629.

Context

Today docs/outside-sessions.md says "Sessions on SSH hosts are not adopted", the probe marks host rows host_unsupported (GateAdoption), and the door refuses any machine but the console (adoptableRow). The door's five steps are local only: it reads ~/.claude/sessions/<pid>.json in Go, compares the pid's kernel start time with procStart, runs the idle check (transcript tail, child processes with start times), sends the SIGINT, SIGTERM, SIGKILL ladder with a start-time check before each signal, and launches headless locally with --resume. Hosts already have the probe over SSH, headless Claude under the runner in relay-agent-<id> (tmux 3.0 or later), the claude-code template, drop-in by PaneDropInArgv, and the hand-back resume.

Checked in the code: an adopt intent row with no completion row does not reach a dormant state today. The ledger record is written only at commitLaunch, after the launch, so a dropped link leaves an ended outside row and no Resume (eve refuses an ended host row). A task in #589 adds that state. The door's model_unknown refusal does block a host adopt with no model, and checkResumeConversation refuses resume_conversation on a host launch. Tasks in relay#589 handle both.

Batches

Contract

Re-cut 2026-10-22: smaller children. This contract replaces the first one. It builds on what #585 merged to main (#592, #593) and what barelyworkingcode/eve#623 merged (barelyworkingcode/eve#631). Quality check: one role, one goal, one reason; the story states the problem. Epic size: four slices (two implementation children, two close children), no later items. Owning repo: relay. Epic branches: relay epic/588-host-adopt, eve epic/626-host-adopt, both cut from main as it is now. Each child PR bases on its repo's epic branch and closes exactly its own child. Issue types are labels (epic, task).

Re-cut: what went, and why

  • Cut 1, checked in the code first. relay-sessions already attaches a live relay-agent-<id> run as a normal managed session: ClaudeProvider.startDetached asks the host RunnerStatus for the session id on every resume and attaches a live run (attachRun). So the adopt status script, the probe settle step (settled_by: probe), the resume answer 409 adopt_in_progress and the attach launch mode are cut. One gap stays: relay-sessions saves a runner session only after Start succeeds (startProvider), so after a dropped link it has no saved session and a plain resume fails. The cure is one fallback (relay#589, interface 4), not a mode. Relay's outside probe attaches nothing by itself, so a dropped adopt lists as dormant with Resume, and Resume attaches.
  • 502 host_unavailable is cut. A host that does not answer the pre-read gets the door's existing 502 unavailable.
  • The pre-read script is cut. Relay reads the transcript tail with the existing outsideRangeScript. The script checks pid, start time and registry itself.
  • GateAdoption and the probe script do not change. The caller passes the host test in (relay#589).
  • Signal times are not sent back to relay. The host's adopt.state file keeps them.
  • Docs, tests and fakerelay are cut to what the owner listed (relay#589).

Cross-repo wire (what main has, plus this epic)

  1. Outside rows (GET /api/outside-sessions, snapshot frames): a live Claude row on a host reads adoptable: true when its registry tests pass and its host adopts. A host adopts when it is not Windows, its detach_sessions is absent or true, and the row's host project allows the claude-code template. Otherwise the row reads adoptable: false, adoptReason: "host_unsupported", as on main. Relay lists no Pi or Codex row on a host. No new row field.
  2. POST /api/sessions/adopt: the body is main's: outsideId or claudeSessionId, interrupt, model. A host row is selected by outsideId only. The 201 body is main's (sessionId, claudeSessionId, interrupted, recoverySent); a host adopt sends no unsandboxed, agent or conversationId. One new refusal: 502 host_link_lost, message "the connection to dropped during the adopt; the session shows as dormant, and Resume continues it". 409 busy, 409 changed, 409 adopt_in_progress (two adopts of one conversation), 502 unavailable, 502 stop_failed, 502 launch_failed keep their main meaning. A host adopt never answers model_unknown.
  3. GET /api/sessions: the adopted host session lists as any host session (host, headless: true, model as launched), with no unsandboxed field. A host adopt that answered host_link_lost or launch_failed lists its session as dormant, with Resume.
  4. POST /api/sessions/{id}/resume, relay session resume: shapes unchanged. On a dropped adopt's session it answers 200 and attaches the agent the script started, or starts one with --resume. It answers 409 changed, starts nothing and removes the session while the outside process still runs with its pinned start time.
  5. Audit: the session_adopt intent and completion and the session_interrupt intent and completion add "host_id": "<id>". The door writes exactly one completion per intent. A host adopt's completion carries interrupted and no signals list.
  6. Drop-in, hand back, Chief of Staff send: unchanged doors and shapes; 409 dropped_in while a terminal holds the session.

Eve reads items 1, 2 and 3 through its existing adopt route and session list. Its adopt route sends only {outsideId, interrupt}.

Paths

  • Simple: Chief of Staff > Agents (on a phone, the Agents dialog) > a Claude row on a host > Adopt, or Interrupt and adopt. The managed row then shows the host chip and Drop in, and the Chief of Staff can send to it. No new control, setting or confirm.
  • Power: POST /api/sessions/adopt with outsideId and an optional model; relay session list --json; relay session resume; relay drop-in; relay audit --event session_adopt; on the host tmux attach -t relay-agent-<id>. Turning adopt off for a host is the existing hosts[].detach_sessions: false. No new switch.

Merge order

  1. Adopt a terminal Claude on an SSH host: transition script, launch, dropped-link recovery, fakerelay, docs #589 into epic/588-host-adopt.
  2. Offer Adopt on SSH-host rows that relay marks adoptable eve#627: PR 1 pins test/e2e/relay-pin.json to relay's epic head after step 1, then PR 2. Both into epic/626-host-adopt. The eve dev may start in parallel from this contract; eve's specs go green only after step 1.
  3. The close children, then the joint close task below.

Joint close task (run by the coordinator on the devbox)

The verification runs once, on frozen heads: after step a no push lands on either epic branch, so step b runs one time. Both epic PRs to main stay drafts until step c; merge-check refuses a draft, so neither merges alone. Eve's relay-pin.json names relay's epic head, which becomes an ancestor of main with the same tree when relay merges, so nothing is re-pinned.
a. #590 and barelyworkingcode/eve#628 report close lines 3 and 4 done: main is synced into each epic branch. The heads are now frozen.
b. Unlock the devid keychain and run npm ci in the eve checkout. Relay's run: stack-up r590 --relay <relay checkout at its epic head> --eve <eve checkout at its epic head>, go run ./cmd/devboxverify --stack <stack.json> --phase api, stack-down r590. Then eve's run on a fresh stack: stack-up e628 --relay <same relay head> --eve <same eve head>, node devboxverify/main.js --checkout <eve checkout> --stack <stack.json>, stack-down e628. Green is each repo's verification; each close child gets its Verified: line naming its frozen head.
c. Mark both epic PRs ready. merge-check passes on each. Merge relay, then eve, minutes apart.
d. Close each parent when gh api repos/<owner>/<repo>/issues/<N>/sub_issues --jq '[.[]|select(.state!="closed")]|length' is 0 and every not_planned child has a Dropped: line.
A push after step b means step b runs again on the new heads. If the relay merge breaks eve in the minutes between, file an issue and advise; no auto-revert. A gap in any step gets one comment here, and the step stops.

The devbox's default tmux server holds the owner's and the nightly's sessions. No journey, test or manual step kills it (tmux kill-server) or starts a replacement on its socket. Cleanup ends only the sessions a run created, by name (tmux kill-session -t <name>).

Acceptance mapping

Parent criterion Child Interface Test
Host row adoptable; host_unsupported cases relay#589 wire 1 e2e TestAdoptHostSuccess; existing g1_outside_adopt_test.go host row, moved to detach_sessions: false
Same conversation, folder, project; tmux or plain runner; one detached script relay#589 wire 2; sshhost.AdoptStart* e2e TestAdoptHostSuccess; journey session-adopt-host
Start-time mismatch sends nothing relay#589 the script's lifetime check e2e TestAdoptHostChanged
Busy refused unless interrupt relay#589 the door's verdict and the script's idle test e2e TestAdoptHostBusy
Dropped SSH: dormant with Resume; Resume continues relay#589 wire 2, 3, 4 e2e TestAdoptHostLinkDrop; journey session-adopt-host-drop
No Claude beside a live outside process relay#589 wire 4 review only (it needs a hold seam the owner cut)
No model_unknown; host policy, no unsandboxed; audit host_id relay#589 wire 2, 3, 5 e2e TestAdoptHostSuccess
Chief of Staff send, drop-in, dropped_in relay#589 (existing code) wire 6 journey session-adopt-host; devbox pass in relay#590
Scope refused, no Chief of Staff adopt action none (unchanged on main) adopt door; eve outside tools existing relay e2e and eve specs
Eve's button on host rows eve#627 wire 1, 2, 3 Playwright g6-outside-adopt.spec.js; journey outside-adopt-host (nightly)

Test surface

  • relay e2e feature tests through the ssh stub host: four cases, no permutations (relay#589). Relay has no unit tests outside e2e/ and fakerelay/ (repo rule).
  • relay devboxverify session-adopt-host and session-adopt-host-drop (api phase, loopback host), driven by the --stack run.
  • eve Playwright test/e2e/g6-outside-adopt.spec.js against fakerelay.
  • eve devboxverify outside-adopt-host: a screen journey (a host project raises the presence prompt), so the nightly runs it.
  • One interactive pass on the devbox, in relay#590's verification task.

Waits

In each child's contract. No duration anywhere. Model output has no signal; a test uses a bounded read there and says so.

Budget

relay#589: 23 production, fakerelay and doc files; about 800 to 900 changed Go lines, 50 fakerelay lines and 150 to 200 doc lines. Tests and journeys: 7 files, 900 to 1,100 lines. About 2 to 3 hours. eve#627: 5 dev files, about 60 lines; 5 test and journey files, about 350 lines. Close children: no source. Past either figure, the dev stops and asks the planner.

Open questions

  1. BEYOND. Recommendation: the launch model is the body's model, else the family alias the transcript tail names (adopt.ReadTail(...).ModelFamily, the line the local door runs today), else no --model. The broker alias step and model_unknown are skipped on a host. Why: eve sends no model, and eve shows Drop in only for a session whose model is a Claude alias (public/agent-board.js, takesDropIn). With no model, an adopt from eve shows no Drop in, which fails this epic's drop-in criterion. This builds nothing new for hosts; it keeps the local line. If the owner wants no --model at all, eve#627 needs a kind field from relay instead, which is a larger change.
  2. BEYOND. Recommendation: correct the lines that the change makes false outside the owner's doc list: three rows of docs/fakerelay.md (adopt refusal step 3, adoptable, adopt.refuse) and one sentence of docs/session-host.md (resume_conversation refused on a host launch). About 8 changed lines. Why: leaving them would document the old refusal.
  3. INSIDE. Recommendation: a host "without opt-in" is a host with detach_sessions: false. Its sessions die with the link, so the detached script cannot hold there.
  4. INSIDE. Recommendation: before the first signal, a start-time mismatch answers changed and sends nothing. After a signal went out, a mismatch or a gone pid means the outside process ended: the script sends nothing more and starts the session. Why: changed at that point would leave an ended terminal Claude and no session.
  5. INSIDE. Recommendation: the dropped adopt's record lists as dormant for the seconds a normal adopt runs. A Resume tap in those seconds gets a 502 and starts nothing (relay-sessions refuses a second launch of a slot in flight). No list filter.
  6. INSIDE. Recommendation: an agent the script started after the link dropped runs unattended until Resume attaches it. A relay-sessions restart does not reattach it (it was never saved).
  7. INSIDE. Recommendation: on the loopback fixture the same process lists twice, as a console row in no project (adoptable and unsandboxed since relay#585) and as a host row. Journeys and the devbox pass select the row by hostId and never touch the console twin.
  8. BEYOND. Recommendation: eve's resume of an ended host row stays out (Resume an ended outside Claude session on an SSH host in a terminal eve#629, needs-review, from-dev).

Activity

  1. barelyworkingcode commented on Oct 22, 2026

    @barelyworkingcode
    OwnerAuthor

    Planner: open questions and risks. The questions are numbered as in the Contract; each takes its recommendation unless the owner says otherwise.

    Open questions (Contract, "Open questions"):

    1. BEYOND. The idle check: Go parses the transcript tail from a read-only pre-read; the script runs the process and registry tests and refuses busy when the transcript size or statusUpdatedAt moved. Recommendation: take it.
    2. BEYOND. The model: body model, else the tail's family alias, else no --model; no catalog lookup, no model_unknown on a host. Recommendation: take it.
    3. BEYOND. "Hosts without opt-in" means detach_sessions: false. No new host setting. Recommendation: take it.
    4. INSIDE. A drop before the script, or a refusal, leaves the outside session untouched and adoptable again.
    5. INSIDE. The script's cases are e2e tests through the ssh stub host (relay has no unit tests).
    6. INSIDE. An unknown turn boundary counts every child but the turn helper; interrupt gets through.
    7. INSIDE. claudeSessionId stays a console selector.
    8. INSIDE. A pending record is not listed while its adopt runs here.
    9. BEYOND. Eve's resume of an ended host row is out: Resume an ended outside Claude session on an SSH host in a terminal eve#629.
    10. INSIDE. The loopback fixture lists the process twice; journeys pick the host row.

    Risks the code could not settle:

    1. Budget. relay#589 is the largest child yet (about 30 files). If it trips, the split is the settle step and resume branch into a second relay child, which makes five slices and needs the coordinator's call.
    2. Start-time precision. On a macOS host the probe's lifetime is ps -o lstart (whole seconds); on Linux it comes from procfs. The script must use the probe's own function (LifetimeShell), or a match never holds.
    3. The script's waits are host wall-clock sleeps, bounded by the ladder's 5 s figures, as the detached start's own wait is. They are production waits, not test waits.
    4. A stopped Claude (SIGSTOP) keeps SIGTERM pending until SIGKILL. The drop journey relies on it to hold the ladder open. If the devbox's macOS ends a stopped process on SIGTERM, the journey drops the link on the started line instead and accepts any valid end state.
    5. A host whose login manager kills a user's processes at logout (KillUserProcesses=yes) takes the detached script with it. Then the settle step finds dead and ends as dormant or untouched, never half. ssh-hosts.md already names this limit.
    6. THREAT-MODEL.md 3f says host sessions are never adopted. relay#589 edits that promise; the reviewer runs on the default model.
    7. Expected gate-report items, not defects: the G1.50 claim edit, the changed host expectations in e2e/features/g1_outside_adopt_test.go and fakerelay/prooftest/adopt_test.go, and eve's spec fixture edit in Offer Adopt on SSH-host rows that relay marks adoptable eve#627 PR 1. Each PR names them as an approved behaviour change.
    8. Stack model on the loopback host: the stack launcher sets only ANTHROPIC_DEFAULT_HAIKU_MODEL, and the transcript records the stack's model id, which names no family. The relay journeys send model: "haiku". Eve's simple path sends none, so its nightly journey's launcher sets all three ANTHROPIC_DEFAULT_*_MODEL values to the stack model.
  2. barelyworkingcode commented on Oct 22, 2026

    @barelyworkingcode
    OwnerAuthor

    Coordinator approval: owner asked, 2026-10-22

  3. barelyworkingcode commented on Oct 22, 2026

    @barelyworkingcode
    OwnerAuthor

    Re-cut 2026-10-22: the cuts that could not be made in full, and why.

    • Cut 1. The probe settle step, settled_by: probe, the resume answer 409 adopt_in_progress and the attach mode are gone. relay-sessions already attaches a live relay-agent-<id> run on resume (ClaudeProvider.startDetached: RunnerStatus, then attachRun). Two small pieces stay. First, relay-sessions saves a runner session only after Start succeeds, so a resume after a dropped link would fail with "not found on disk". The cure is one fallback in Manager.resolveSessionForCreate (Adopt a terminal Claude on an SSH host: transition script, launch, dropped-link recovery, fakerelay, docs #589, interface 4). Second, the script takes one flag, no_signal, so a resume never starts a Claude beside a live outside process. Relay's outside probe attaches nothing by itself, so a dropped adopt lists as dormant until Resume.
    • Cut 2. Two docs outside the list get only the lines that would read false: three rows of docs/fakerelay.md and one sentence of docs/session-host.md, about 8 lines (open question 2).
    • Cut 3. The four e2e cases leave one criterion with no automated test: a resume beside a live outside process. Covering it needs a hold seam, and that seam was cut. The reviewer checks it.
    • Cut 5. model_unknown and the broker alias step are skipped on a host. The existing family-alias line stays, because eve sends no model and eve shows Drop in only for a Claude alias in model (public/agent-board.js, takesDropIn). That line is not new code for hosts (open question 1).
  4. barelyworkingcode commented on Oct 23, 2026

    @barelyworkingcode
    OwnerAuthor

    Joint close step a: main has not moved since both epic branches were cut (relay main 6954403, eve main 9880b2f), so a sync PR has no commits and GitHub refuses it. Close line 4 is a no-op in both repos. Frozen heads: relay epic/588-host-adopt 1e96e38, eve epic/626-host-adopt ae73cfe. Line 3 holds: relay#589 merged by #597; eve#627 merged by eve#639 and eve#641. Eve's relay-pin.json names 1e96e38.

  5. barelyworkingcode commented on Oct 23, 2026

    @barelyworkingcode
    OwnerAuthor

    Epic merged. relay: #597 (relay#589) into the epic branch, #604 to main (c8e0fe2). eve: barelyworkingcode/eve#639 and barelyworkingcode/eve#641 (eve#627) into the epic branch, barelyworkingcode/eve#640 to main (79af431). Verified on the frozen heads (relay#590, eve#628).

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 requestepicParent issue for a multi-PR change

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions