From e4cfed2f401215b1e197a69bd441b01487aa5a56 Mon Sep 17 00:00:00 2001 From: barelyworkingcode Date: Mon, 19 Oct 2026 20:44:09 -0700 Subject: [PATCH 01/17] Drop in from an idle headless session; Take over after the turn from a working one (#592) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/design-workbench.md | 8 ++++++ public/agent-board.js | 58 +++++++++++++++++++++++++++------------- public/apple/agents.css | 3 +++ 3 files changed, 51 insertions(+), 18 deletions(-) diff --git a/docs/design-workbench.md b/docs/design-workbench.md index 8348e2ec..21c96327 100644 --- a/docs/design-workbench.md +++ b/docs/design-workbench.md @@ -245,3 +245,11 @@ The board becomes the shared component for a third mount, the Chief of Staff rai - `onOpen(row)`: called after a row tap or Drop in. - **Rail headers.** In the rail layout each group header has `tabindex="-1"` and the test id `{p}-agents-group-{key}-head`, so "N need you" can scroll to it and focus it. - **Touch.** Rail rows and the Done toggle are at least 44px high under `(pointer: coarse)`. + +## Amended by eve#592 + +- **Rule.** `AgentBoard.showsDropIn(r)`: a headless Claude session row (never an outside row) shows the button in Needs you and Idle, and in Working unless this page's own drop-in terminal holds it. Done and Ended never show it. +- **Labels.** Needs you and Idle: `Drop in` (`{p}-drop-in-{id}`, busy `Dropping in…`). Working: `Take over after this turn` (`{p}-take-over-{id}`, accessible name `Take over {label} after this turn`, busy `Waiting for the turn to end…`). It is the same POST; relay waits for the turn. The busy text follows the kind fixed at the tap. +- **`held`.** `AgentBoard.held` maps a session id to the terminal a drop-in on this page opened. `collect()` drops an entry once that terminal is gone or stopped. Another page or device still shows Take over and gets relay's `dropped_in` refusal. +- **`onOpen` timing.** A row's Drop in or Take over calls `onOpen(row)` when relay answers (terminal or refusal), not at the tap, so the phone sheet stays open on the waiting text. A row tap is unchanged. +- **Refusal.** The existing error toast, text collapsed to one line. diff --git a/public/agent-board.js b/public/agent-board.js index 41a849fb..fa688f5b 100644 --- a/public/agent-board.js +++ b/public/agent-board.js @@ -157,12 +157,16 @@ class AgentBoard { // relay's deriveSessionKind: these models are Claude sessions. static CLAUDE_MODELS = ['haiku', 'sonnet', 'opus']; static DROP_IN_SIZE = { cols: 80, rows: 24 }; - // Session ids with a drop-in in flight, shared by every board. - static dropping = new Set(); + // sessionId -> 'drop' | 'wait': a drop-in in flight, shared by every board. The kind is + // fixed at the tap, so the busy text does not change when the row changes group. + static dropping = new Map(); + // sessionId -> terminalId of a drop-in this page opened. While that terminal lives, this + // page does not offer Take over: relay reads the held session as running and would refuse. + static held = new Map(); // Rail and sheet options (eve#274): `maxRows` (Infinity: no cap), `layout` ('board' or // 'rail'), `collapseDone`, `note(sessionId)` for a row's third line, `onCounts` (group - // counts, or null while offline or loading) and `onOpen(row)` after a tap or Drop in. + // counts, or null while offline or loading) and `onOpen(row)` after a tap, or when relay answers a Drop in or Take over. constructor({ container, testidPrefix, showProject, filter, onCount, maxRows = AgentBoard.MAX_ROWS, layout = 'board', collapseDone = false, @@ -276,28 +280,40 @@ class AgentBoard { return AgentBoard.CLAUDE_MODELS.includes(session?.model); } - // The one place the Drop in rule lives: a headless Claude session that needs me. + // The one place the Drop in rule lives: a headless Claude session that needs me or is + // idle, or a working one (Take over after the turn) that this page does not already hold. static showsDropIn(r) { - return r.kind === 'session' && AgentBoard.isClaude(r.item) && r.item.headless === true - && (r.group === 'needs' || AgentBoard.dropping.has(r.id)); + if (r.kind !== 'session' || !AgentBoard.isClaude(r.item) || r.item.headless !== true) return false; + return AgentBoard.dropping.has(r.id) || r.group === 'needs' || r.group === 'idle' + || (r.group === 'working' && !AgentBoard.held.has(r.id)); } // Never rejects. No client timeout: relay bounds the wait, and giving up sooner // would orphan a terminal it already launched. - static async dropIn(container, sessionId) { + // `wait` marks "Take over after this turn": the same POST, relay waits for the turn. + // `onAnswer` runs when relay answers (terminal or refusal), before the terminal opens. + static async dropIn(container, sessionId, { wait = false, onAnswer = null } = {}) { if (sessionId?.startsWith('outside:')) return; if (AgentBoard.dropping.has(sessionId)) return; - AgentBoard.dropping.add(sessionId); + AgentBoard.dropping.set(sessionId, wait ? 'wait' : 'drop'); const renderAll = () => { for (const b of AgentBoard.live) b.render(); }; const toast = (message) => container.get('bus').emit(EVT.TOAST_SHOW, { - id: `drop-in-${sessionId}`, message, type: 'error', duration: 8000, + id: `drop-in-${sessionId}`, message: message.replace(/\s+/g, ' ').trim(), type: 'error', duration: 8000, }); renderAll(); try { const body = await container.get('api').dropIn(sessionId, AgentBoard.DROP_IN_SIZE); - if (body?.terminal?.terminalId) AgentBoard._termMgr(container)?.openDropIn(body.terminal); - else toast("Can't drop in: relay did not return a terminal."); + const terminal = body?.terminal; + if (terminal?.terminalId) { + AgentBoard.held.set(sessionId, terminal.terminalId); + onAnswer?.(); + AgentBoard._termMgr(container)?.openDropIn(terminal); + } else { + onAnswer?.(); + toast("Can't drop in: relay did not return a terminal."); + } } catch (err) { + onAnswer?.(); toast(`Can't drop in: ${err?.body?.message || err?.body?.error || "relay isn't reachable"}`); } finally { AgentBoard.dropping.delete(sessionId); @@ -342,6 +358,10 @@ class AgentBoard { const add = (kind, item, id, st, label, project, since = '', exit = null, group = AgentBoard.groupOf(st)) => { if (filter(item, project)) rows.push({ kind, id, state: st, group, label, project, since, exit, item }); }; + for (const [sid, tid] of AgentBoard.held) { + const t = mgr?.allTerminals.get(tid); + if (!t || t.state === 'stopped') AgentBoard.held.delete(sid); + } for (const t of mgr ? mgr.allTerminals.values() : []) { if (state.isTaskRun(t.id) || t.outside === true) continue; const project = AgentBoard._projectOf(state, t); @@ -618,17 +638,19 @@ class AgentBoard { const act = document.createElement('button'); act.type = 'button'; act.className = 'agent-row__action'; - act.dataset.testid = `${this.prefix}-drop-in-${id}`; - act.setAttribute('aria-label', `Drop in to ${label}`); - if (AgentBoard.dropping.has(id)) { + const kind = AgentBoard.dropping.get(id); + const wait = kind ? kind === 'wait' : r.group === 'working'; + act.dataset.testid = `${this.prefix}-${wait ? 'take-over' : 'drop-in'}-${id}`; + act.setAttribute('aria-label', wait ? `Take over ${label} after this turn` : `Drop in to ${label}`); + if (kind) { act.disabled = true; act.setAttribute('aria-busy', 'true'); - act.textContent = 'Dropping in…'; + act.textContent = wait ? 'Waiting for the turn to end…' : 'Dropping in…'; } else { - act.textContent = 'Drop in'; + act.textContent = wait ? 'Take over after this turn' : 'Drop in'; + // onOpen fires when relay answers, so a sheet stays open on the waiting text. act.addEventListener('click', () => { - this.onOpen?.(r); - AgentBoard.dropIn(this.container, id); + AgentBoard.dropIn(this.container, id, { wait, onAnswer: () => this.onOpen?.(r) }); }); } wrap.append(row, act); diff --git a/public/apple/agents.css b/public/apple/agents.css index 2df2159c..0212c7f3 100644 --- a/public/apple/agents.css +++ b/public/apple/agents.css @@ -54,6 +54,9 @@ font-family: var(--font-ui); font-size: 12px; cursor: pointer; + max-width: calc(100% - 28px); + text-align: left; + overflow-wrap: anywhere; } .agent-row__action:hover:not(:disabled) { background: var(--bg-hover); } .agent-row__action:focus-visible { outline: none; box-shadow: var(--focus-ring); } From 1a1b4e59875346faa9ede27d139e760b964df53e Mon Sep 17 00:00:00 2001 From: barelyworkingcode Date: Tue, 20 Oct 2026 09:42:59 -0700 Subject: [PATCH 02/17] Test: drop in from idle, take over after the turn, refusals, outside rows (#592) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/FEATURES.md | 7 +- test/e2e/g6-drop-in.spec.js | 409 ++++++++++++++++++++++++++++++++++++ 2 files changed, 413 insertions(+), 3 deletions(-) create mode 100644 test/e2e/g6-drop-in.spec.js diff --git a/docs/FEATURES.md b/docs/FEATURES.md index ab5a9836..610d1b99 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -369,8 +369,8 @@ Code: public/task-viewer.js, public/routine-audit.js, public/routine-history.js, | G6.20 | See each agent's state on the board | Today | `relay session list` | Under "Agents", groups "Needs you", "Working", "Idle" and "Done" show counts and a row per agent with a state dot; an errored agent's row adds its provider's last stderr line and exit code, like ` (exit 127)` | `relay session list` shows the same sessions | r1: relay down: text "Can't reach relay."; r2: none: text "No agents running" | agent-board-states | g6-board.spec.js, g6-exit.spec.js | | G6.21 | Open an agent's thread from the board | Today > button "" | none | The agent's thread opens as a tab | none | none | none yet | g6-board.spec.js | | G6.22 | See an agent's state change within moments | n/a: background | n/a: background | A row moves between groups, for example from "Working" to "Needs you", within about two seconds | none | none | agent-board-states | g6-board.spec.js | -| G6.23 | Drop in to an agent that is waiting on me | Today > button "Drop in to " | `relay drop-in ` | A terminal tab opens on the same conversation; the button read "Dropping in…" while relay worked | `relay logs --event session.drop_in` ok; `relay terminal list` shows it | r1: relay refuses: toast "Can't drop in: "; r2: no terminal back: toast "Can't drop in: relay did not return a terminal."; r3: not a headless Claude session: no button | agent-drop-in | devbox: agent-drop-in | -| G6.24 | Close a dropped-in terminal to hand the agent back | Terminal > button "Close " | none | The tab closes and the agent is back under its old state on the board | none: the hand-back is not in the proof list | none | agent-drop-in | devbox: agent-drop-in | +| G6.23 | Drop in to an agent that is waiting on me or idle | Today > button "Drop in to " | `relay drop-in ` | A terminal tab opens on the same conversation; the button read "Dropping in…" while relay worked; the agent shows under "Needs you" or "Idle" | `relay logs --event session.drop_in` ok; `relay terminal list` shows it | r1: relay refuses: toast "Can't drop in: "; r2: no terminal back: toast "Can't drop in: relay did not return a terminal."; r3: not a headless Claude session, or an outside row: no button | agent-drop-in | g6-drop-in.spec.js, devbox: agent-drop-in | +| G6.24 | Close a dropped-in terminal to hand the agent back | Terminal > button "Close " | none | The tab closes and the agent is back under "Idle" with its Drop in button | none: the hand-back is not in the proof list | none | agent-drop-in | g6-drop-in.spec.js, devbox: agent-drop-in | | G6.25 | See how many agents need me on a phone | Bottom bar > button "Today" | none | The button "Today" carries a red count read as " need you" | none | r1: none need me: no count shows | none yet | g6-board.spec.js | | G6.26 | See which threads are running right now | Today | none | Under "Running", a button per running thread or routine shows a dot titled "Running" | none | r1: nothing running: text "Nothing running." | none yet | g6-board.spec.js | | G6.27 | See a running dot on a project chip | Today | none | The project button shows a dot titled " running" | none | none | none yet | none yet: #369 | @@ -387,7 +387,7 @@ Code: public/task-viewer.js, public/routine-audit.js, public/routine-history.js, | G6.38 | See every agent in the rail to the right of the thread | Chief of Staff > navigation "Agents" | `relay session list` | On a window wider than 900px the rail sits to the right of the thread, 280px wide by default. Headings "Needs you", "Working", "Idle" and "Done"; each row shows name, age, project and state words, and a last line (for an errored agent, its exit line) | `relay session list` shows the same sessions | r1: relay down: text "Can't reach relay." | cos-agent-rail | g6-cos.spec.js, g6-exit.spec.js | | G6.39 | Open an agent from a rail row | Chief of Staff > navigation "Agents" > button "" | none | The agent's thread, or its terminal, opens as a tab | none | none | cos-agent-rail | g6-cos.spec.js | | G6.40 | Show the finished agents in the rail | Chief of Staff > button "Done " | none | The "Done" group expands and lists finished agents; a second press collapses it | none | none | none yet | g6-cos.spec.js | -| G6.41 | Drop in from a rail row | Chief of Staff > button "Drop in to " | `relay drop-in ` | A terminal tab opens on the same conversation | `relay logs --event session.drop_in` ok | r1: relay refuses: toast "Can't drop in: "; r2: not a headless Claude session: no button | none yet | none yet: #369 | +| G6.41 | Drop in from a rail row | Chief of Staff > button "Drop in to " or the phone dialog "Agents" | `relay drop-in ` | A terminal tab opens on the same conversation | `relay logs --event session.drop_in` ok | r1: relay refuses: toast "Can't drop in: "; r2: not a headless Claude session: no button | none yet | g6-drop-in.spec.js | | G6.42 | See an idle agent's summary under its rail row | n/a: background | n/a: background | The row's third line shows a one-line summary once the agent's turn ends; no post appears in the thread | none | none | cos-agent-rail | g6-cos.spec.js | | G6.43 | Open the agents sheet on a phone | Chief of Staff > resize to 390x844 > button "Agents" (its name starts with the counts, like "2 idle Agents") | none | A dialog "Agents" opens with the same rows; the strip shows red, amber and green counts | none | r1: relay down: no counts show | cos-agent-rail | g6-cos.spec.js | | G6.44 | Close the agents sheet | Chief of Staff > dialog "Agents" > button "Close" | none | The sheet closes and focus returns to the strip; Escape, a tap outside or a drag down does the same | none | none | none yet | g6-cos.spec.js | @@ -451,6 +451,7 @@ Code: public/task-viewer.js, public/routine-audit.js, public/routine-history.js, | G6.102 | Answer an agent only when its question fits my condition | Chief of Staff > card, after Approve > say it in the instruction ("say continue to Migration if it asks to proceed") | none | When a listed session ends a turn on a question and a reply rule with a condition fits it, the post names the agent and the rule's text with the chip "Sent for you · rule "; when no rule fits, a notice reads "No rule covers