@bill10/agent-007 0.10.0 → 0.12.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/README.md CHANGED
@@ -13,7 +13,7 @@ Use it as far along as you need:
13
13
 
14
14
  1. **One task: one agent in a web terminal.** Start Claude Code, Codex or any CLI agent in the browser. Add a repo once; every agent gets its own git worktree and branch, so you never set one up by hand.
15
15
  2. **Many tasks across projects: many terminals, one window.** Every repo and every agent in one place, with live terminals, a file explorer, inline diffs, and a pixel office where each agent faces its screen while it works and turns to you when it needs you.
16
- 3. **Stop watching them: a job board.** Put tasks on the board and Claude Code or Codex workers pick them up, each in its own worktree, and move them To do -> In progress -> Review on their own, landing as a pull request (or a summary, for work that isn't code). Cards can run on a cron schedule, and agents can post cards and message each other.
16
+ 3. **Stop watching them: a job board.** Put tasks on the board and Claude Code or Codex workers pick them up, each in its own worktree, and move them To do -> In progress -> Review on their own, landing as a pull request (or a summary, for work that isn't code). Cards can run on a cron schedule and pick their model (strong for hard code, fast for docs), from the models the board finds installed, and agents can post cards and message each other.
17
17
  4. **Stop posting jobs: give Billion a goal.** Billion is one always-on agent that plans, posts the jobs, reviews what comes back and merges the PRs. It only asks you about money, access or anything irreversible.
18
18
 
19
19
  Claude Code, Codex, any terminal agent is supported -- use your existing subscriptions, no extra charge.
@@ -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 |
@@ -88,9 +88,10 @@ The job board reuses that same machinery: a dispatched job is an ordinary agent,
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
 
@@ -221,6 +222,7 @@ public/
221
222
  terminal.js xterm.js terminals, clipboard paste, tab management
222
223
  explorer.js File tree, diff viewer, repo management
223
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)
224
226
  ws.js WebSocket client with auto-reload on reconnect
225
227
  state.js Shared client state (agents, repos, viewer identity, server platform, the panel a phone shows)
226
228
  shortcuts.js Keyboard shortcuts
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.10.0.0
1
+ 0.12.0.0
package/lib/jobs.js CHANGED
@@ -395,17 +395,48 @@ export function isCodexSessionId(value) {
395
395
  // known — it is the board's current word. With neither, the CLI's own
396
396
  // default applies. Both go through their allowlists here, so this is the one
397
397
  // place that guarantees nothing but a permission reaches the argv.
398
- export function resumeCommand(agent, mode, flags, sessionId) {
398
+ //
399
+ // `model` is the card's model, so a worker picked for a strong model does not
400
+ // come back on the CLI's default.
401
+ export function resumeCommand(agent, mode, flags, sessionId, model = null) {
399
402
  const validMode = isValidPermissionMode(mode) ? mode : null;
400
403
  const own = validMode ? '' : normalizePermissionFlags(agent === 'codex' ? 'codex' : 'claude', flags).join(' ');
404
+ const m = modelFlag(agent === 'codex' ? 'codex' : 'claude', model);
401
405
  if (agent === 'codex') {
402
406
  const flag = validMode ? CODEX_MODE_FLAGS[validMode] : own;
403
407
  // The id reaches the argv, so only a real session id passes.
404
408
  const target = isCodexSessionId(sessionId) ? ` ${sessionId}` : '';
405
- return `codex resume${target}${flag ? ` ${flag}` : ''}`;
409
+ return `codex resume${target}${flag ? ` ${flag}` : ''}${m ? ` ${m}` : ''}`;
406
410
  }
407
411
  const flag = validMode ? `--permission-mode ${validMode}` : own;
408
- return `claude --continue${flag ? ` ${flag}` : ''}`;
412
+ return `claude --continue${flag ? ` ${flag}` : ''}${m ? ` ${m}` : ''}`;
413
+ }
414
+
415
+ // A card's model: a name its CLI's --model / -m takes. It reaches the argv, so
416
+ // besides being one the server discovered (server/models.js) it must be a
417
+ // single plain token — no space, quote or leading dash.
418
+ export function isSafeModelName(model) {
419
+ return typeof model === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:\[\]-]{0,99}$/.test(model);
420
+ }
421
+
422
+ // Empty is null: the CLI's own default. `available` is the discovered list per
423
+ // CLI; left out (a schedule's run, copying a model its schedule already
424
+ // passed), only the shape is checked.
425
+ export function resolveJobModel(model, agent, available) {
426
+ if (model === undefined || model === null || model === '') return { model: null };
427
+ const list = available ? (available[agent] || []) : null;
428
+ if (!isSafeModelName(model) || (list && !list.includes(model))) {
429
+ const shown = typeof model === 'string' ? `"${model.slice(0, 40)}"` : `a ${typeof model}`;
430
+ return { error: `Unknown model ${shown} for ${agent} \u2014 expected one of: ${list?.length ? list.join(', ') : '(none discovered; leave it empty for the CLI default)'}` };
431
+ }
432
+ return { model };
433
+ }
434
+
435
+ // The model flag for that CLI, or nothing for the default. Quoted though the
436
+ // name is already a plain token, the same as every other value put on the argv.
437
+ export function modelFlag(agent, model) {
438
+ if (!isSafeModelName(model)) return '';
439
+ return `${agent === 'codex' ? '-m' : '--model'} ${quote(model)}`;
409
440
  }
410
441
 
411
442
  export function resolveJobAgent(agent) {
@@ -468,7 +499,7 @@ export function newJobId() {
468
499
  export const MAX_TITLE_LEN = 200;
469
500
  export const MAX_DETAIL_LEN = 20000;
470
501
 
471
- export function createJob({ title, detail, repoPath, type, schedule, permissionMode, agent, requiresPr, postedBy, postedByName, postedByAgent, postedByBillion }) {
502
+ export function createJob({ title, detail, repoPath, type, schedule, permissionMode, agent, model, availableModels, requiresPr, postedBy, postedByName, postedByAgent, postedByBillion }) {
472
503
  const cleanTitle = String(title || '').trim().slice(0, MAX_TITLE_LEN);
473
504
  if (!cleanTitle) return { error: 'Title is required' };
474
505
  if (!repoPath) return { error: 'Repository is required' };
@@ -481,6 +512,8 @@ export function createJob({ title, detail, repoPath, type, schedule, permissionM
481
512
  if (mode.error) return { error: mode.error };
482
513
  const cli = resolveJobAgent(agent);
483
514
  if (cli.error) return { error: cli.error };
515
+ const chosen = resolveJobModel(model, cli.agent, availableModels);
516
+ if (chosen.error) return { error: chosen.error };
484
517
  return {
485
518
  job: {
486
519
  id: newJobId(),
@@ -504,7 +537,8 @@ export function createJob({ title, detail, repoPath, type, schedule, permissionM
504
537
  // Which CLI is spawned for it. Cards written before this existed have no
505
538
  // field, which jobAgent reads as claude.
506
539
  agent: cli.agent,
507
- // See jobRequiresPr. Only false when the poster said so.
540
+ // The model its CLI runs, or null for the CLI's default. See resolveJobModel.
541
+ model: chosen.model,
508
542
  // See jobRequiresPr. Unset takes the type's default.
509
543
  requiresPr: typeof requiresPr === 'boolean' ? requiresPr : defaultRequiresPr(resolved.type),
510
544
  detail: String(detail || '').trim().slice(0, MAX_DETAIL_LEN),
@@ -742,10 +776,11 @@ export function buildJobCommand(job, { permissionMode = DEFAULT_PERMISSION_MODE
742
776
  if (jobAgent(job) === 'codex') {
743
777
  // No --add-dir: every Codex sandbox, read-only included, reads anywhere
744
778
  // on disk and gates only writes, and attachments are only ever read.
745
- const flags = permissionModeFlags('codex', mode).join(' ');
779
+ const flags = [...permissionModeFlags('codex', mode), modelFlag('codex', job.model)].filter(Boolean).join(' ');
746
780
  return `codex ${flags ? `${flags} ` : ''}${quote(buildJobPrompt(job))}`;
747
781
  }
748
- return `claude ${permissionModeFlags('claude', mode).join(' ')} ${quote(buildJobPrompt(job))}${dirs.map(d => ` --add-dir ${quote(d)}`).join('')}`;
782
+ const m = modelFlag('claude', job.model);
783
+ return `claude ${permissionModeFlags('claude', mode).join(' ')}${m ? ` ${m}` : ''} ${quote(buildJobPrompt(job))}${dirs.map(d => ` --add-dir ${quote(d)}`).join('')}`;
749
784
  }
750
785
 
751
786
  // --- Live status (derived, never stored) ---
@@ -849,6 +884,7 @@ export function createRunJob(schedule) {
849
884
  type: 'one-time',
850
885
  permissionMode: schedule.permissionMode,
851
886
  agent: schedule.agent,
887
+ model: schedule.model,
852
888
  requiresPr: jobRequiresPr(schedule),
853
889
  postedBy: schedule.postedBy,
854
890
  postedByName: schedule.postedByName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bill10/agent-007",
3
- "version": "0.10.0",
3
+ "version": "0.12.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
@@ -167,6 +167,11 @@
167
167
  <option value="codex">Codex</option>
168
168
  </select>
169
169
  </label>
170
+ <label title="Which model this job's agent runs. The list is what the board found for the chosen CLI on this computer; CLI default leaves the choice to the CLI.">Model
171
+ <select id="job-model">
172
+ <option value="">CLI default</option>
173
+ </select>
174
+ </label>
170
175
  <label title="How much this job's agent may do without asking. Leave it on the board default unless this one job needs something different. A Codex card offers auto (Codex's own default) and bypassPermissions (--dangerously-bypass-approvals-and-sandbox); a board set to another mode maps onto Codex's nearest flag: plan is read-only, manual asks before every command, dontAsk never asks.">Permissions
171
176
  <select id="job-permission-mode-field">
172
177
  <option value="">Board default</option>
@@ -214,6 +219,10 @@
214
219
  <div class="job-cards" id="job-finished-cards"></div>
215
220
  </div>
216
221
  </div>
222
+ <!-- Waiting on you: Billion's questions (public/modules/waiting.js) -->
223
+ <div class="waiting-board" id="waiting-board" style="display:none">
224
+ <div class="waiting-list" id="waiting-list" aria-label="Waiting on you"></div>
225
+ </div>
217
226
  <div class="voice-indicator" id="voice-indicator" aria-hidden="true" style="display:none">
218
227
  <span class="voice-indicator-dot"></span><span class="voice-indicator-text"></span>
219
228
  </div>
@@ -230,6 +239,7 @@
230
239
  <button data-view="files">Files</button>
231
240
  <button data-view="office" aria-current="true">Office</button>
232
241
  <button data-view="terminal">Terminal</button>
242
+ <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
243
  </nav>
234
244
 
235
245
  <!-- 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';
@@ -282,6 +284,13 @@ function renderCard(job) {
282
284
  chip.title = 'Finishes with a summary from its agent instead of a pull request';
283
285
  title.appendChild(chip);
284
286
  }
287
+ if (job.model) {
288
+ const chip = document.createElement('span');
289
+ chip.className = 'job-card-type';
290
+ chip.textContent = job.model;
291
+ chip.title = `Runs on the ${job.model} model instead of the CLI's default`;
292
+ title.appendChild(chip);
293
+ }
285
294
  if (job.permissionMode) {
286
295
  // Only when the card overrides the board. The mode decides how much its
287
296
  // agent may do unasked, so a card carrying its own must say so on its face
@@ -728,6 +737,32 @@ function syncAgentField() {
728
737
  opt.textContent = codex ? opt.dataset.codexLabel : opt.dataset.claudeLabel;
729
738
  }
730
739
  markDangerousMode(permEl);
740
+ syncModelField();
741
+ }
742
+
743
+ // The discovered models for the chosen CLI (server/models.js), plus the one
744
+ // the card already holds if discovery no longer lists it, so opening the form
745
+ // never silently changes it.
746
+ let models = { claude: [], codex: [] };
747
+ let formModel = '';
748
+ let formAgent = 'claude';
749
+ function syncModelField() {
750
+ const el = document.getElementById('job-model');
751
+ if (!el) return;
752
+ const agent = document.getElementById('job-agent')?.value === 'codex' ? 'codex' : 'claude';
753
+ const list = [...(models[agent] || [])];
754
+ if (formModel && agent === formAgent && !list.includes(formModel)) list.push(formModel);
755
+ // The card's own value on open; after that, whatever is picked.
756
+ const keep = el.dataset.filled ? el.value : formModel;
757
+ el.dataset.filled = '1';
758
+ el.innerHTML = '<option value="">CLI default</option>';
759
+ for (const m of list) {
760
+ const opt = document.createElement('option');
761
+ opt.value = m;
762
+ opt.textContent = m;
763
+ el.appendChild(opt);
764
+ }
765
+ el.value = list.includes(keep) ? keep : '';
731
766
  }
732
767
 
733
768
  // A shape check only — five whitespace-separated fields, or a known @shorthand.
@@ -799,6 +834,12 @@ function openForm(jobId) {
799
834
  // which would silently freeze the card onto today's setting.
800
835
  if (permEl) permEl.value = job && job.permissionMode ? job.permissionMode : '';
801
836
  if (agentEl) agentEl.value = job && job.agent === 'codex' ? 'codex' : 'claude';
837
+ formModel = job && job.model ? job.model : '';
838
+ formAgent = agentEl ? agentEl.value : 'claude';
839
+ const modelEl = document.getElementById('job-model');
840
+ if (modelEl) delete modelEl.dataset.filled;
841
+ // The server looks again if its list is over 10 minutes old.
842
+ send({ type: 'models-refresh' });
802
843
  prPicked = false;
803
844
  prEl.value = job ? (job.requiresPr === false || (isScheduled(job) && job.requiresPr !== true) ? 'no' : 'yes') : 'yes';
804
845
  syncAgentField(); // also marks the danger colour on the permission select
@@ -847,7 +888,11 @@ function saveForm() {
847
888
  // The form always holds the complete list; an empty one on an edit means
848
889
  // "none left".
849
890
  const attachments = pendingAttachments.map(a => ({ name: a.name, data: a.data }));
891
+ const model = document.getElementById('job-model')?.value || '';
850
892
  const fields = { title, detail, repoPath, jobType, schedule, permissionMode, agent, requiresPr, attachments };
893
+ // An edit that leaves the model alone does not send it, so a card whose
894
+ // model discovery no longer lists can still be retitled.
895
+ if (!editingJobId || model !== formModel) fields.model = model;
851
896
  if (editingJobId) send({ type: 'job-update', jobId: editingJobId, ...fields });
852
897
  else send({ type: 'job-create', ...fields });
853
898
  closeForm();
@@ -871,6 +916,7 @@ export function closeJobForm() {
871
916
  // --- Show / hide ---
872
917
 
873
918
  export function showJobBoard() {
919
+ hideWaiting();
874
920
  // Always open on the columns. Opening the board is the user asking for the
875
921
  // live work; the archive is somewhere you go on purpose, not somewhere the
876
922
  // Jobs tab can strand you.
@@ -896,6 +942,7 @@ export function handleJobsList(msg) {
896
942
  jobs.clear();
897
943
  for (const job of msg.jobs || []) jobs.set(job.id, job);
898
944
  if (msg.settings) setBoardSettings(msg.settings);
945
+ if (msg.models) { models = msg.models; syncModelField(); }
899
946
  renderToolbar();
900
947
  renderBoard();
901
948
  if (window._onBoardVisibilityChanged) window._onBoardVisibilityChanged();
@@ -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
+ }