Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<line> (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 "<agent name>" | 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 <agent name>" | `relay drop-in <session-id>` | 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: <reason>"; 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 <tab label>" | 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 <agent name>" | `relay drop-in <session-id>` | 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: <reason>"; 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 <tab label>" | 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 "<n> 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 "<n> running" | none | none | none yet | none yet: #369 |
Expand All @@ -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 "<agent name>" | 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 <n>" | 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 <agent name>" | `relay drop-in <session-id>` | A terminal tab opens on the same conversation | `relay logs --event session.drop_in` ok | r1: relay refuses: toast "Can't drop in: <reason>"; 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 <agent name>" or the phone dialog "Agents" | `relay drop-in <session-id>` | A terminal tab opens on the same conversation | `relay logs --event session.drop_in` ok | r1: relay refuses: toast "Can't drop in: <reason>"; 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 |
Expand Down Expand Up @@ -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 <n>"; when no rule fits, a notice reads "No rule covers <label>'s last turn; it waits for you." and the digest lists the agent as "no rule matched"; with a keep-going rule for that agent its text goes instead | Relay audit `chief_of_staff.send` per send | r1: the model fails, is off or is at its daily limit: nothing from the rule is sent; r2: a match naming another agent's rule or another trigger is ignored; a match turn counts one model call | none yet | g6-cos-delegation-match.spec.js |
| G6.103 | Clear the Chief of Staff thread and start afresh | Chief of Staff > textbox "Tell an agent" > type "/clear" > press Return | none | The thread empties to "Nothing yet. I'll post here when an agent needs you.", except cards still waiting on a tap, which still act; the next answer remembers nothing from before | `relay logs --event session.delete` names each Chief of Staff session | r1: a delegation is active: "A delegation is active, so I didn't clear the thread. Stop it on the bar first, then type /clear again." and the thread stays; r2: the archive can't be written: "I couldn't archive the thread, so I didn't clear it. Try again." and the thread stays | cos-clear | g6-cos-clear.spec.js |
| G6.104 | See recently ended outside sessions | Chief of Staff > navigation "Agents" > heading "Ended" (or the phone dialog "Agents") | Relay `settings.json` `outside_sessions.ended_window_days` | Ended lists ended outside rows newest first; each row shows name, `Outside · machine · folder · Ended`, its age and its last turn; it is not a button and has no View read-only; ended rows add nothing to need you or Watching | The relay outside snapshot (`state:"ended"`, `endedAt`) | r1: relay lists no ended rows: no Ended heading and no error | outside-thread | g6-cos-outside-ended.spec.js |
| G6.105 | Take over a working agent after its turn | Today or Chief of Staff > button "Take over <agent name> after this turn" | `relay drop-in <session-id>` | the button reads "Waiting for the turn to end…" until relay answers, then a terminal tab opens on the same conversation | `relay logs --event session.drop_in` ok | r1: relay refuses (a tool is running, a terminal already has it, the turn did not end): toast "Can't drop in: <reason>"; r2: this page's own drop-in terminal holds it: no button; r3: outside row: no button | none yet | g6-drop-in.spec.js |


### G7 · Read and edit project files
Expand Down
8 changes: 8 additions & 0 deletions docs/design-workbench.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
58 changes: 40 additions & 18 deletions public/agent-board.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions public/apple/agents.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
Loading