Repository navigation
Description
User story
As the owner, I want a Drop in button on an idle headless Claude session, so that I can take it over and type to it from my phone or tablet.
Acceptance criteria
- A headless Claude session in the Idle group shows the Drop in button, in the board and in the Chief of Staff agents list.
- A headless session in the Working group shows a "Take over after this turn" button. It waits for the turn to end, as relay's handoff does.
- A tap opens the terminal on the session with its history and read-write input.
- When the terminal closes, the session returns to the board as idle and headless.
- A refusal from relay (a tool is open, another terminal holds it, no conversation yet) shows its reason in one line.
- The button never shows on a read-only outside row.
What I'd notice
Any idle agent I started through relay has a Drop in button, and the Chief of Staff list does too.
Context
AgentBoard.showsDropIn in public/agent-board.js allows the button only in the Needs you group (asking, errored, stalled). Relay's POST /api/sessions/{id}/drop-in already takes over any headless Claude session, including one with no live process (docs/session-host.md, Drop-in). No relay change is needed.
Outside sessions stay read-only by design. The next issue covers resuming an ended one.
Tasks
- Planner: write the
## Contract. - Widen the rule; add the Take over after this turn state; show the refusal reason.
- Playwright tests from the criteria; update the feature map row. Verification task, last.
Contract
Standalone story. Branch feat/592-drop-in-idle off main; the PR targets main with Closes #592. Three tasks: T1 dev, T2 tests and feature map, T3 verification (last).
Relay side: no change, no pin bump
The pinned fakerelay (test/e2e/relay-pin.json, relay b96e32d) already serves everything this story calls:
POST /api/sessions/{id}/drop-inon an idle headless Claude session, also one with no live process.- A running turn: the request waits on the session's own wake signal (no timer), then checks again.
- Refusals
dropped_in,tool_running(a pending permission),no_conversation,not_headless,not_claude,session_not_found,template_unavailable. - Hand-back: when the drop-in terminal ends, the hold clears and a
session_stateidleframe goes out with a newsince.
Two limits, both accepted (questions 3 and 4): turn_timeout is not faked, and haiku/sonnet/opus always reply echo. So no hermetic spec can hold a Claude turn running. relay main adds nothing to drop-in since the pin, so a pin bump would gain nothing. Relay is not touched.
Interfaces
Wire (unchanged; docs/api.md row already correct, no edit):
- Request:
POST /api/sessions/:id/drop-inwith{"cols":80,"rows":24}. Eve forwards it to relay throughproxy()with no client timeout. - Success
201:{sessionId, claudeSessionId, host?, terminal: {terminalId, templateId, name, directory, host}}.terminal.nameis<session name> (drop-in). - Refusal:
{error, message}with relay's status.messageis the reason text, and relay is its only source. Real relay codes:session_not_found404;not_claude,not_headless,dropped_in,tool_running,turn_timeout,no_conversation,host_unavailable,attach_failed409; a terminal-launch refusal such astemplate_unavailable403. - Pinned fakerelay texts the specs may assert:
dropped_in:a terminal already has this session; close it firsttool_running:a tool is running (<tool>); wait for it to finish or stop the turn, then try againno_conversation:the session has not run a turn yet; there is nothing to take over
- Eve's own answers: an
outside:id gets403 {error:"outside_session_read_only", message:"Outside sessions are read-only."}; relay down gets502 {"error":"Service unavailable"}. - "After this turn" is the same POST. Relay waits for the turn to end (up to 60 s in real relay), so eve adds no second call and no client timer.
- Hand-back: eve makes no call. Closing the tab sends the ordinary
terminal_close. Relay hands the session back, and thesession_stateidleframe moves the row to Idle. The row stays headless (headless: trueon the list row).
public/agent-board.js (T1):
static dropping = new Map(); // sessionId -> 'drop' | 'wait'; was a Set. Kind is fixed at tap.
static held = new Map(); // sessionId -> terminalId of a drop-in this page opened
static showsDropIn(r) // the one rule, see below
static async dropIn(container, sessionId, { wait = false, onAnswer = null } = {}) // never rejects- Rule.
showsDropIn(r)is true when all of these hold:r.kind === 'session',isClaude(r.item)andr.item.headless === true;- and one of these:
dropping.has(r.id); groupneedsoridle; or groupworkingand!held.has(r.id).
Outside rows (kind 'outside') never pass._withActionskeeps sending them to_withViewonly. Done and Ended never pass.
held.dropInaddssessionId -> terminal.terminalIdon a 201.collect()(or a render step in the same file) deletes an entry once that terminal is missing frommgr.allTerminalsor hasstate === 'stopped'.- Effect: this page never offers "Take over" on a session its own open terminal holds. Another page or device still sees the button and gets relay's
dropped_inrefusal.
dropInflow.- Set
droppingtowait ? 'wait' : 'drop'and re-render every live board. - POST.
- On 201 with a
terminalId: setheld, callonAnswer?.(), thenopenDropIn(terminal). - Otherwise: call
onAnswer?.()and show the toast. - Finally: delete from
droppingand re-render.
The CoS alert-card callAgentBoard.dropIn(container, id)keeps working unchanged (kinddrop).
- Set
onOpentiming (changed). A row's Drop in or Take over callsonOpen(r)throughonAnswer, when relay answers (terminal or refusal), not at the tap. Only the phone sheet passesonOpentoday, so only the sheet changes: it stays open and shows the waiting state, then closes when relay answers. A row tap is unchanged.- Toast (refusal, unchanged id
drop-in-<id>, type error, 8 s). The text is one line, made by collapsing whitespace runs to one space and trimming:Can't drop in: <message>, elseCan't drop in: <error>, elseCan't drop in: relay isn't reachable;- with no terminal back:
Can't drop in: relay did not return a terminal.
UI states (sibling .agent-row__action under the row, as today):
| Row | Visible text | Accessible name | testid | Busy text (disabled, aria-busy="true") |
|---|---|---|---|---|
| Needs you, Idle | Drop in |
Drop in to <label> |
<p>-drop-in-<id> |
Dropping in… |
Working (running, starting) |
Take over after this turn |
Take over <label> after this turn |
<p>-take-over-<id> |
Waiting for the turn to end… |
- The busy text follows the kind fixed at tap, not the row's current group. A Take over whose turn ends keeps reading
Waiting for the turn to end…under Idle until relay answers. - Prefixes are
today, the project page's,railandsheet. All four come fromAgentBoard, so the Chief of Staff list gets the button with no edit tocos-agent-rail.jsorchief-of-staff-page.js. - Touch. The existing
(pointer: coarse)rule gives.agent-row__actiona 44x44 px minimum. The longer label may wrap; it must not overflow the rail or sheet.
Paths
- Simple: Today's Agents board, a project page, or Chief of Staff > Agents (the rail on wide; on a phone or tablet the strip opens the sheet). Tap
Drop inon an idle agent, orTake over after this turnon a working one. A terminal tab opens on the conversation. - Power:
relay drop-in <session-id>(relay CLI, existing; same wait and refusals). Eve adds no config or flag.
File ownership
| Task | Owns | Others may |
|---|---|---|
| T1 dev | public/agent-board.js; docs/design-workbench.md (append ## Amended by eve#592: rule, labels, held, onOpen timing) |
— |
| T1 dev, shared | public/apple/agents.css: only .agent-row__action wrapping/max-width if the long label overflows at 220 px rail or 412 px phone |
nothing else in the file |
| T2 tests | test/e2e/g6-drop-in.spec.js (new); docs/FEATURES.md: rows G6.23, G6.24, G6.41 and a new row G6.105 |
— |
| T3 verify | no files; posts the Verified: line |
— |
docs/areas.jsoncneeds no edit:test/**anddocs/**arequiet, and T1 adds no file.docs/api.mdneeds no edit: no route or frame changes.test/e2e/support/**is not owned. A needed fixture change comes back as a question.
FEATURES.md edits (T2):
- G6.23: Action becomes
Drop in to an agent that is waiting on me or idle. Addidleto the screen proof. r3 becomesnot a headless Claude session, or an outside row: no button. Spec becomesg6-drop-in.spec.js; keep the devbox journey. - G6.24: Screen proof becomes
The tab closes and the agent is back under "Idle" with its Drop in button. Spec becomesg6-drop-in.spec.js. - G6.41: The simple door adds
or the phone dialog "Agents". Spec becomesg6-drop-in.spec.js, replacingnone yet: #369. - New G6.105,
Take over a working agent after its turn:- Simple door:
Today or Chief of Staff > button "Take over <agent name> after this turn". - Power door:
relay drop-in <session-id>. - Screen proof:
the button reads "Waiting for the turn to end…" until relay answers, then a terminal tab opens on the same conversation. - Relay proof:
relay logs --event session.drop_inok. - Refusals: r1
relay refuses (a tool is running, a terminal already has it, the turn did not end): toast "Can't drop in: <reason>"; r2this page's own drop-in terminal holds it: no button; r3outside row: no button. - Journey:
none yet. Spec:g6-drop-in.spec.js.
- Simple door:
Acceptance mapping
| # | Criterion | Task · interface | Spec |
|---|---|---|---|
| 1 | Idle headless Claude shows Drop in, board and CoS list | T1 · showsDropIn idle branch |
idle row on Today and in the rail: button Drop in to <name>; a non-headless idle thread has none |
| 2 | Working shows "Take over after this turn", waits for the turn | T1 · working branch, dropping kind wait, dropIn({wait:true}) |
Page B sees the row under Working while page A holds it; fault --mode slow on the drop-in route; tap shows Waiting for the turn to end…, disabled; page A closes its tab; release; page B's terminal opens |
| 3 | Tap opens the terminal with history and read-write input | T1 · openDropIn(terminal) (existing) |
tab <name> (drop-in) selected; type into Terminal input and the echo shows; no Read-only badge. History: T3 real-app only (fake drop-in is an echo terminal) |
| 4 | Terminal closes, session returns idle and headless | relay hand-back + existing AgentAttention |
button Close <name> (drop-in); row under Idle with Drop in to <name> again |
| 5 | Refusal reason in one line | T1 · toast text rule | (a) page B taps Take over while page A holds it: Can't drop in: a terminal already has this session; close it first; (b) a --mode error --status 409 fault with the tool_running body; (c) the same with no_conversation. Each toast is one line |
| 6 | No button on a read-only outside row | T1 · kind 'outside' never passes |
world outside_sessions with an idle and a working row: no Drop in and no Take over button in rail or sheet |
| — | Touch, phone and tablet | CSS (existing) | test.use(profiles.phone) in the CoS sheet: tap() Drop in, the sheet closes, the tab opens, and the button box is at least 44 px high; one Take over in the sheet with the slow fault: the sheet stays open with the waiting text. One profiles.tablet Today-board tap |
How to get rows: an idle headless Claude session comes from a Chief of Staff Start card with an entry (a chat template or Claude agent on model haiku). A model-only start is not tracked in the pinned fakerelay, so it never reaches the board. Working is reached only by another page's (eve.openAnother) drop-in hold (question 4).
Test surface
- Playwright (
g6-drop-in.spec.js): criteria 1, 2, 3 (input), 4, 5 and 6, plus the touch rows. Unit and integration: none (the repo has no unit tier). - Real-app pass (T3): criterion 3's history, and relay's real turn wait for criterion 2.
Waits
- T1: none needed. The waiting state ends on the POST's own promise (completion handle). T1 adds no timer.
- T2: each wait uses one of these signals, never a duration:
- eve boot:
eve.open()(<html data-ready="1">); - an agent's turn ended: button
Drop in to <name>visible (driven by thesession_stateidle frame); - the slow fault holding:
relay.waitForEvent('fakerelay.fault', { match: l => l.action === 'held' }); - drop-in done:
relay.waitForEvent('session.drop_in', { match: l => l.status === 'ok' }), then the tab selected; - refusal:
session.drop_inwith statusdenied, then the toast text; - hand-back:
session.exitedfor the terminal id, then the Idle button visible; - echo: the typed text visible in the terminal.
- eve boot:
- T3:
stack-upprintsSTACK up e592 <stack.json>; each harness run waits on its own process exit; the real-app check waits on the screen (button, tab, typed echo).
Verification task (T3, last; takes no devlock)
On the PR head, after main is merged in:
- Unlock the devid keychain. Run
npm ciin the worktree. - Run
npx playwright test(repo suite). ./stack-up.sh e592 --relay <relay main checkout> --eve <this worktree>.node devboxverify/main.js --checkout <this worktree> --stack <stack.json>.- Real-app check, before stack-down, on the stack's eve at a touch viewport (tablet or phone):
- Start a real headless Claude agent on Haiku with a one-line prompt and wait for it under Idle.
- Tap
Drop in: the terminal opens on the same conversation with its earlier turn, and a typed line reaches Claude. - Close the tab: the row is back under Idle with
Drop in. - Then one attempt at
Take over after this turnduring a turn. Record which one shows: the waiting text, then the terminal; or a one-line refusal.
./stack-down.sh e592.- Post
Verified: <head sha> · repo-suite: <result> · devboxverify: <result> · real-app: <result> · log: <path>. A push after it reruns this task.
Budget
- T1: 2 files (3 if the CSS wrap is needed), about 50 to 90 changed lines.
- T2: 2 files, about 220 to 300 lines of spec and 6 row edits.
- T3: no diff.
Going past either is a question for the coordinator.
Open questions
- INSIDE. Recommend hiding "Take over" on a session this page's own drop-in terminal holds (
held). Relay reads a held session asrunning, so without this my own board would offer a take-over that relay refuses. - INSIDE. Recommend keeping the existing error toast for the refusal, collapsed to one line, and closing the sheet when relay answers so the toast is not under the modal. Not a new inline row line.
- INSIDE. Recommend proving "with its history" by the T3 real-app check only. The fakerelay drop-in is an echo terminal with no transcript.
- INSIDE. Recommend accepting that the hermetic Working path is a page-B-while-page-A-holds setup plus a slow fault. The pinned fakerelay cannot hold a
haiku/sonnet/opusturn, and adding that would be a relay change, which the issue rules out. Relay's real turn wait is tried in T3. - INSIDE. Recommend no devboxverify journey change.
agent-drop-instays G6.23's journey (Needs you path), and T3's real-app check covers the idle path. A journey edit would be a harness change outside the task list. - INSIDE. Recommend showing "Take over after this turn" for the whole Working group,
startingincluded. Relay answers a session with no turn yet withno_conversation, shown as the one-line refusal.
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.
barelyworkingcode commentedon Oct 20, 2026
Contract amendment (coordinator, 2026-10-20): under epic eve#596, branch
feat/592-drop-in-idleis cut fromepic/596-session-adoption, notmain. The PR targetsepic/596-session-adoptionwithCloses #592. The verification task (T3) moves to the epic's close child; a PR into the epic branch needs none. Nothing else in the contract changes.Merge pull request #599 from barelyworkingcode/feat/592-drop-in-idle
barelyworkingcode commentedon Oct 20, 2026
Merged into epic/596-session-adoption by #599. Left open: the epic's close child closes its children by hand. Real-app check of history and relay's real turn wait goes to the epic's verification task (contract T3).
barelyworkingcode commentedon Oct 21, 2026
Done by #599, merged into epic/596-session-adoption. Verified on the epic head 1399a79 (see #598).