@bill10/agent-007 0.9.3000 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -35,6 +35,11 @@
35
35
  # same kind of mode.
36
36
  # AGENT_MESSAGING=open
37
37
 
38
+ # After a restart, the board brings back each worker on one of Billion's cards
39
+ # that is still In progress (the worker resumes its own worktree and
40
+ # conversation). 0 leaves them in the orphans list for you to re-spawn.
41
+ # RESPAWN_BOARD_WORKERS=0
42
+
38
43
  # Billion, the always-on agent you talk to (docs/BILLION.md). On by default;
39
44
  # 0/false/off/no turns it off. It is also off whenever user accounts exist.
40
45
  # BILLION=0
package/README.md CHANGED
@@ -51,7 +51,7 @@ Uncomment what you want, then restart. The ones people change:
51
51
  | Setting | What it does |
52
52
  |---------|--------------|
53
53
  | `BILLION=0` | Turns off Billion, the always-on agent |
54
- | `TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_ID` | Billion's questions reach your phone, and you answer from there ([setup](docs/BILLION.md#telegram)) |
54
+ | `TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_ID` | Billion's questions reach your phone as well as the Waiting on you tab, and you answer from either, with a tap when it is a pick ([setup](docs/BILLION.md#telegram)) |
55
55
  | `HOST=0.0.0.0` + `ALLOWED_ORIGINS=<tailnet name>` | Reach it from your phone or another machine (behind Tailscale only, see [docs/REMOTE.md](docs/REMOTE.md)) |
56
56
  | `CLAUDE_PERMISSION_MODE` | Mode Claude Code agents start in, e.g. `bypassPermissions` |
57
57
  | `CODEX_PERMISSION_MODE` | The same for Codex |
@@ -83,14 +83,15 @@ up a stale local base or whatever unrelated branch you happen to have checked
83
83
  out. Override it per agent with **Advanced -> Start from** when you want to
84
84
  branch off work in progress.
85
85
 
86
- The job board reuses that same machinery: a dispatched job is an ordinary agent, with a real terminal you can type into and take over at any point. Each job gets its own worktree and branch, so a job maps one-to-one onto a branch and a pull request. When the agent finishes it calls the board's `finish_job` tool (a card that requires a pull request hands over the PR it opened; one that does not hands over a summary), and the card moves to Review with the agent still running, so you can click straight into it to ask about the work. When the card reaches Done the board closes the agent and releases its worktree and local branch; the PR itself is untouched, and work that was never pushed is kept as an orphan rather than deleted. **Re-spawn** on an orphan picks that conversation back up with the CLI it ran, `codex resume <session-id>` (the newest Codex session recorded in that exact worktree) or `claude --continue`, under the permission mode its job card was dispatched with (a board agent whose card is already finished or deleted follows the board's current setting), or, for an agent you spawned by hand, under the permission flags you started it with. A board worker re-spawned after a restart is its card's worker again: it counts toward the cap, sends its approvals where the card says, is told once to carry on if its card is still In progress, and leaves like any other board worker when the card is filed. When the PR merges the job is filed away as finished -- the record is kept, the card is not.
86
+ The job board reuses that same machinery: a dispatched job is an ordinary agent, with a real terminal you can type into and take over at any point. Each job gets its own worktree and branch, so a job maps one-to-one onto a branch and a pull request. When the agent finishes it calls the board's `finish_job` tool (a card that requires a pull request hands over the PR it opened; one that does not hands over a summary), and the card moves to Review with the agent still running, so you can click straight into it to ask about the work. When the card reaches Done the board closes the agent and releases its worktree and local branch; the PR itself is untouched, and work that was never pushed is kept as an orphan rather than deleted. **Re-spawn** on an orphan picks that conversation back up with the CLI it ran, `codex resume <session-id>` (the newest Codex session recorded in that exact worktree) or `claude --continue`, under the permission mode its job card was dispatched with (a board agent whose card is already finished or deleted follows the board's current setting), or, for an agent you spawned by hand, under the permission flags you started it with. A board worker re-spawned after a restart is its card's worker again: it counts toward the cap, sends its approvals where the card says, is told once to carry on if its card is still In progress, and leaves like any other board worker when the card is filed. Workers on Billion's own cards still In progress come back by themselves after a restart, one every couple of seconds within the per-repo cap (`RESPAWN_BOARD_WORKERS=0` turns that off), and Billion can bring back an orphan on one of its cards with its `respawn_agent` tool. When the PR merges the job is filed away as finished -- the record is kept, the card is not.
87
87
 
88
88
  ```
89
89
  ┌─────────────┬──────────────┬────────────────────┐
90
90
  │ Explorer │ Pixel │ Jobs + Terminals │
91
- │ (repos, │ Office │ (job board tab, │
92
- │ files, │ (canvas, │ xterm.js, one │
93
- │ diffs) │ agents) │ tab per agent) │
91
+ │ (repos, │ Office │ (job board and │
92
+ │ files, │ (canvas, │ Waiting on you │
93
+ │ diffs) │ agents) │ tabs, xterm.js, │
94
+ │ │ │ one tab per agent)│
94
95
  └─────────────┴──────────────┴────────────────────┘
95
96
  ```
