Repository navigation
Description
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: truewhen 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
busyand sends nothing, unless the request setsinterrupt. - When the SSH connection drops during an adopt, relay answers 502
host_link_lostand 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
modelin the body never answersmodel_unknown. - The adopted host session keeps the host project's policy, and relay lists it with no
unsandboxedfield. - The
session_adoptandsession_interruptaudit rows of a host adopt carryhost_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 noclaude-codetemplate, keepsadoptable: falsewithadoptReason: "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-codetemplate rule and therelay-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-agenttmux 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-agentsession 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
- Adopt a terminal Claude on an SSH host: transition script, launch, dropped-link recovery, fakerelay, docs #589: relay: host transition script, launch, dropped-link recovery, gate, audit, fakerelay, docs
- Offer Adopt on SSH-host rows that relay marks adoptable eve#627: eve: Adopt on host rows, specs, journey, docs (eve parent Epic (eve track): offer Adopt on terminal Claude rows on SSH hosts eve#626)
- Verify and close the host-adopt epic (relay) #590: close child (relay)
- Verify and close the host-adopt epic (eve) eve#628: close child (eve), then the joint close task below
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.startDetachedasks the hostRunnerStatusfor 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 answer409 adopt_in_progressand theattachlaunch mode are cut. One gap stays: relay-sessions saves a runner session only afterStartsucceeds (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_unavailableis cut. A host that does not answer the pre-read gets the door's existing502 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. GateAdoptionand 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.statefile keeps them. - Docs, tests and fakerelay are cut to what the owner listed (relay#589).
Cross-repo wire (what main has, plus this epic)
- Outside rows (
GET /api/outside-sessions, snapshot frames): a live Claude row on a host readsadoptable: truewhen its registry tests pass and its host adopts. A host adopts when it is not Windows, itsdetach_sessionsis absent ortrue, and the row's host project allows theclaude-codetemplate. Otherwise the row readsadoptable: false,adoptReason: "host_unsupported", as onmain. Relay lists no Pi or Codex row on a host. No new row field. POST /api/sessions/adopt: the body ismain's:outsideIdorclaudeSessionId,interrupt,model. A host row is selected byoutsideIdonly. The 201 body ismain's (sessionId,claudeSessionId,interrupted,recoverySent); a host adopt sends nounsandboxed,agentorconversationId. 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_failedkeep theirmainmeaning. A host adopt never answersmodel_unknown.GET /api/sessions: the adopted host session lists as any host session (host,headless: true,modelas launched), with nounsandboxedfield. A host adopt that answeredhost_link_lostorlaunch_failedlists its session as dormant, with Resume.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 answers409 changed, starts nothing and removes the session while the outside process still runs with its pinned start time.- Audit: the
session_adoptintent and completion and thesession_interruptintent and completion add"host_id": "<id>". The door writes exactly one completion per intent. A host adopt's completion carriesinterruptedand nosignalslist. - Drop-in, hand back, Chief of Staff send: unchanged doors and shapes;
409 dropped_inwhile 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/adoptwithoutsideIdand an optionalmodel;relay session list --json;relay session resume;relay drop-in;relay audit --event session_adopt; on the hosttmux attach -t relay-agent-<id>. Turning adopt off for a host is the existinghosts[].detach_sessions: false. No new switch.
Merge order
- Adopt a terminal Claude on an SSH host: transition script, launch, dropped-link recovery, fakerelay, docs #589 into
epic/588-host-adopt. - Offer Adopt on SSH-host rows that relay marks adoptable eve#627: PR 1 pins
test/e2e/relay-pin.jsonto relay's epic head after step 1, then PR 2. Both intoepic/626-host-adopt. The eve dev may start in parallel from this contract; eve's specs go green only after step 1. - 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/andfakerelay/(repo rule). - relay devboxverify
session-adopt-hostandsession-adopt-host-drop(api phase, loopback host), driven by the--stackrun. - eve Playwright
test/e2e/g6-outside-adopt.spec.jsagainst 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
- 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 andmodel_unknownare skipped on a host. Why: eve sends nomodel, and eve shows Drop in only for a session whosemodelis 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--modelat all, eve#627 needs a kind field from relay instead, which is a larger change. - 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 ofdocs/session-host.md(resume_conversationrefused on a host launch). About 8 changed lines. Why: leaving them would document the old refusal. - 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. - INSIDE. Recommendation: before the first signal, a start-time mismatch answers
changedand 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:changedat that point would leave an ended terminal Claude and no session. - 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.
- 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).
- 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
hostIdand never touch the console twin. - 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
barelyworkingcode commentedon Oct 22, 2026
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"):
busywhen the transcript size orstatusUpdatedAtmoved. Recommendation: take it.model, else the tail's family alias, else no--model; no catalog lookup, nomodel_unknownon a host. Recommendation: take it.detach_sessions: false. No new host setting. Recommendation: take it.claudeSessionIdstays a console selector.Risks the code could not settle:
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.startedline instead and accepts any valid end state.KillUserProcesses=yes) takes the detached script with it. Then the settle step findsdeadand ends as dormant or untouched, never half.ssh-hosts.mdalready names this limit.THREAT-MODEL.md3f says host sessions are never adopted. relay#589 edits that promise; the reviewer runs on the default model.G1.50claim edit, the changed host expectations ine2e/features/g1_outside_adopt_test.goandfakerelay/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.ANTHROPIC_DEFAULT_HAIKU_MODEL, and the transcript records the stack's model id, which names no family. The relay journeys sendmodel: "haiku". Eve's simple path sends none, so its nightly journey's launcher sets all threeANTHROPIC_DEFAULT_*_MODELvalues to the stack model.barelyworkingcode commentedon Oct 22, 2026
Coordinator approval: owner asked, 2026-10-22
barelyworkingcode commentedon Oct 22, 2026
Re-cut 2026-10-22: the cuts that could not be made in full, and why.
settled_by: probe, the resume answer409 adopt_in_progressand theattachmode are gone. relay-sessions already attaches a liverelay-agent-<id>run on resume (ClaudeProvider.startDetached:RunnerStatus, thenattachRun). Two small pieces stay. First, relay-sessions saves a runner session only afterStartsucceeds, so a resume after a dropped link would fail with "not found on disk". The cure is one fallback inManager.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.docs/fakerelay.mdand one sentence ofdocs/session-host.md, about 8 lines (open question 2).model_unknownand 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 inmodel(public/agent-board.js,takesDropIn). That line is not new code for hosts (open question 1).barelyworkingcode commentedon Oct 23, 2026
Joint close step a:
mainhas not moved since both epic branches were cut (relaymain6954403, evemain9880b2f), so a sync PR has no commits and GitHub refuses it. Close line 4 is a no-op in both repos. Frozen heads: relayepic/588-host-adopt1e96e38, eveepic/626-host-adoptae73cfe. Line 3 holds: relay#589 merged by #597; eve#627 merged by eve#639 and eve#641. Eve'srelay-pin.jsonnames 1e96e38.barelyworkingcode commentedon Oct 23, 2026
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).