Skip to content

Adopt an idle outside session from the agents board #597

Closed

Description

@barelyworkingcode

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/adopt is class execute, socket only. It is the same class as POST /api/sessions/{id}/drop-in. With X-Relay-Scope: chief-of-staff it answers 403 Forbidden.
  • Every outside row carries adoptable. A row that is not adoptable carries adoptReason.
  • fakerelay serves both fields, the door, and a world adopt object 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, through relayTransport.fetch with no scope option. 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.wsTypes does not change.

Interfaces

Relay wire (2faa421, unchanged, for reference)

  • Request: {"outsideId":"outside:<32 hex>","interrupt":<bool>}. Eve never sends claudeSessionId or model.
  • 201 {sessionId, claudeSessionId, interrupted, recoverySent}.
  • Refusal {error:<code>, message:<one line>}:
    • 400 invalid_body; 413 body_too_large; 404 outside_session_not_found;
    • 409 an adoptReason code, managed, busy, model_unknown, adopt_in_progress, changed;
    • 403/400 a launch-authorization code; 502 unavailable, stop_failed, launch_failed; 503 audit_unavailable.
  • 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. Outcome ok; denied below 500, error at 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}.

  1. :id does not match ^outside:[0-9a-f]{32}$: 400 {error:'invalid_request'}. Relay is not called.
  2. interrupt is not exactly true or false: 400 {error:'interrupt must be true or false'}. Relay is not called. Other body keys are ignored and never forwarded.
  3. One call: relayTransport.fetch('POST', '/api/sessions/adopt', { outsideId: id, interrupt }, { traceId: req.traceId }). No scope key. This is the code path proxy() uses for Drop in: eve's frontend-socket peer identity, class execute.
  4. Answers:
    • Relay 201 with a string sessionId: 201 {sessionId, interrupted: data.interrupted === true, recoverySent: data.recoverySent === true}. claudeSessionId is dropped: no outside conversation id reaches the browser (the eve#593 promise in docs/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'}.
  5. 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_FIELDS adds adoptable and adoptReason. Nothing else changes.
  • claudeIds still holds ended rows only. A live row's claudeSessionId stays 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 no outside: prefix. A refusal rejects with err.body = {error, message}, as resumeOutside does.

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.state is idle, running or asking; r.item.adoptable === true; !r.item.hostId. An absent or non-true adoptable gives no button (fail closed). idle gives Adopt. running or asking gives Interrupt and adopt.
  • Reason. adoptReason(r) applies to a live outside row with adoptable === false and a string adoptReason. 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 adoptable key gets no line.
  • Flow of adoptOutside.
    1. Return at once if the id has no outside: prefix or is in adopting.
    2. Set adopting to the kind and re-render every live board.
    3. Call api.adoptOutside(id, {interrupt}).
    4. On 201 with a sessionId: set adopted, then await container.get('app').loadSessions(). The managed session then reaches the board through AgentAttention (the list row carries attention). The page does not call onOpen: the sheet stays open and shows the result.
    5. On a refusal: call onRefuse?.() (the row passes () => this.onOpen?.(r), so the phone sheet closes and the toast shows), then the toast.
    6. Finally: delete from adopting and re-render.
  • Toast. Id adopt-<row id>, type error, 8 s. Text Can't adopt: <message>, else <error>, else relay 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 an adopted entry once its row is no longer in outsideRows(), beside the resumed prune.
  • 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.js and g6-cos-outside*.spec.js stay 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) or Interrupt and adopt (working). One tap; no other choice. The row becomes a managed idle agent with Drop 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 claudeSessionId and model on an execute credential.

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 under test/** (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 gains adoptable and adoptReason. The claudeSessionId sentence 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.107 Adopt an idle outside session. Simple door Chief of Staff > navigation "Agents" > button "Adopt <name>" (on a phone, the dialog "Agents"). Power door none. Screen proof the button reads "Adopting…" until relay answers; the outside row goes and the agent shows under Idle with "Drop in to <name>". Relay proof relay logs --event session.adopt ok; relay audit --event session_adopt ok. Refusals: r1 not adoptable (no project, on a host, not an interactive command-line session): no button and the reason line; r2 relay refuses: toast "Can't adopt: <reason>" and the button is back. Journey outside-adopt. Spec g6-outside-adopt.spec.js.
    • New G6.108 Interrupt and adopt a working outside session. Simple door ... > button "Interrupt and adopt <name>". Power door none. Screen proof the 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 proof relay logs --event session.adopt ok with interrupted; relay audit --event session_interrupt ok. Refusals r1 and r2 as G6.107. Journey none yet. Spec g6-outside-adopt.spec.js.
    • G6.93: one clause, an adoptable live row also offers Adopt or Interrupt and adopt (G6.107, G6.108).

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:
    1. Start a real outside Claude on Haiku in a scratch folder under Acme with startClaude, using the stack's broker wiring.
    2. Wait for its row under Idle in the rail with Adopt <name>.
    3. Tap Adopt.
    4. 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_adopt holds an ok completion.
    5. Cleanup: delete the managed session, end the tmux session, remove the transcript and the folder.
  • 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 captured cos_outside_sessions frames; the Adopt button by locator; the eve route's answer by page.waitForResponse; the managed row by locator; the pane's exit by the tmux session's end; relay audit by 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 a claude-* id, relay answers model_unknown and 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-sessions resolves after its push; the hold by relay.waitForEvent('fakerelay.fault', {match: l => l.action === 'held'}), then released; the adopt by relay.waitForEvent('session.adopt', ...) with outcome ok, denied or error, then the screen; the browser request by page.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

  1. Task 0: the pin PR (eve#604), test/e2e/relay-pin.json only, into epic/596-session-adoption.
  2. feat/597-adopt off the epic branch after step 1. One PR into the epic branch, Closes #597.
  3. 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

  1. Coordinator, 2026-10-20. The static selftest is dropped. eve-test-writer reads no eve code, and no static selftest runs routes without the full server.
    • The spec sends a bad id and a non-boolean interrupt to eve's route and expects 400.
    • The spec checks that fakerelay logs no session.adopt event 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.

Open questions

  1. 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).
  2. INSIDE. Recommendation: render the relay-recovery origin as the chip Sent 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.
  3. INSIDE. Recommendation: Interrupt and adopt takes one tap, with no confirmation. The label names the interrupt, and the criteria ask for a button.
  4. INSIDE. Recommendation: show relay's message for every door refusal, model_unknown included ("...; send model"). Eve sends no model and has no picker. A gentler relay text goes to the coordinator as a from-dev idea, not this child.
  5. INSIDE. Recommendation: extract _actionButton and _reasonLine now. 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.
  6. INSIDE. Recommendation: criterion 4 is proven by the managed row and its Drop in button. A Drop in on fakerelay's adopted session answers no_conversation (it has run no turn). A real Drop in after adopt is eve#598's real-app pass.
  7. INSIDE. Recommendation: adoptable and adoptReason in ROW_FIELDS also reach cos_session_status's session. That is information only; the Chief of Staff gets no adopt action.
  8. INSIDE, reported. The new journey and its frozen.json entry add to the harness and remove nothing. PR line Harness weakening: none.

Activity

  1. barelyworkingcode commented on Oct 21, 2026

    @barelyworkingcode
    OwnerAuthor

    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.

  2. added 2 commits that reference this issue on Oct 21, 2026
  3. barelyworkingcode commented on Oct 21, 2026

    @barelyworkingcode
    OwnerAuthor

    Merged into epic/596-session-adoption by #604 (relay pin) and #606 (Adopt and Interrupt and adopt).

  4. barelyworkingcode commented on Oct 21, 2026

    @barelyworkingcode
    OwnerAuthor

    Done by #606, merged into epic/596-session-adoption. Verified on the epic head 1399a79 (see #598).

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