Repository navigation
Description
User story
As the owner, I want an Adopt button on an idle session I started in a terminal, so that I can drive it from my phone.
Acceptance criteria
- An idle live outside row in a registered project shows the Adopt button.
- A busy row shows an Interrupt and adopt button instead.
- A refusal from relay shows its reason in one line.
- After adopt, the row is a managed idle headless session with the Drop in button.
- The button never shows on an unregistered folder, or on a row that is not an interactive command-line session.
What I'd notice
Every idle terminal session I left running has an Adopt button, and one tap puts it under relay.
Depends on
barelyworkingcode/relay527 merged and pinned. #592 and #593 shipped, so Drop in, Resume and Adopt share one row action model.
Parent
#596. The design is in docs/session-adoption.md (relay child "Design note", below).
Tasks
- Planner: write the
## Contract. - Add the Adopt and Interrupt and adopt actions; reuse the Drop in row-action code.
- Bump the relay pin in its own PR first.
- Playwright or e2e tests from the criteria; update the feature map row. Verification task, last.
Contract
Planner contract, 2026-10-20. Child of epic eve#596. Base epic/596-session-adoption (head c11387e: eve#592 and eve#593 merged). Relay side: epic/525-session-adoption at 2faa421 (barelyworkingcode/relay#547, barelyworkingcode/relay#560). Wire sources: barelyworkingcode/relay#527 and barelyworkingcode/relay#541 contracts, relay docs/session-adoption.md, relay docs/outside-sessions.md and fakerelay internal/sessions/adopt.go at 2faa421.
Quality check
The story passes. Criterion 3 covers two refusals: the row's adoptReason (no button) and a refusal from the adopt door (after a tap). Both are one line.
Relay side: no change
Relay at 2faa421 serves everything this child calls. No new relay door, scope or grant.
POST /api/sessions/adoptis classexecute, socket only. It is the same class asPOST /api/sessions/{id}/drop-in. WithX-Relay-Scope: chief-of-staffit answers403 Forbidden.- Every outside row carries
adoptable. A row that is not adoptable carriesadoptReason. - fakerelay serves both fields, the door, and a world
adoptobject that picks each outcome.
Approach (smallest change)
- The browser sends the outside row id in the path and one boolean,
interrupt, which the tapped button sets. It never sends a session id, a pid, a model or a CLI argument. - Eve's server checks the shape. It then makes one relay call,
POST /api/sessions/adopt, throughrelayTransport.fetchwith noscopeoption. Relay judges the row, the project, idleness and the launch. - Eve does not read the adopt through
ChiefOfStaffOutside. That module calls relay with{scope:'chief-of-staff'}, which the door refuses. Eve needs no server-held value for adopt. - The Chief of Staff gets no adopt action and no new tool. Its outside actions still refuse with
outside_session_read_only. - No new WebSocket frame type.
frozen.wsTypesdoes not change.
Interfaces
Relay wire (2faa421, unchanged, for reference)
- Request:
{"outsideId":"outside:<32 hex>","interrupt":<bool>}. Eve never sendsclaudeSessionIdormodel. 201 {sessionId, claudeSessionId, interrupted, recoverySent}.- Refusal
{error:<code>, message:<one line>}:- 400
invalid_body; 413body_too_large; 404outside_session_not_found; - 409 an
adoptReasoncode,managed,busy,model_unknown,adopt_in_progress,changed; - 403/400 a launch-authorization code; 502
unavailable,stop_failed,launch_failed; 503audit_unavailable.
- 400
- Row reason codes:
ended,host_unsupported,no_project,not_registered(no Claude registry entry),invalid_id,start_mismatch,not_interactive,not_command_line,ambiguous. - Event
session.adopt, one per request:outside_id,claude_session_id,project_id,session_id,interrupted. Outcomeok;deniedbelow 500,errorat or above.
Eve route (routes/index.js, beside the resume route; CRLF file, patch in place, check with grep -c $'\r')
POST /api/outside-sessions/:id/adopt, requireAuth, body {interrupt}.
:iddoes not match^outside:[0-9a-f]{32}$:400 {error:'invalid_request'}. Relay is not called.interruptis not exactlytrueorfalse:400 {error:'interrupt must be true or false'}. Relay is not called. Other body keys are ignored and never forwarded.- One call:
relayTransport.fetch('POST', '/api/sessions/adopt', { outsideId: id, interrupt }, { traceId: req.traceId }). Noscopekey. This is the code pathproxy()uses for Drop in: eve's frontend-socket peer identity, classexecute. - Answers:
- Relay 201 with a string
sessionId:201 {sessionId, interrupted: data.interrupted === true, recoverySent: data.recoverySent === true}.claudeSessionIdis dropped: no outside conversation id reaches the browser (the eve#593 promise indocs/api.md). - Relay 2xx with no
sessionId:502 {error:'adopt_failed', message:'Relay did not return a session.'}. - Relay non-2xx: relay's status,
{error: data.error || 'adopt_failed', message: data.message || 'Relay answered <status>.'}. - Transport throws:
502 {error:'Service unavailable'}.
- Relay 201 with a string
- One log line through
logProxyCall,op:'session.adopt'. It never logs the body.
- No client or server timeout. Relay bounds the ladder (at most about 15 s).
Eve server: chief-of-staff-outside.js
ROW_FIELDSaddsadoptableandadoptReason. Nothing else changes.claudeIdsstill holds ended rows only. A live row'sclaudeSessionIdstays out of every row, frame, list, detail and tool result (pick).
Browser: public/core/api-client.js
adoptOutside(id, { interrupt }). It refuses an id with nooutside:prefix. A refusal rejects witherr.body = {error, message}, asresumeOutsidedoes.
Browser: public/agent-board.js
static adopting = new Map(); // outside row id -> 'adopt' | 'interrupt'; kind fixed at tap
static adopted = new Map(); // outside row id -> managed sessionId of an adopt this page made
static showsAdopt(r) // the one rule, below
static adoptReason(r) // the one-line reason for a live outside row with no button; '' otherwise
static async adoptOutside(container, id, { interrupt, onRefuse = null }) // never rejects- Rule.
showsAdopt(r)is true when all hold:r.kind === 'outside';r.group !== 'ended';r.item.stateisidle,runningorasking;r.item.adoptable === true;!r.item.hostId. An absent or non-trueadoptablegives no button (fail closed).idlegives Adopt.runningoraskinggives Interrupt and adopt. - Reason.
adoptReason(r)applies to a live outside row withadoptable === falseand a stringadoptReason. Text per code:host_unsupported:On a host, so it can't be adopted here.no_project:Not in a project, so it can't be adopted.not_registered:Claude keeps no session record for it, so it can't be adopted.invalid_id:Its conversation id is not valid, so it can't be adopted.start_mismatch:Its process changed, so it can't be adopted.not_interactive:Not an interactive session, so it can't be adopted.not_command_line:Not started from the command line, so it can't be adopted.ambiguous:Two processes hold this conversation, so it can't be adopted.- any other code:
Relay can't adopt this session. - A row with no
adoptablekey gets no line.
- Flow of
adoptOutside.- Return at once if the id has no
outside:prefix or is inadopting. - Set
adoptingto the kind and re-render every live board. - Call
api.adoptOutside(id, {interrupt}). - On 201 with a
sessionId: setadopted, thenawait container.get('app').loadSessions(). The managed session then reaches the board throughAgentAttention(the list row carriesattention). The page does not callonOpen: the sheet stays open and shows the result. - On a refusal: call
onRefuse?.()(the row passes() => this.onOpen?.(r), so the phone sheet closes and the toast shows), then the toast. - Finally: delete from
adoptingand re-render.
- Return at once if the id has no
- Toast. Id
adopt-<row id>, type error, 8 s. TextCan't adopt: <message>, else<error>, elserelay isn't reachable, with whitespace runs collapsed to one space and trimmed. With no session back:Can't adopt: relay did not return a session. - Prune.
render()deletes anadoptedentry once its row is no longer inoutsideRows(), beside theresumedprune. - One row-action model (epic criterion 4). The board now builds four action buttons (Drop in, Resume, View read-only, Adopt). T1 extracts one builder,
_actionButton({testid, name, text, busyText, busy, done, onClick}), and one reason-line builder,_reasonLine(testid, kind, text). Drop in, Resume, View read-only and Adopt use them. The DOM of Drop in, Resume and View read-only must not change:g6-drop-in.spec.js,g6-outside-resume.spec.jsandg6-cos-outside*.spec.jsstay green unedited. - Placement. The Adopt button is a sibling in the row's
.agent-row-wrap, after View read-only when that shows. The reason line is a span inside the live row button, after line 3.
UI states (live outside row, rail and phone sheet)
| Case | Shows |
|---|---|
Idle, adoptable |
button Adopt, accessible name Adopt <name>, test id <prefix>-adopt-<id> |
Running or asking, adoptable |
button Interrupt and adopt, accessible name Interrupt and adopt <name>, test id <prefix>-interrupt-adopt-<id> |
| Tapped, request open | the same button, disabled, aria-busy="true", text Adopting… or Interrupting… (fixed at tap) |
| Adopted, row still in the snapshot | the button disabled, text Adopted |
| Refused | toast Can't adopt: <message>; the button is back |
| Not adoptable | no button; line [data-kind="adopt-reason"], test id <prefix>-adopt-reason-<id> |
| Ended | no Adopt and no adopt line, ever (Resume, eve#593) |
Accessible names must not contain "read-only". Touch: the existing (pointer: coarse) rule gives the button a 44 px minimum.
After adopt. Relay removes the outside row (fakerelay at once; real relay at its next probe) and lists a managed headless Claude session with the row's name, attention.state idle. The board shows it under Idle with Drop in to <name> (showsDropIn, unchanged).
Recovery message (Interrupt and adopt). Relay sends one user message with origin relay-recovery before it answers. public/message-renderer.js shows the chip Sent by relay after it took over (class message-origin-chip, test id message-origin-chip) for that origin, beside the existing chief-of-staff chip. Nothing else in the file changes.
Criterion 5: relay's adoptable, eve fails closed
Relay sets adoptable:false with no_project (a folder in no registered project), not_interactive or not_command_line (registry kind or entrypoint). Eve adds no folder or process filter of its own. Eve shows the button only on adoptable === true, so a missing field also gives no button.
Paths
- Simple. Chief of Staff, then the Agents rail (on a phone, the Agents sheet), then the outside row, then
Adopt(idle) orInterrupt and adopt(working). One tap; no other choice. The row becomes a managed idle agent withDrop in. - Power. None in eve: no setting, flag or CLI verb. Relay adds no CLI verb (Adopter interface and the Claude adopter: take over an idle Claude session started outside relay relay#527 decision 13). The waits are relay constants, and relay derives the model from the transcript. A script that must name a model calls relay's door with
claudeSessionIdandmodelon anexecutecredential.
File ownership
| Task | Owns | Allowed edits in shared files |
|---|---|---|
| Task 0: pin bump (eve#604, coordinator) | test/e2e/relay-pin.json only, fakerelay.commit = 2faa42108c59be39e7fd26d81b0a3886acb5eab4 |
none |
| T1 dev | routes/index.js (the adopt route only); public/core/api-client.js (adoptOutside); public/agent-board.js (adopt statics, _withActions outside branch, _actionButton, _reasonLine, the adopted prune); chief-of-staff-outside.js (ROW_FIELDS only); public/message-renderer.js (the relay-recovery chip only); devboxverify/journeys-cos-outside.js (new journey outside-adopt); devboxverify/journeys.js (one list entry after cosOutside.outsideThread); devboxverify/README.md (one outside-adopt section); test/static/frozen.json (one journeys entry); docs/api.md; docs/design-chief-of-staff.md (one paragraph) |
public/apple/agents.css: only .agent-row-wrap / .agent-row__action wrapping, and only if two buttons overflow a 220 px rail or a 412 px phone |
T2 test (eve-test-writer) |
test/e2e/g6-outside-adopt.spec.js (new); docs/FEATURES.md (rows G6.107, G6.108 and one G6.93 clause) |
none |
| Verification | none in this child; the close child eve#598 runs it on the epic head | — |
docs/areas.jsonc: no change. The new files are undertest/**(quiet). Every T1 file is already in an area.test/e2e/support/**is not owned. A needed fixture change comes back as a question.docs/api.md(T1): a row for the new route after the resume row. The outside row field list gainsadoptableandadoptReason. TheclaudeSessionIdsentence becomes: relay serves it on outside rows, it stays on eve's server, and the browser sends only the row id to resume or adopt.docs/FEATURES.md(T2):- New
G6.107Adopt an idle outside session. Simple doorChief of Staff > navigation "Agents" > button "Adopt <name>" (on a phone, the dialog "Agents"). Power doornone. Screen proofthe button reads "Adopting…" until relay answers; the outside row goes and the agent shows under Idle with "Drop in to <name>". Relay proofrelay logs --event session.adoptok;relay audit --event session_adoptok. Refusals: r1not adoptable (no project, on a host, not an interactive command-line session): no button and the reason line; r2relay refuses: toast "Can't adopt: <reason>" and the button is back. Journeyoutside-adopt. Specg6-outside-adopt.spec.js. - New
G6.108Interrupt and adopt a working outside session. Simple door... > button "Interrupt and adopt <name>". Power doornone. Screen proofthe button reads "Interrupting…" until relay answers; the agent shows under Idle with "Drop in to <name>", and its thread starts with relay's message marked "Sent by relay after it took over". Relay proofrelay logs --event session.adoptok withinterrupted;relay audit --event session_interruptok. Refusals r1 and r2 as G6.107. Journeynone yet. Specg6-outside-adopt.spec.js. G6.93: one clause,an adoptable live row also offers Adopt or Interrupt and adopt (G6.107, G6.108).
- New
Acceptance mapping
| # | Criterion | Task, interface | Spec (g6-outside-adopt.spec.js unless named) |
|---|---|---|---|
| 1 | Idle live row in a registered project shows Adopt | T1 showsAdopt idle branch; ROW_FIELDS |
row adoptable:true, state:'idle', project_id:'p_acme': button Adopt <name> in the rail (wide) and the sheet (profiles.phone, box height of 44 px or more); no Interrupt and adopt button; the ended row beside it has Resume only |
| 2 | Busy row shows Interrupt and adopt instead | T1 working branch; route interrupt:true |
running and asking rows: Interrupt and adopt <name>, no Adopt; tap with adopt:{busy:'registry_busy', model_family:'haiku'}: session.adopt ok with interrupted:true; open the new session: its first user message has the chip Sent by relay after it took over |
| 3 | A refusal from relay shows its reason in one line | T1 adoptReason, toast |
row: adopt_reason:'not_registered' gives the line and no button; host_id set gives On a host, so it can't be adopted here.. Door: adopt.refuse:'changed' gives the toast Can't adopt: the process or its registry entry changed on one line, and the button is back; no model_family gives relay's model_unknown text; an idle row with adopt.busy set and Adopt tapped gives the busy text (eve sent interrupt:false) |
| 4 | After adopt, a managed idle headless session with Drop in | T1 flow step 4; relay | session.adopt ok; the outside row leaves the rail; Drop in to <name> shows under Idle in the rail and the sheet; the sheet stayed open |
| 5 | Never on an unregistered folder or a non-interactive CLI row | relay adoptable:false; T1 fail-closed rule |
rows with project_id:null (no_project), adopt_reason:'not_interactive', adopt_reason:'not_command_line', and a row with no adoptable key: no Adopt or Interrupt and adopt button in rail or sheet; each but the last shows its line |
| — | Browser sends only the row id; eve uses the owner-class path | T1 route | spec: the browser request is POST /api/outside-sessions/<id>/adopt with body exactly {"interrupt":false}; the 201 the browser gets has no claudeSessionId; no frame the page receives contains the fixture's conversation id. Selftest: bad id and non-boolean interrupt answer 400 with no relay call; one fetch('POST','/api/sessions/adopt',{outsideId,interrupt},opts) with no scope key in opts; the four answer mappings; a 201 body with claudeSessionId is answered without it |
| — | In flight | T1 adopting |
hold fault on POST /api/sessions/adopt: the button reads Adopting…, disabled, aria-busy; release; the managed row shows |
Fixture rows: ids match ^outside:[0-9a-f]{32}$; adoptable rows carry a UUID claude_session_id. A test changes an outcome with the worlds helper's PUT /v1/outside-sessions. The world needs chief_of_staff set, as g6-outside-resume.spec.js does.
Test surface
- Playwright (
g6-outside-adopt.spec.js, against fakerelay at 2faa421): criteria 1 to 5, the id-only checks, the in-flight state, phone and wide. - Static selftest: none (amendment 1).
- Journey
outside-adopt(T1, simple path, G6.107),claude: true,needs: ['project:acme'],areas: ['chief-of-staff'],timeoutMs: 480000, not a screen journey. Steps:- Start a real outside Claude on Haiku in a scratch folder under Acme with
startClaude, using the stack's broker wiring. - Wait for its row under Idle in the rail with
Adopt <name>. - Tap Adopt.
- PASS when: the eve route answers 201; the outside row leaves;
Drop in to <name>shows under Idle; the tmux pane's claude has exited;relay audit --event session_adoptholds anokcompletion. - Cleanup: delete the managed session, end the tmux session, remove the transcript and the folder.
- Start a real outside Claude on Haiku in a scratch folder under Acme with
- Real-app pass (eve#598's verification, on the epic head): Interrupt and adopt on a real working Claude, and Drop in on the adopted session showing the history. The fakerelay adopted session has run no turn, so its Drop in answers
no_conversation(question 6).
Waits
- Task 0: none; the suite's own exit.
- T1 (product): the adopt POST's promise (completion handle), then
loadSessions()'s promise. No timer. - T1 (journey):
Waits:the outside row's state from the capturedcos_outside_sessionsframes; the Adopt button by locator; the eve route's answer bypage.waitForResponse; the managed row by locator; the pane's exit by the tmux session's end;relay auditby CLI exit.Waits: none possible for Claude's first reply and its folder-trust prompt: model output and a terminal UI have no hook; bounded polls, as outside-thread uses.If the stack's model id is not aclaude-*id, relay answersmodel_unknownand the journey FAILs with that text. - T2 (Playwright): eve and fakerelay boot by the fixture's ready signals (
eve.open(),data-ready); rows and buttons by locators driven by the snapshot push;PUT /v1/outside-sessionsresolves after its push; the hold byrelay.waitForEvent('fakerelay.fault', {match: l => l.action === 'held'}), thenreleased; the adopt byrelay.waitForEvent('session.adopt', ...)with outcomeok,deniedorerror, then the screen; the browser request bypage.waitForRequest; the recovery chip by locator after opening the session. Never a duration. - T2 (selftest): the stub transport's promise.
Break-and-restore
Remove the r.item.adoptable === true test from showsAdopt. The criterion 5 test (the not_interactive and no-adoptable rows) must go red. Restore byte for byte and compare with cmp.
Merge order
- Task 0: the pin PR (eve#604),
test/e2e/relay-pin.jsononly, intoepic/596-session-adoption. feat/597-adoptoff the epic branch after step 1. One PR into the epic branch,Closes #597.- The close child eve#598.
Budget
- Task 0: 1 file, 1 line.
- T1: 11 files (12 with the CSS edit), about 330 changed lines: route 30, api-client 6, agent-board 120 (with the shared builder), outside 1, renderer 8, journey 130, list, frozen and README 25, docs 15.
- T2: 3 files (2 new), about 550 lines: spec 400, selftest 130, feature rows 20.
- Fix attempts: two per task, then one on the default model. Review rounds: two. More files, about 1.5x the lines, or 3x the tokens goes to the coordinator.
Amendments
- Coordinator, 2026-10-20. The static selftest is dropped.
eve-test-writerreads no eve code, and no static selftest runs routes without the full server.- The spec sends a bad id and a non-boolean
interruptto eve's route and expects 400. - The spec checks that fakerelay logs no
session.adoptevent for either 400. - fakerelay refuses the chief-of-staff scope on this door, so a 201 in the spec proves eve sent no scope.
- The spec checks that the 201 body has no
claudeSessionId.
- The spec sends a bad id and a non-boolean
Open questions
- INSIDE. Recommendation: a refusal calls
onOpen(the phone sheet closes, so the toast shows above it); a success does not, so the sheet stays and shows the managed row. This follows Drop in's toast rule (eve#592 question 2). - INSIDE. Recommendation: render the
relay-recoveryorigin as the chipSent by relay after it took over. Adopter interface and the Claude adopter: take over an idle Claude session started outside relay relay#527 question 8 gave this to eve#597. Without it, relay's message reads as if the owner typed it. - INSIDE. Recommendation: Interrupt and adopt takes one tap, with no confirmation. The label names the interrupt, and the criteria ask for a button.
- INSIDE. Recommendation: show relay's
messagefor every door refusal,model_unknownincluded ("...; send model"). Eve sends no model and has no picker. A gentler relay text goes to the coordinator as afrom-devidea, not this child. - INSIDE. Recommendation: extract
_actionButtonand_reasonLinenow. The board has four action buttons, and epic criterion 4 asks for one row-action model. The Drop in, Resume and View read-only DOM stays the same. - INSIDE. Recommendation: criterion 4 is proven by the managed row and its
Drop inbutton. A Drop in on fakerelay's adopted session answersno_conversation(it has run no turn). A real Drop in after adopt is eve#598's real-app pass. - INSIDE. Recommendation:
adoptableandadoptReasoninROW_FIELDSalso reachcos_session_status'ssession. That is information only; the Chief of Staff gets no adopt action. - INSIDE, reported. The new journey and its
frozen.jsonentry add to the harness and remove nothing. PR lineHarness weakening: none.
Activity
barelyworkingcode commentedon Oct 21, 2026
Coordinator approval: owner asked, 2026-10-20. Security story: the owner approved it by name. Depends on barelyworkingcode/relay#527 merging to the relay epic branch and a pin bump.
Merge pull request #604 from barelyworkingcode/chore/597-relay-pin
Merge pull request #606 from barelyworkingcode/feat/597-adopt
barelyworkingcode commentedon Oct 21, 2026
Merged into epic/596-session-adoption by #604 (relay pin) and #606 (Adopt and Interrupt and adopt).
barelyworkingcode commentedon Oct 21, 2026
Done by #606, merged into epic/596-session-adoption. Verified on the epic head 1399a79 (see #598).