96
97
 
@@ -120,6 +121,7 @@ ALLOWED_ORIGINS=mac-mini.tailXXXX.ts.net npm start # Allow a remote browser or
120
121
  | `CLAUDE_PERMISSION_MODE` | *(the CLI's own)* | Permission mode every Claude Code agent the app starts runs in (`auto`, `acceptEdits`, `bypassPermissions`, `manual`, `dontAsk`, `plan`). Flags in the command, a card's own mode or a mode picked in the board's dropdown win over it |
121
122
  | `CODEX_PERMISSION_MODE` | *(the CLI's own)* | The same for Codex agents, mapped onto Codex's sandbox and approval flags |
122
123
  | `AGENT_MESSAGING` | *(guarded)* | `open` lets any of your agents message any other. By default an agent that asks before acting cannot message one that never asks |
124
+ | `RESPAWN_BOARD_WORKERS` | *(on)* | `0` leaves Billion's workers orphaned after a restart. On, each one whose card is still In progress comes back by itself, resuming its own worktree and conversation, within the board's per-repo cap |
123
125
  | `BILLION` | *(on)* | `0` (or `false`/`off`/`no`) turns Billion off. It is also off whenever user accounts exist, since it would belong to everyone |
124
126
  | `BILLION_DIR` | `~/.agent-007/billion` | Billion's own folder and git repo. Point it at a new or empty folder |
125
127
  | `TELEGRAM_BOT_TOKEN` | *(off)* | A Telegram bot's token. Billion's `notify_owner` questions are sent through it, and replies come back into Billion's terminal. See [docs/BILLION.md](docs/BILLION.md#telegram) |
@@ -220,6 +222,7 @@ public/
220
222
  terminal.js xterm.js terminals, clipboard paste, tab management
221
223
  explorer.js File tree, diff viewer, repo management
222
224
  jobs.js Job board UI (columns, cards, the job form)
225
+ waiting.js The Waiting on you tab (Billion's questions, answered by a click or a typed line)
223
226
  ws.js WebSocket client with auto-reload on reconnect
224
227
  state.js Shared client state (agents, repos, viewer identity, server platform, the panel a phone shows)
225
228
  shortcuts.js Keyboard shortcuts
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.9.3.0
1
+ 0.11.0.0
package/bin/agent-007.js CHANGED
@@ -39,6 +39,7 @@ Settings (default in brackets):
39
39
  bypassPermissions, manual, dontAsk, plan [Claude's own]
40
40
  CODEX_PERMISSION_MODE The same for Codex agents [Codex's own]
41
41
  AGENT_MESSAGING open = any agent may message any other [guarded]
42
+ RESPAWN_BOARD_WORKERS 0 = Billion's workers stay orphaned after a restart [on]
42
43
  BILLION 0 turns off Billion, the always-on agent [on]
43
44
  BILLION_DIR Billion's folder and repo [~/.agent-007/billion]
44
45
  TELEGRAM_BOT_TOKEN Bot token for Billion's questions on your phone [off]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bill10/agent-007",
3
- "version": "0.9.3000",
3
+ "version": "0.11.0",
4
4
  "description": "From web terminals for your coding agents to a self-running agent company: Claude Code and Codex in parallel git worktrees, a job board they pick work from, and one agent that runs the board from a goal.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/public/app.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Main init + message routing
2
- import { agents, repos, selfUserId, setSelf, shellPreset, setView, setBillionEnabled, setWaitingItems } from './modules/state.js';
2
+ import { agents, repos, selfUserId, setSelf, shellPreset, setView, setBillionEnabled, setWaitingItems, waitingActive } from './modules/state.js';
3
3
  import { connect, send } from './modules/ws.js';
4
4
  import {
5
5
  handleSessionCreated, handlePtyOutput, handlePtySize, handleStateChange,
@@ -22,6 +22,7 @@ import { setupShortcuts } from './modules/shortcuts.js';
22
22
  import { setupVoice, stopVoice } from './modules/voice.js';
23
23
  import { setupJobBoard, handleJobsList, renderBoard, closeJobForm } from './modules/jobs.js';
24
24
  import { isAbsolutePath, joinBrowsePath } from './modules/paths.js';
25
+ import { renderWaiting, handleWaitingError, showWaiting, leaveWaiting } from './modules/waiting.js';
25
26
  import { captureTokenFromUrl, authHeaders, showLogin, renderPresence, escapeHtml } from './modules/auth.js';
26
27
 
27
28
  // Cross-module coordination: when sessions change, re-render office + explorer
@@ -461,11 +462,14 @@ function setupResize() {
461
462
  });
462
463
  // Phone bottom nav. The shown panel changes size from 0, so refit after reflow.
463
464
  document.getElementById('mobile-nav').onclick = (e) => {
464
- const view = e.target.dataset.view;
465
+ const view = e.target.closest('button')?.dataset.view;
465
466
  if (!view) return;
466
467
  // The mic's only "recording" cue lives in the terminal panel.
467
468
  if (view !== 'terminal') stopVoice({ notice: 'Voice input stopped — left the terminal' });
468
- setView(view);
469
+ // Waiting is the terminal panel showing the Waiting tab.
470
+ if (view === 'waiting') { showWaiting(); updateTabs(); }
471
+ else if (view === 'terminal' && waitingActive) { leaveWaiting(); updateTabs(); }
472
+ setView(view === 'waiting' ? 'terminal' : view);
469
473
  requestAnimationFrame(() => { renderOffice(); fitActiveTerminal(); });
470
474
  };
471
475
  }
@@ -533,7 +537,8 @@ function onMessage(msg) {
533
537
  case 'file-diff': handleFileDiff(msg); break;
534
538
  case 'full-tree': handleFullTree(msg); break;
535
539
  case 'orphans-list': handleOrphansList(msg); break;
536
- case 'waiting-list': setWaitingItems(msg.items); renderExplorer(); break;
540
+ case 'waiting-list': setWaitingItems(msg.items); renderWaiting(); updateTabs(); break;
541
+ case 'waiting-error': handleWaitingError(msg); break;
537
542
  // The office canvas pins one paper per job, so a jobs-list broadcast has
538
543
  // to repaint it too — the animation loop skips frames with no live agent.
539
544
  case 'jobs-list': handleJobsList(msg); noteJobsUpdate(); renderOffice(); break;
package/public/index.html CHANGED
@@ -214,6 +214,10 @@
214
214
  <div class="job-cards" id="job-finished-cards"></div>
215
215
  </div>
216
216
  </div>
217
+ <!-- Waiting on you: Billion's questions (public/modules/waiting.js) -->
218
+ <div class="waiting-board" id="waiting-board" style="display:none">
219
+ <div class="waiting-list" id="waiting-list" aria-label="Waiting on you"></div>
220
+ </div>
217
221
  <div class="voice-indicator" id="voice-indicator" aria-hidden="true" style="display:none">
218
222
  <span class="voice-indicator-dot"></span><span class="voice-indicator-text"></span>
219
223
  </div>
@@ -230,6 +234,7 @@
230
234
  <button data-view="files">Files</button>
231
235
  <button data-view="office" aria-current="true">Office</button>
232
236
  <button data-view="terminal">Terminal</button>
237
+ <button data-view="waiting" class="mobile-nav-waiting" aria-label="Waiting on you"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round" aria-hidden="true"><path d="M3 8.5V5.5a3 3 0 0 1 6 0v3l1 1H2z"/><path d="M5 11h2"/></svg>Waiting<span class="board-tab-badge" id="mobile-nav-waiting-count" hidden></span></button>
233
238
  </nav>
234
239
 
235
240
  <!-- Reconnecting banner -->
@@ -1,5 +1,5 @@
1
1
  // File explorer panel — repo sections, branch trees, inline diffs
2
- import { agents, repos, orphans, activeSessionId, setView, billionEnabled, waitingItems } from './state.js';
2
+ import { agents, repos, orphans, activeSessionId, setView, billionEnabled } from './state.js';
3
3
  import { send } from './ws.js';
4
4
  import { switchToSession } from './terminal.js';
5
5
 
@@ -326,32 +326,6 @@ function renderBillionRow(content) {
326
326
  entry.appendChild(start);
327
327
  }
328
328
  section.appendChild(entry);
329
- // What Billion asked the owner with notify_owner, until they dismiss it.
330
- if (waitingItems.length) {
331
- const list = document.createElement('ul');
332
- list.className = 'explorer-waiting';
333
- list.setAttribute('aria-label', 'Waiting on you');
334
- const head = document.createElement('li');
335
- head.className = 'explorer-waiting-head';
336
- head.textContent = 'Waiting on you';
337
- list.appendChild(head);
338
- for (const item of waitingItems) {
339
- const li = document.createElement('li');
340
- li.className = 'explorer-waiting-item';
341
- const text = document.createElement('span');
342
- text.textContent = item.text;
343
- text.title = item.text;
344
- const dismiss = document.createElement('button');
345
- dismiss.className = 'explorer-icon-btn';
346
- dismiss.textContent = '\u00d7';
347
- dismiss.title = 'Dismiss';
348
- dismiss.setAttribute('aria-label', 'Dismiss');
349
- dismiss.onclick = () => send({ type: 'waiting-dismiss', id: item.id });
350
- li.append(text, dismiss);
351
- list.appendChild(li);
352
- }
353
- section.appendChild(list);
354
- }
355
329
  content.appendChild(section);
356
330
  }
357
331
 
@@ -13,6 +13,8 @@
13
13
  // it was written.
14
14
 
15
15
  import { agents, jobs, boardSettings, setBoardSettings, boardActive, setBoardActive, activeSessionId, repos } from './state.js';
16
+ // Circular with waiting.js, which is fine: each calls the other only at event time.
17
+ import { hideWaiting } from './waiting.js';
16
18
  import { send } from './ws.js';
17
19
  import { escapeHtml, getToken } from './auth.js';
18
20
  import { switchToSession } from './terminal.js';
@@ -871,6 +873,7 @@ export function closeJobForm() {
871
873
  // --- Show / hide ---
872
874
 
873
875
  export function showJobBoard() {
876
+ hideWaiting();
874
877
  // Always open on the columns. Opening the board is the user asking for the
875
878
  // live work; the archive is somewhere you go on purpose, not somewhere the
876
879
  // Jobs tab can strand you.
@@ -28,9 +28,13 @@ export let serverPlatform = ''; // process.platform of the server, from the welc
28
28
  export let billionEnabled = false;
29
29
  export function setBillionEnabled(on) { billionEnabled = !!on; }
30
30
 
31
- // Billion's notify_owner messages the owner has not dismissed (server/owner.js).
31
+ // Billion's notify_owner questions, open and answered, not dismissed
32
+ // (server/owner.js). The "Waiting on you" tab shows them in the terminal
33
+ // viewport, like the job board, and activeSessionId stays put meanwhile too.
32
34
  export let waitingItems = [];
33
35
  export function setWaitingItems(items) { waitingItems = Array.isArray(items) ? items : []; }
36
+ export let waitingActive = false;
37
+ export function setWaitingActive(on) { waitingActive = !!on; }
34
38
 
35
39
  // Billion's tab first, then the rest in their own order.
36
40
  export function billionFirst(entries) {
@@ -62,9 +66,11 @@ export function canControlAgent(agent) {
62
66
 
63
67
  // Which panel a phone shows (body[data-view], see .mobile-nav in style.css).
64
68
  // Set unconditionally: desktop CSS ignores it, so no width check is needed.
69
+ // The Waiting button is the terminal panel showing the Waiting tab.
65
70
  export function setView(view) {
66
71
  document.body.dataset.view = view;
67
- for (const b of document.querySelectorAll('.mobile-nav button')) b.setAttribute('aria-current', b.dataset.view === view);
72
+ const lit = view === 'terminal' && waitingActive ? 'waiting' : view;
73
+ for (const b of document.querySelectorAll('.mobile-nav button')) b.setAttribute('aria-current', b.dataset.view === lit);
68
74
  }
69
75
 
70
76
  export function stateColor(state) {
@@ -1,10 +1,11 @@
1
1
  // Terminal (xterm.js) lifecycle, tabs, session switching, file upload
2
- import { agents, activeSessionId, setActiveSession, stateColor, canControlAgent, boardActive, jobs, billionFirst } from './state.js';
2
+ import { agents, activeSessionId, setActiveSession, stateColor, canControlAgent, boardActive, waitingActive, jobs, billionFirst } from './state.js';
3
3
  import { send } from './ws.js';
4
4
  import { escapeHtml, safeColor } from './auth.js';
5
5
  import { isGlobalShortcut } from './shortcuts.js';
6
6
  import { stopVoice } from './voice.js';
7
7
  import { showJobBoard, hideJobBoard } from './jobs.js';
8
+ import { showWaiting, hideWaiting, openCount } from './waiting.js';
8
9
  // Circular with office.js (it imports switchToSession), which is fine: both
9
10
  // sides only call the other's functions at event time, never during load.
10
11
  import { noteAgentDeparture } from './office.js';
@@ -194,7 +195,7 @@ export async function handleSessionCreated(msg) {
194
195
  // spawns open quietly — the tab dot, the office character and the job card
195
196
  // still announce them, and clicking any of them jumps here. A window showing
196
197
  // nothing takes it, unless that nothing is the job board being worked on.
197
- const stealFocus = focus || replacesActive || (!activeSessionId && !boardActive);
198
+ const stealFocus = focus || replacesActive || (!activeSessionId && !boardActive && !waitingActive);
198
199
  if (stealFocus) switchToSession(sessionId);
199
200
  updateTabs();
200
201
  updateStatusBar();
@@ -312,6 +313,7 @@ export function switchToSession(sessionId) {
312
313
  // agent land in another's shell after a tab switch (or auto-switch on kill).
313
314
  if (sessionId !== activeSessionId) stopVoice({ notice: 'Voice input stopped — switched agents' });
314
315
  hideJobBoard();
316
+ hideWaiting();
315
317
  if (activeSessionId && agents.has(activeSessionId)) {
316
318
  agents.get(activeSessionId).termEl.style.display = 'none';
317
319
  }
@@ -366,6 +368,16 @@ export function removeSession(sessionId) {
366
368
  if (onSessionChanged) onSessionChanged();
367
369
  }
368
370
 
371
+ export const BELL_ICON = '<svg class="board-tab-icon" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"><path d="M3 8.5V5.5a3 3 0 0 1 6 0v3l1 1H2z"/><path d="M5 11h2"/></svg>';
372
+
373
+ // The phone's bottom bar carries the same count.
374
+ function updateNavBadge(open) {
375
+ const badge = document.getElementById('mobile-nav-waiting-count');
376
+ if (!badge) return;
377
+ badge.textContent = open > 0 ? String(open) : '';
378
+ badge.hidden = open === 0;
379
+ }
380
+
369
381
  export function updateTabs() {
370
382
  const container = document.getElementById('terminal-tabs');
371
383
  container.innerHTML = '';
@@ -392,6 +404,24 @@ export function updateTabs() {
392
404
  boardTab.onclick = () => { showJobBoard(); updateTabs(); };
393
405
  container.appendChild(boardTab);
394
406
 
407
+ // Pinned next to it: Billion's questions to the owner, counted like Jobs.
408
+ const waitingTab = document.createElement('div');
409
+ waitingTab.className = `terminal-tab board-tab waiting-tab${waitingActive ? ' active' : ''}`;
410
+ waitingTab.title = 'Waiting on you: Billion\'s questions';
411
+ waitingTab.innerHTML = BELL_ICON;
412
+ waitingTab.appendChild(document.createTextNode('Waiting'));
413
+ const open = openCount();
414
+ if (open > 0) {
415
+ const badge = document.createElement('span');
416
+ badge.className = 'board-tab-badge';
417
+ badge.textContent = String(open);
418
+ badge.title = `${open} question${open === 1 ? '' : 's'} waiting on you`;
419
+ waitingTab.appendChild(badge);
420
+ }
421
+ waitingTab.onclick = () => { showWaiting(); updateTabs(); };
422
+ container.appendChild(waitingTab);
423
+ updateNavBadge(open);
424
+
395
425
  for (const [sessionId, agent] of billionFirst(agents)) {
396
426
  const tab = document.createElement('div');
397
427
  tab.className = `terminal-tab${sessionId === activeSessionId ? ' active' : ''}`;
@@ -0,0 +1,164 @@
1
+ // The "Waiting on you" tab: Billion's notify_owner questions, answered here
2
+ // with a choice or a typed line (server/owner.js answerWaiting). Shares the
3
+ // terminal viewport with the terminals and the job board, like Jobs does.
4
+ import { agents, activeSessionId, waitingItems, waitingActive, setWaitingActive, setView } from './state.js';
5
+ import { send } from './ws.js';
6
+ import { hideJobBoard } from './jobs.js';
7
+
8
+ const ANSWERED_SHOWN = 20;
9
+ const drafts = new Map(); // item id -> reply typed but not sent, kept across re-renders
10
+ const errors = new Map(); // item id -> why the last answer did not go through
11
+ const pending = new Set(); // item ids answered, waiting for the server to say so
12
+ let answeredOpen = false; // the Answered section, as the owner left it
13
+
14
+ export const openCount = () => waitingItems.filter(item => item.status === 'open').length;
15
+
16
+ export function showWaiting() {
17
+ hideJobBoard();
18
+ const agent = activeSessionId ? agents.get(activeSessionId) : null;
19
+ if (agent) agent.termEl.style.display = 'none';
20
+ document.getElementById('terminal-empty').style.display = 'none';
21
+ document.getElementById('waiting-board').style.display = 'flex';
22
+ setWaitingActive(true);
23
+ setView(document.body.dataset.view);
24
+ renderWaiting();
25
+ if (window._onBoardVisibilityChanged) window._onBoardVisibilityChanged();
26
+ }
27
+
28
+ // The phone's Terminal button, pressed while this tab is showing: back to
29
+ // the terminal that was open, or to the empty state.
30
+ export function leaveWaiting() {
31
+ hideWaiting();
32
+ if (!activeSessionId || !agents.has(activeSessionId)) document.getElementById('terminal-empty').style.display = 'flex';
33
+ else agents.get(activeSessionId).termEl.style.display = 'block';
34
+ }
35
+
36
+ export function hideWaiting() {
37
+ const board = document.getElementById('waiting-board');
38
+ if (board) board.style.display = 'none';
39
+ setWaitingActive(false);
40
+ setView(document.body.dataset.view);
41
+ }
42
+
43
+ function answer(item, text) {
44
+ const body = text.trim();
45
+ if (!body || pending.has(item.id)) return;
46
+ errors.delete(item.id);
47
+ if (send({ type: 'waiting-answer', id: item.id, answer: body })) pending.add(item.id);
48
+ else errors.set(item.id, 'Not connected to the server; try again in a moment.');
49
+ renderWaiting();
50
+ }
51
+
52
+ export function handleWaitingError(msg) {
53
+ pending.delete(msg.id);
54
+ errors.set(msg.id, msg.error);
55
+ renderWaiting();
56
+ }
57
+
58
+ const el = (tag, className, text) => {
59
+ const node = document.createElement(tag);
60
+ if (className) node.className = className;
61
+ if (text !== undefined) node.textContent = text;
62
+ return node;
63
+ };
64
+
65
+ function ago(iso) {
66
+ const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
67
+ if (!Number.isFinite(s)) return '';
68
+ if (s < 60) return 'just now';
69
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
70
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
71
+ return `${Math.floor(s / 86400)}d ago`;
72
+ }
73
+
74
+ function openCard(item) {
75
+ const busy = pending.has(item.id);
76
+ const card = el('article', 'waiting-card');
77
+ card.dataset.id = item.id;
78
+ const head = el('div', 'waiting-card-head');
79
+ head.append(el('span', 'waiting-card-n', `Q${item.n}`), el('span', 'waiting-card-time', ago(item.at)));
80
+ const dismiss = el('button', 'explorer-icon-btn waiting-dismiss', '×');
81
+ dismiss.title = 'Dismiss';
82
+ dismiss.setAttribute('aria-label', `Dismiss Q${item.n}`);
83
+ dismiss.onclick = () => send({ type: 'waiting-dismiss', id: item.id });
84
+ head.appendChild(dismiss);
85
+ card.append(head, el('p', 'waiting-card-text', item.text));
86
+
87
+ if (Array.isArray(item.choices) && item.choices.length) {
88
+ const choices = el('div', 'waiting-choices');
89
+ for (const choice of item.choices) {
90
+ const btn = el('button', `waiting-choice${choice === item.recommended ? ' recommended' : ''}`, choice);
91
+ if (choice === item.recommended) {
92
+ btn.appendChild(el('span', 'waiting-choice-tag', 'recommended'));
93
+ btn.setAttribute('aria-label', `${choice} (recommended)`);
94
+ }
95
+ btn.disabled = busy;
96
+ btn.onclick = () => answer(item, choice);
97
+ choices.appendChild(btn);
98
+ }
99
+ card.appendChild(choices);
100
+ }
101
+
102
+ const form = el('form', 'waiting-reply');
103
+ const input = el('input');
104
+ input.type = 'text';
105
+ input.placeholder = item.choices?.length ? 'Or type an answer' : 'Type an answer';
106
+ input.setAttribute('aria-label', `Answer Q${item.n}`);
107
+ input.value = drafts.get(item.id) || '';
108
+ input.disabled = busy;
109
+ input.oninput = () => drafts.set(item.id, input.value);
110
+ const sendBtn = el('button', 'waiting-send', busy ? 'Sending' : 'Send');
111
+ sendBtn.type = 'submit';
112
+ sendBtn.disabled = busy;
113
+ form.onsubmit = (e) => {
114
+ e.preventDefault();
115
+ answer(item, input.value);
116
+ };
117
+ form.append(input, sendBtn);
118
+ card.appendChild(form);
119
+
120
+ if (errors.has(item.id)) {
121
+ const error = el('p', 'waiting-error', errors.get(item.id));
122
+ error.setAttribute('role', 'alert');
123
+ card.appendChild(error);
124
+ }
125
+ return card;
126
+ }
127
+
128
+ function answeredRow(item) {
129
+ const li = el('li', 'waiting-answered-item');
130
+ li.append(el('span', 'waiting-card-n', `Q${item.n}`), el('span', 'waiting-answered-text', item.text));
131
+ const via = item.answeredVia === 'telegram' ? 'Telegram' : 'app';
132
+ li.appendChild(el('span', 'waiting-answered-answer', `→ ${item.answer} · ${via} · ${ago(item.answeredAt)}`));
133
+ return li;
134
+ }
135
+
136
+ export function renderWaiting() {
137
+ const list = document.getElementById('waiting-list');
138
+ if (!list) return;
139
+ // Settled: the server moved it on, so it is no longer ours to wait for.
140
+ for (const id of new Set([...pending, ...errors.keys(), ...drafts.keys()])) {
141
+ if (!waitingItems.some(item => item.id === id && item.status === 'open')) { pending.delete(id); drafts.delete(id); errors.delete(id); }
142
+ }
143
+ if (!waitingActive) return;
144
+ const focused = document.activeElement?.closest?.('.waiting-card')?.dataset.id;
145
+ list.innerHTML = '';
146
+ const open = waitingItems.filter(item => item.status === 'open').reverse();
147
+ if (!open.length) list.appendChild(el('p', 'waiting-empty', 'Nothing waiting on you.'));
148
+ for (const item of open) list.appendChild(openCard(item));
149
+
150
+ const answered = waitingItems.filter(item => item.status === 'answered')
151
+ .sort((a, b) => String(b.answeredAt).localeCompare(String(a.answeredAt))).slice(0, ANSWERED_SHOWN);
152
+ if (answered.length) {
153
+ const section = el('details', 'waiting-answered');
154
+ section.open = answeredOpen;
155
+ section.ontoggle = () => { answeredOpen = section.open; };
156
+ section.appendChild(el('summary', null, `Answered (${answered.length})`));
157
+ const ul = el('ul');
158
+ for (const item of answered) ul.appendChild(answeredRow(item));
159
+ section.appendChild(ul);
160
+ list.appendChild(section);
161
+ }
162
+ // A broadcast re-renders under the owner's fingers; keep their place.
163
+ if (focused) list.querySelector(`.waiting-card[data-id="${CSS.escape(focused)}"] input`)?.focus();
164
+ }
package/public/style.css CHANGED
@@ -752,11 +752,6 @@ body {
752
752
  /* The one way back for a stopped Billion, so it reads as the row's action. */
753
753
  .explorer-billion-start { margin-left: auto; color: var(--accent); }
754
754
  /* notify_owner's questions, under Billion's row until dismissed */
755
- .explorer-waiting { list-style: none; margin: 0 0 4px; padding: 0 10px 0 22px; font-size: 11px; }
756
- .explorer-waiting-head { color: var(--accent); font-weight: 600; padding: 2px 0; }
757
- .explorer-waiting-item { display: flex; align-items: flex-start; gap: 4px; color: var(--text-muted); padding: 2px 0; }
758
- .explorer-waiting-item span { flex: 1; min-width: 0; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
759
- .explorer-waiting-item button { flex-shrink: 0; }
760
755
 
761
756
  .explorer-branch-name {
762
757
  overflow: hidden;
@@ -1181,7 +1176,7 @@ body {
1181
1176
  .mobile-nav { display: none; }
1182
1177
  @media (max-width: 700px) {
1183
1178
  .divider, .explorer-reopen-btn, #btn-toggle-explorer { display: none !important; }
1184
- .explorer-billion-start, .explorer-waiting-item button { min-width: 44px; min-height: 44px; }
1179
+ .explorer-billion-start, .waiting-dismiss { min-width: 44px; min-height: 44px; }
1185
1180
  #explorer-panel, #office-panel, #terminal-panel {
1186
1181
  display: none; width: 100% !important; min-width: 0; max-width: none; flex: 1;
1187
1182
  }
@@ -1207,6 +1202,10 @@ body {
1207
1202
  cursor: pointer;
1208
1203
  }
1209
1204
  .mobile-nav button:hover, .mobile-nav button[aria-current="true"] { color: var(--accent); }
1205
+ .mobile-nav-waiting { display: inline-flex; align-items: center; justify-content: center; gap: 5px; }
1206
+ .mobile-nav-waiting .board-tab-badge { letter-spacing: 0; }
1207
+ .waiting-choice, .waiting-send { min-height: 44px; }
1208
+ .waiting-reply input { min-height: 44px; font-size: 16px; }
1210
1209
  /* Overlays sized for desktop */
1211
1210
  .spawn-form-inner, .dir-browser-inner { width: auto; max-width: 100%; }
1212
1211
  .spawn-form-main, .spawn-presets { flex-wrap: wrap; }
@@ -1302,6 +1301,117 @@ body {
1302
1301
  text-align: center;
1303
1302
  }
1304
1303
 
1304
+ /* ============================================================
1305
+ Waiting on you
1306
+ Billion's questions to the owner. A pinned tab next to Jobs,
1307
+ same viewport, same badge; each open question is a card with
1308
+ its choices and a reply line.
1309
+ ============================================================ */
1310
+
1311
+ .waiting-board {
1312
+ position: absolute;
1313
+ inset: 0;
1314
+ display: flex;
1315
+ flex-direction: column;
1316
+ background: var(--bg-panel);
1317
+ overflow-y: auto;
1318
+ z-index: 5;
1319
+ }
1320
+ .waiting-list {
1321
+ display: flex;
1322
+ flex-direction: column;
1323
+ gap: 10px;
1324
+ width: 100%;
1325
+ max-width: 720px;
1326
+ padding: 14px 12px 24px;
1327
+ }
1328
+ .waiting-empty { color: var(--text-dim); font-size: 12px; padding: 24px 2px; }
1329
+
1330
+ .waiting-card {
1331
+ display: flex;
1332
+ flex-direction: column;
1333
+ gap: 8px;
1334
+ padding: 10px 12px;
1335
+ background: var(--bg-dark);
1336
+ border: 1px solid var(--border);
1337
+ border-left: 2px solid var(--state-message);
1338
+ border-radius: 3px;
1339
+ }
1340
+ .waiting-card-head { display: flex; align-items: center; gap: 8px; }
1341
+ .waiting-card-n { color: var(--accent); font-size: 11px; font-weight: 700; flex-shrink: 0; }
1342
+ .waiting-card-time { color: var(--text-dim); font-size: 10px; flex: 1; }
1343
+ .waiting-card-text {
1344
+ margin: 0;
1345
+ color: var(--text);
1346
+ font-size: 12px;
1347
+ line-height: 1.5;
1348
+ white-space: pre-wrap;
1349
+ overflow-wrap: anywhere;
1350
+ }
1351
+
1352
+ .waiting-choices { display: flex; flex-wrap: wrap; gap: 6px; }
1353
+ .waiting-choice {
1354
+ display: inline-flex;
1355
+ align-items: center;
1356
+ gap: 6px;
1357
+ padding: 5px 12px;
1358
+ font-size: 12px;
1359
+ font-family: inherit;
1360
+ color: var(--text);
1361
+ background: transparent;
1362
+ border: 1px solid var(--border);
1363
+ border-radius: 3px;
1364
+ cursor: pointer;
1365
+ }
1366
+ .waiting-choice:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
1367
+ .waiting-choice.recommended { border-color: var(--accent); }
1368
+ .waiting-choice-tag {
1369
+ color: var(--accent);
1370
+ font-size: 9px;
1371
+ letter-spacing: 1px;
1372
+ text-transform: uppercase;
1373
+ }
1374
+
1375
+ .waiting-reply { display: flex; gap: 6px; }
1376
+ .waiting-reply input {
1377
+ flex: 1;
1378
+ min-width: 0;
1379
+ padding: 5px 8px;
1380
+ font-size: 12px;
1381
+ font-family: inherit;
1382
+ color: var(--text);
1383
+ background: var(--bg-panel);
1384
+ border: 1px solid var(--border);
1385
+ border-radius: 3px;
1386
+ }
1387
+ .waiting-reply input:focus { outline: none; border-color: var(--accent); }
1388
+ .waiting-send {
1389
+ padding: 5px 12px;
1390
+ font-size: 11px;
1391
+ font-family: inherit;
1392
+ color: var(--accent-contrast);
1393
+ background: var(--accent);
1394
+ border: none;
1395
+ border-radius: 3px;
1396
+ cursor: pointer;
1397
+ }
1398
+ .waiting-card :disabled { opacity: 0.55; cursor: default; }
1399
+ .waiting-error { margin: 0; color: var(--state-disconnected); font-size: 11px; }
1400
+
1401
+ .waiting-answered { color: var(--text-muted); font-size: 11px; margin-top: 6px; }
1402
+ .waiting-answered summary {
1403
+ cursor: pointer;
1404
+ color: var(--text-dim);
1405
+ font-size: 9px;
1406
+ letter-spacing: 2px;
1407
+ text-transform: uppercase;
1408
+ padding: 4px 0;
1409
+ }
1410
+ .waiting-answered ul { list-style: none; margin: 4px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
1411
+ .waiting-answered-item { display: grid; grid-template-columns: auto 1fr; column-gap: 8px; row-gap: 2px; }
1412
+ .waiting-answered-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1413
+ .waiting-answered-answer { grid-column: 2; color: var(--text-dim); overflow-wrap: anywhere; }
1414
+
1305
1415
  /* An agent the board created, not you. Subtle — it is still an ordinary agent
1306
1416
  you can type into; the marker only explains where it came from. */
1307
1417
  .terminal-tab.board-spawned .dot {
package/server/http.js CHANGED
@@ -91,7 +91,7 @@ export function requireAgent(req, res, next) {
91
91
  }
92
92
 
93
93
  // --- Routes ---
94
- export function setupRoutes(app, staticDir, { broadcast, killSession } = {}) {
94
+ export function setupRoutes(app, staticDir, { broadcast, killSession, respawnAgent } = {}) {
95
95
  app.use(express_static(staticDir));
96
96
 
97
97
  // --- POST /mcp — the board's MCP server ---
@@ -130,14 +130,17 @@ export function setupRoutes(app, staticDir, { broadcast, killSession } = {}) {
130
130
  answerPermission: ({ id, decision, reason }) => (req.agentSession.isBillion
131
131
  ? answerApproval(id, decision, reason)
132
132
  : { error: 'Only Billion answers permission requests.' }),
133
- notifyOwner: (text) => (req.agentSession.isBillion
134
- ? notifyOwner(text, { broadcast })
133
+ notifyOwner: (text, { choices, recommended } = {}) => (req.agentSession.isBillion
134
+ ? notifyOwner(text, { choices, recommended, broadcast })
135
135
  : { error: 'Only Billion can notify the owner.' }),
136
136
  // Never logged: a screen can hold a secret that scrolled by.
137
137
  readAgentScreen: ({ name, lines }) => readAgentScreen({
138
138
  from: req.agentSession, name, lines, sessions,
139
139
  isBillionCard: (jobId) => allJobs().some(job => job.id === jobId && job.postedByBillion),
140
140
  }),
141
+ respawnAgent: ({ name }) => (respawnAgent
142
+ ? respawnAgent(req.agentSession, name)
143
+ : { error: 'Re-spawning is not available.' }),
141
144
  billionReady: () => {
142
145
  const session = req.agentSession;
143
146
  if (!session.isBillion) return { error: 'Only Billion has an inbox to open.' };