@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 +5 -0
- package/README.md +8 -5
- package/VERSION +1 -1
- package/bin/agent-007.js +1 -0
- package/package.json +1 -1
- package/public/app.js +9 -4
- package/public/index.html +5 -0
- package/public/modules/explorer.js +1 -27
- package/public/modules/jobs.js +3 -0
- package/public/modules/state.js +8 -2
- package/public/modules/terminal.js +32 -2
- package/public/modules/waiting.js +164 -0
- package/public/style.css +116 -6
- package/server/http.js +6 -3
- package/server/jobs.js +13 -4
- package/server/mcp.js +48 -8
- package/server/owner.js +159 -31
- package/server/ws.js +181 -90
- package/server.js +5 -2
- package/templates/billion/charter.md +17 -6
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
|
|
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
|
|
92
|
-
│ files, │ (canvas, │
|
|
93
|
-
│ diffs) │ agents) │
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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);
|
|
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
|
|
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
|
|
package/public/modules/jobs.js
CHANGED
|
@@ -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.
|
package/public/modules/state.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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, .
|
|
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.' };
|