@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 +7 -5
- package/VERSION +1 -1
- package/lib/jobs.js +43 -7
- package/package.json +1 -1
- package/public/app.js +9 -4
- package/public/index.html +10 -0
- package/public/modules/explorer.js +1 -27
- package/public/modules/jobs.js +47 -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 +5 -2
- package/server/jobs.js +33 -8
- package/server/mcp.js +53 -11
- package/server/models.js +76 -0
- package/server/owner.js +159 -31
- package/server/ws.js +25 -3
- package/server.js +3 -0
- package/templates/billion/charter.md +17 -4
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
|
|
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
|
|
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
|
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
@@ -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
|
|
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';
|
|
@@ -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();
|
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
|
+
}
|