Skip to content

Resume an ended outside session in a terminal #593

Closed

Description

@barelyworkingcode

User story

As the owner, I want a Resume button on an ended outside session, so that I can continue its conversation in a terminal I control, from my phone or tablet.

Acceptance criteria

  • An Ended outside row (eve#578) in a registered project shows a Resume button.
  • A tap opens a sandboxed Claude terminal from the claude-code template, in that session's folder, resumed on the same conversation (--resume <session id>), read-write.
  • The terminal shows the conversation's history.
  • A row whose folder is in no registered project shows no button and a one-line reason.
  • A live outside row never shows the button. Two processes must not share one conversation.
  • The resume uses the existing terminal door and the existing sandbox rules. No new authority.

What I'd notice

A finished coordinator or agent shows Resume. One tap and I am typing to it.

Context

Depends on eve#578 and relay#514 (merged). Relay's relay terminal start takes a project, a template and extra arguments, so the terminal door already exists. Outside rows stay read-only: this resumes an ended conversation in a new managed terminal. It does not touch the original process.
Companion to the Drop in issue for managed headless sessions.

Tasks

  • Planner: write the ## Contract.
  • Add the Resume action on Ended rows; start the terminal with --resume.
  • Playwright tests from the criteria. Verification task, last.

Contract

Planner contract, 2026-10-19. Base: main at 8628431. Branch feat/593-resume-ended, one PR to main, Closes #593. Not an epic: it follows the eve#578 pattern (a relay issue, a pin-bump issue, then this PR).

Quality check

The story passes. Criterion 2 says read-write and criterion 6 says the existing sandbox rules; they differ on a read-only project (question 2). Criterion 2 needs a Claude session id that eve does not have today (question 1).

Blocker: eve has no Claude session id

Relay keeps the Claude session id private. An ended row's id is outside: + a hash of (machine, Claude session id), and no list, snapshot, detail or transcript answer carries the id itself. Without it eve cannot build --resume <session id>. The pinned fakerelay (b96e32d) and relay main do not serve it. This PR does not start until the prerequisites in question 1 merge. A pin bump is the only relay move this PR may make, and it is a separate PR.

Approach (smallest change)

  • Relay gives eve the Claude session id on ended rows only (prerequisite).
  • Eve keeps that id on the server. The browser never sees it and never sends a CLI argument.
  • The browser sends only the outside row id. Eve's server finds the row in its own outside snapshot, checks it, and calls relay's existing POST /api/terminals (the door terminal_create and relay terminal start use) with extraArgs: ["--resume", <id>].
  • The browser opens the answer exactly as Drop in does (TerminalManager#openDropIn).
  • No new relay route, scope, template or grant. No new WS frame type, so frozen.wsTypes stays as it is. No CSS change: .agent-row__action already has a 44 px target under pointer: coarse.

Interfaces

Prerequisite wire (relay, question 1). In GET /api/chief-of-staff/outside-sessions, GET .../{id} and the /ws/outside-sessions snapshot, an ended row adds claudeSessionId: the transcript file stem. Live rows never carry it. fakerelay: world key outside_sessions[].claude_session_id, served as claudeSessionId on ended rows. fakerelay's terminal session.launch event line adds template_id, directory and extra_args (array).

Eve server: chief-of-staff-outside.js

// constructor options gain: resolveProject(id) -> cached project | null
// Private: Map<row id, claudeSessionId>, replaced with each accepted snapshot.
// Never in getSnapshot(), browserSnapshot(), list(), detail(), a frame or a cos_* tool result.
async resume(id, { cols, rows }) // -> { status, body }; never throws

resume checks, in this order:

  1. A row with that id is in the current snapshot. Else 404 {error:'outside_session_not_found', message:'That session is no longer in the list.'}.
  2. row.state === 'ended'. Else 409 {error:'outside_session_live', message:'This session is still running. Only an ended session can be resumed.'}.
  3. row.projectId resolves through resolveProject. Else 409 {error:'outside_resume_unavailable', message:"Not in a project, so it can't be resumed."}.
  4. row.hostId is null and the project has no hostId. Else 409 {error:'outside_resume_unavailable', message:"On a host, so it can't be resumed here."}. Relay refuses extraArgs on host terminals.
  5. The stored id matches ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ (case-insensitive). Else 409 {error:'outside_resume_unavailable', message:"Relay gave no conversation id, so it can't be resumed."}. This is the boundary check: the value becomes a CLI argument.
  6. No resume of this row id is in flight on the server. Else 409 {error:'outside_resume_busy', message:'This session is already being resumed.'}.

It then makes one call, through relayTransport.fetch with no scope argument (the same caller as terminal_create):

POST /api/terminals
{ templateId:'claude-code', name:`${row.name} (resumed)`, directory: row.directory,
  projectId: row.projectId, cols, rows, extraArgs:['--resume', <claudeSessionId>] }
  • Relay 2xx with terminalId: 201 {terminal: <relay body>}.
  • Relay 2xx with no terminalId: 502 {error:'resume_failed', message:'Relay did not return a terminal.'}.
  • Relay non-2xx: relay's status, {error: data.code || 'resume_failed', message: data.error || 'Relay answered <status>.'}.
  • Transport throws: 502 {error:'Service unavailable'}.

chief-of-staff.js passes its resolveProject to ChiefOfStaffOutside.

Eve route (routes/index.js, beside the transcript route):
POST /api/outside-sessions/:id/resume, requireAuth, body {cols, rows}.

  • :id fails OUTSIDE_ID: 400 {error:'invalid_request'}.
  • cols or rows is not a whole number from 1 to 500: 400 {error:'cols and rows must be whole numbers from 1 to 500'}.
  • In both cases relay is not called.
  • Otherwise it answers with resumeOutside(id, {cols, rows})'s status and body. registerRoutes gains a dep resumeOutside, which server.js wires to chiefOfStaff.outside.resume.

Browser

  • public/core/api-client.js: resumeOutside(id, {cols, rows}). It refuses an id with no outside: prefix. It POSTs, and a refusal rejects with err.body = {error, message}.
  • public/agent-board.js:
    • static showsResume(r) (on the board class in agent-board.js) is the one rule: r.kind === 'outside' && r.group === 'ended' && r.item.state === 'ended' && r.project && !r.item.hostId && !r.project.hostId.
    • static resumeReason(r) gives the line for an ended outside row with no button: Not in a project, so it can't be resumed. or On a host, so it can't be resumed here.
    • static resuming (a Set of row ids in flight) and static resumed (a Map of row id to the terminalId it opened) are shared by every board, like dropping.
    • static resumeOutside(container, id) never rejects and has no client timeout. It uses DROP_IN_SIZE. On a 201 it calls openDropIn(body.terminal). On a refusal it shows the toast Can't resume: <message> (id resume-<row id>, type error).
  • _endedRow stays a div. The Resume button is its sibling in .agent-row-wrap, as View read-only is.

UI states (Ended row)

Case Shows
Ended, console project button Resume, accessible name Resume <name> in a terminal, test id <prefix>-resume-<id>
Tapped, request open the same button, disabled, aria-busy, text Resuming…
Resumed, its terminal still running the button disabled, text Resumed (cleared when that terminal stops or closes, or the row leaves)
Refused toast Can't resume: <message>; the button is back to Resume
No project, or on a host no button; line [data-kind="resume-reason"], test id <prefix>-resume-reason-<id>, under line 2
Live (running, asking, idle) no button and no reason line, ever

A tap calls onOpen?.(r) first, as Drop in does, so the phone sheet closes. The accessible name must not contain "read-only" (the g6-cos-outside-ended assertions stay green).

Paths

  • Simple: Chief of Staff, then the Agents rail (on a phone, the Agents sheet), then Ended, then Resume. A terminal tab opens on the conversation. The row offers no other choice.
  • Power: no eve config. Relay's CLI is the existing power door: relay terminal start --project <id> --template claude-code --directory <folder> --extra-arg --resume --extra-arg <session id>. The feature row names it.

File ownership

Prerequisites, not this PR: a relay issue (question 1) and an eve pin-bump issue (test/e2e/relay-pin.json only).

T1 dev

  • chief-of-staff-outside.js: private id map, resume.
  • chief-of-staff.js: pass resolveProject (at most 3 lines).
  • routes/index.js: the route. CRLF file: patch in place, then check with grep -c $'\r'.
  • server.js: the resumeOutside dep (at most 3 lines).
  • public/core/api-client.js: resumeOutside.
  • public/agent-board.js: the ended-row block, the new statics, and the _row ended branch only. No edits to showsDropIn, _withDropIn or _withActions (eve#592 owns them).
  • devboxverify/journeys-cos-outside.js: outside-thread. The "no button beside the ended row" check becomes "the only button beside it is Resume". Add steps: tap Resume, the terminal tab shows the marker from the ended conversation, close the terminal.
  • devboxverify/README.md: the outside-thread section.
  • test/static/frozen.json: only the outside-thread timeoutMs, if the added steps need it (600000 to at most 720000).
  • docs/design-chief-of-staff.md: one paragraph. Outside rows stay read-only; Resume starts a new managed terminal.

T2 test

  • test/e2e/g6-outside-resume.spec.js (new).
  • test/static/outside-resume-selftest.js (new; check order, request body, id never in browser frames).
  • docs/FEATURES.md: new row G6.106 "Resume an ended outside session", with refusals r1 (no project), r2 (host) and r3 (relay refuses). G6.104 gains one clause: its only button is Resume (G6.106).
  • docs/api.md: a row for the new route. One sentence in "Outside sessions": claudeSessionId stays on eve's server.
  • docs/areas.jsonc: no change. The new files are under test/**, which is quiet. The route and api-client files are already in core.

T3 verification (last; no files).

Shared with eve#592

Both change public/agent-board.js (eve#592: the Drop in rule and button; this PR: the ended-row block). Both may also change docs/FEATURES.md (different rows), docs/api.md, routes/index.js, test/static/frozen.json and devboxverify. Merge eve#592 first. This PR merges main in before T3.

Acceptance mapping

# Criterion Task / interface Tier
1 Ended row in a project shows Resume T1 showsResume, _endedRow wrap Playwright: wide, profiles.phone, profiles.tablet; height of 44 px or more by boundingBox; journey outside-thread
2 Sandboxed claude-code terminal, in the folder, --resume <id>, read-write T1 resume + route; relay's existing sandbox Playwright: session.launch with template_id:'claude-code', the row's directory, extra_args:['--resume',<id>]; the tab has no Read-only badge and typed text echoes. Selftest: the body. Real-app: relay audit session_launch shows the sandbox.
3 The terminal shows the history relay + claude journey outside-thread, real-app check (fakerelay's echo terminal has no history)
4 No project: no button, one-line reason T1 resumeReason Playwright @G6.106.r1; host row @G6.106.r2
5 Live row never shows the button T1 showsResume; server check 2 Playwright: a live row has none; a row pushed from ended to live loses it. Selftest: 409 outside_session_live and outside_resume_busy.
6 Existing door, no new authority T1 resume selftest: one POST /api/terminals, no scope, no other relay call; review; Playwright @G6.106.r3: relay's refusal shows in the toast

Waits

  • T1 dev (journey): the cos_outside_sessions frame with the ended row; the terminal_created frame; the marker text in the terminal pane (a locator wait); the terminal_closed frame at cleanup. The poll on the Claude TUI's first paint is bounded by the journey timeout. Waits: none possible beyond the pane text: Claude has no ready hook.
  • T2 test (Playwright):
    • Eve and fakerelay boot: the fixture's ready signals.
    • Rows: the locators on screen.
    • Launch: relay.waitForEvent('session.launch', {match}).
    • In flight: ctl fault add --route 'POST /api/terminals' --mode hold, wait for fakerelay.fault action:'held', check Resuming…, ctl fault release, wait for action:'released'.
    • Refusal: an error-mode fault, wait for action:'applied', then the toast text.
    • Ended to live: the worlds helper's PUT /v1/outside-sessions resolves after the push; then the row on screen.
    • Selftest: the stub transport's promise.
  • T3: STACK up line, harness exit, stack-down exit.

Verification task (T3, last, on the PR head after main is merged in)

  1. npm ci, then npx playwright test (repo-suite).
  2. Unlock the devid keychain. stack-up.sh e593 --relay <relay main checkout, with the prerequisite merged> --eve <this worktree>.
  3. node devboxverify/main.js --checkout <this worktree> --stack <stack.json>, then stack-down.sh e593.
  4. real-app: on the box's relay, rebuilt from relay main, end a real Claude session (Haiku) in a project folder. On a phone-sized viewport tap Resume: the terminal shows the earlier turns, and a typed message gets a reply. relay audit shows the session_launch with the sandbox.
  5. Post Verified: <head> · repo-suite: … · devboxverify: … · real-app: … · log: …. No devlock.

Budget

  • T1: 10 files, about 250 changed lines (about 60 of them in the journey).
  • T2: 4 files, about 350 lines.
  • More files or about 1.5x the lines goes to the coordinator.

Open questions

  1. BEYOND. Recommendation: file a relay issue (needs-review, from-dev, "Found while working eve#593"). It adds claudeSessionId to ended rows only (relay and fakerelay) and adds template_id, directory and extra_args to fakerelay's terminal session.launch line. Then an eve pin-bump issue. Order: relay, then the pin bump, then this PR. Rejected: a relay resume route, which is a new door against criterion 6.
  2. INSIDE. Recommendation: a read-only project resumes under its own read-only rules. "Existing sandbox rules, no new authority" outranks "read-write". No extra UI.
  3. INSIDE. Recommendation: a row on an SSH host shows no button and the line On a host, so it can't be resumed here. Relay refuses extraArgs on host terminals.
  4. INSIDE. Recommendation: a project that does not allow claude-code still shows Resume; relay's 403 text shows in the toast. Checking templates first costs a fetch per project.
  5. INSIDE. Recommendation: Resume on two devices, or an outside claude --resume, inside one 15 s probe goes on the theoretical list. The guards are the browser and server in-flight checks and the Resumed state.
  6. INSIDE, reported. Changing outside-thread's no-button check is a harness change. It goes in the run report in bold, with Main harness vs head.
  7. INSIDE. Recommendation: the terminal is named <row name> (resumed), like Drop in's (drop-in).

Amendment (coordinator, 2026-10-19)

  • The new feature row is G6.106. eve#592 takes G6.105.

Activity

  1. barelyworkingcode commented on Oct 20, 2026

    @barelyworkingcode
    OwnerAuthor

    Coordinator approval: owner asked, 2026-10-19 (the owner's number one priority: the Chief of Staff must be highly usable)

  2. barelyworkingcode commented on Oct 20, 2026

    @barelyworkingcode
    OwnerAuthor

    Planning done: the ## Contract is in this issue. Work stops here and waits for the owner review (owner instruction, 2026-10-19).

  3. barelyworkingcode commented on Oct 20, 2026

    @barelyworkingcode
    OwnerAuthor

    Linked as a child of #596 (session adoption epic, eve track). Under the epic its PR targets epic/596-session-adoption, not main; the planner amends the contract for that when the epic opens. Nothing else changes.

  4. barelyworkingcode commented on Oct 20, 2026

    @barelyworkingcode
    OwnerAuthor

    Coordinator approval: owner asked, 2026-10-20. Child of the approved epic eve#596; the PR targets epic/596-session-adoption. Its relay prerequisite, relay#524, merged to epic/525-session-adoption as relay#535.

  5. barelyworkingcode commented on Oct 20, 2026

    @barelyworkingcode
    OwnerAuthor

    Amendment (coordinator, 2026-10-20): epic branch and target

    • Under epic eve#596 this ships as two PRs into epic/596-session-adoption, both Closes #593, as eve#444 did.
    • PR 1, chore/593-relay-pin, edits only test/e2e/relay-pin.json. It names relay's epic head 121e6b8, which carries Outside sessions: claudeSessionId on ended rows (relay#524) relay#535.
    • PR 2, feat/593-resume-ended, branches off epic/596-session-adoption after PR 1 merges and holds T1 and T2.
    • The verification task (T3) moves to the close child eve#598, which verifies the epic head.
    • Nothing else in the contract changes.
  6. added 2 commits that reference this issue on Oct 20, 2026
  7. barelyworkingcode commented on Oct 20, 2026

    @barelyworkingcode
    OwnerAuthor

    Merged into epic/596-session-adoption: #601 (pin), #602 (feature). Left open for the close child eve#598 to close by hand.

  8. barelyworkingcode commented on Oct 21, 2026

    @barelyworkingcode
    OwnerAuthor

    Done by #602, 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 request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions