Repository navigation
Description
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-codetemplate, 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 doorterminal_createandrelay terminal startuse) withextraArgs: ["--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.wsTypesstays as it is. No CSS change:.agent-row__actionalready has a 44 px target underpointer: 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 throwsresume checks, in this order:
- 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.'}. row.state === 'ended'. Else409 {error:'outside_session_live', message:'This session is still running. Only an ended session can be resumed.'}.row.projectIdresolves throughresolveProject. Else409 {error:'outside_resume_unavailable', message:"Not in a project, so it can't be resumed."}.row.hostIdis null and the project has nohostId. Else409 {error:'outside_resume_unavailable', message:"On a host, so it can't be resumed here."}. Relay refusesextraArgson host terminals.- 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). Else409 {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. - 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}.
:idfailsOUTSIDE_ID:400 {error:'invalid_request'}.colsorrowsis 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.registerRoutesgains a depresumeOutside, whichserver.jswires tochiefOfStaff.outside.resume.
Browser
public/core/api-client.js:resumeOutside(id, {cols, rows}). It refuses an id with nooutside:prefix. It POSTs, and a refusal rejects witherr.body = {error, message}.public/agent-board.js:static showsResume(r)(on the board class inagent-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.orOn a host, so it can't be resumed here.static resuming(a Set of row ids in flight) andstatic resumed(a Map of row id to the terminalId it opened) are shared by every board, likedropping.static resumeOutside(container, id)never rejects and has no client timeout. It usesDROP_IN_SIZE. On a 201 it callsopenDropIn(body.terminal). On a refusal it shows the toastCan't resume: <message>(idresume-<row id>, type error).
_endedRowstays 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: passresolveProject(at most 3 lines).routes/index.js: the route. CRLF file: patch in place, then check withgrep -c $'\r'.server.js: theresumeOutsidedep (at most 3 lines).public/core/api-client.js:resumeOutside.public/agent-board.js: the ended-row block, the new statics, and the_rowended branch only. No edits toshowsDropIn,_withDropInor_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: theoutside-threadsection.test/static/frozen.json: only theoutside-threadtimeoutMs, 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":claudeSessionIdstays on eve's server.docs/areas.jsonc: no change. The new files are undertest/**, which isquiet. The route and api-client files are already incore.
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_sessionsframe with the ended row; theterminal_createdframe; the marker text in the terminal pane (a locator wait); theterminal_closedframe 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 forfakerelay.faultaction:'held', checkResuming…,ctl fault release, wait foraction:'released'. - Refusal: an error-mode fault, wait for
action:'applied', then the toast text. - Ended to live: the worlds helper's
PUT /v1/outside-sessionsresolves after the push; then the row on screen. - Selftest: the stub transport's promise.
- T3:
STACK upline, harness exit,stack-downexit.
Verification task (T3, last, on the PR head after main is merged in)
npm ci, thennpx playwright test(repo-suite).- Unlock the devid keychain.
stack-up.sh e593 --relay <relay main checkout, with the prerequisite merged> --eve <this worktree>. node devboxverify/main.js --checkout <this worktree> --stack <stack.json>, thenstack-down.sh e593.- 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 auditshows the session_launch with the sandbox. - 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
- BEYOND. Recommendation: file a relay issue (
needs-review,from-dev, "Found while working eve#593"). It addsclaudeSessionIdto ended rows only (relay and fakerelay) and addstemplate_id,directoryandextra_argsto fakerelay's terminalsession.launchline. 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. - 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.
- 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 refusesextraArgson host terminals. - INSIDE. Recommendation: a project that does not allow
claude-codestill shows Resume; relay's 403 text shows in the toast. Checking templates first costs a fetch per project. - 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 theResumedstate. - INSIDE, reported. Changing
outside-thread's no-button check is a harness change. It goes in the run report in bold, withMain harness vs head. - 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
barelyworkingcode commentedon Oct 20, 2026
Coordinator approval: owner asked, 2026-10-19 (the owner's number one priority: the Chief of Staff must be highly usable)
barelyworkingcode commentedon Oct 20, 2026
Planning done: the
## Contractis in this issue. Work stops here and waits for the owner review (owner instruction, 2026-10-19).barelyworkingcode commentedon Oct 20, 2026
Linked as a child of #596 (session adoption epic, eve track). Under the epic its PR targets
epic/596-session-adoption, notmain; the planner amends the contract for that when the epic opens. Nothing else changes.barelyworkingcode commentedon Oct 20, 2026
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.
barelyworkingcode commentedon Oct 20, 2026
Amendment (coordinator, 2026-10-20): epic branch and target
epic/596-session-adoption, bothCloses #593, as eve#444 did.chore/593-relay-pin, edits onlytest/e2e/relay-pin.json. It names relay's epic head 121e6b8, which carries Outside sessions: claudeSessionId on ended rows (relay#524) relay#535.feat/593-resume-ended, branches offepic/596-session-adoptionafter PR 1 merges and holds T1 and T2.Merge pull request #601 from barelyworkingcode/chore/593-relay-pin
Merge pull request #602 from barelyworkingcode/feat/593-resume-ended
barelyworkingcode commentedon Oct 20, 2026
Merged into
epic/596-session-adoption: #601 (pin), #602 (feature). Left open for the close child eve#598 to close by hand.barelyworkingcode commentedon Oct 21, 2026
Done by #602, merged into epic/596-session-adoption. Verified on the epic head 1399a79 (see #598).