@ucsandman/legcli 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/CHANGELOG.md +212 -0
- package/README.md +158 -67
- package/bin/leg.mjs +168 -18
- package/docs/DECISIONS.md +10 -0
- package/docs/DEMO.md +20 -14
- package/docs/DEVIATIONS.md +1 -0
- package/docs/ERRORS.md +94 -0
- package/docs/ROADMAP-v2.md +69 -11
- package/docs/VOCABULARY.md +27 -0
- package/docs/adapters.md +93 -11
- package/docs/board-guide.md +401 -66
- package/docs/cli-contracts.md +235 -22
- package/docs/concepts.md +167 -19
- package/docs/configuration.md +113 -5
- package/docs/faq.md +21 -5
- package/docs/getting-started.md +15 -11
- package/docs/redesign-2026-09-17.md +477 -0
- package/docs/screenshots/background-1280.png +0 -0
- package/docs/screenshots/board-400px.png +0 -0
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/board-drawer.png +0 -0
- package/docs/screenshots/board-handoff.png +0 -0
- package/docs/screenshots/board-running.png +0 -0
- package/docs/screenshots/capacity-drawer-1280.png +0 -0
- package/docs/screenshots/settings-ladder-1280.png +0 -0
- package/docs/screenshots/terminals-1280.png +0 -0
- package/fixtures/limits/claude/claude-fable-limit.json +11 -0
- package/fixtures/limits/claude/claude-model-limit.json +1 -1
- package/fixtures/limits/claude/claude-session-limit.json +1 -1
- package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
- package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
- package/fixtures/live/claude/resume-model-probe.json +20 -0
- package/fixtures/live/claude/usage-oauth.json +87 -0
- package/fixtures/live/grok/cmd.txt +1 -1
- package/fixtures/live/grok/parsed.json +6 -3
- package/fixtures/live/grok/run.json +22 -10
- package/fixtures/verified.json +8 -1
- package/package.json +3 -2
- package/scripts/build-docs-site.mjs +4 -4
- package/scripts/probe.mjs +2 -1
- package/scripts/seed-fake-cards.mjs +59 -6
- package/scripts/seed-wes-board.mjs +81 -12
- package/src/accounts.mjs +6 -1
- package/src/adapters/cli.mjs +130 -0
- package/src/adapters/custom.mjs +271 -0
- package/src/adapters/grok.mjs +51 -10
- package/src/adapters/index.mjs +34 -7
- package/src/attach.mjs +350 -42
- package/src/audit.mjs +118 -0
- package/src/board/audit.js +123 -0
- package/src/board/board.css +134 -9
- package/src/board/board.js +482 -106
- package/src/board/index.html +89 -7
- package/src/board/sessions.js +1371 -113
- package/src/buckets.mjs +101 -0
- package/src/cards.mjs +9 -1
- package/src/chain.mjs +13 -0
- package/src/hook.mjs +7 -1
- package/src/ledger.mjs +10 -2
- package/src/orchestrator.mjs +13 -4
- package/src/preferences.mjs +214 -5
- package/src/scheduler.mjs +24 -1
- package/src/server.mjs +615 -50
- package/src/sessions.mjs +17 -1
- package/src/share.mjs +66 -6
- package/src/taps/claude-usage.mjs +91 -2
- package/src/taps/claude.mjs +144 -5
- package/src/taps/codex.mjs +23 -3
- package/src/taps/grok.mjs +4 -0
- package/src/usage.mjs +424 -13
package/src/board/sessions.js
CHANGED
|
@@ -23,6 +23,14 @@
|
|
|
23
23
|
}
|
|
24
24
|
const WIN_WORDS = { '5h': '5 hour', '7d': '7 day' }
|
|
25
25
|
const IDS = ['claude', 'codex', 'agy', 'grok', 'fake']
|
|
26
|
+
// Mirrors MODEL_ALIASES and ALL_HANDOFF_AGENTS in src/buckets.mjs and
|
|
27
|
+
// src/preferences.mjs. This file is served to a browser and cannot import
|
|
28
|
+
// from either, so the lists are copied here and nowhere else on the board; a
|
|
29
|
+
// new alias is a change to both files in the same commit. The server is still
|
|
30
|
+
// the authority: it answers a rung it has never heard of with a 400 sentence,
|
|
31
|
+
// which the editor prints verbatim.
|
|
32
|
+
const MODEL_ALIASES = { claude: ['fable', 'opus', 'sonnet', 'haiku'], codex: [], agy: [], grok: [] }
|
|
33
|
+
const LADDER_AGENTS = ['claude', 'codex', 'agy', 'grok']
|
|
26
34
|
|
|
27
35
|
let view = null
|
|
28
36
|
const NO_BRANCH_BLOCKER = 'this terminal works in the checkout itself: there is no branch of its own to land'
|
|
@@ -34,9 +42,16 @@
|
|
|
34
42
|
const alsoOpen = new Set()
|
|
35
43
|
const actionNotes = new Map()
|
|
36
44
|
const lastTone = new Map()
|
|
37
|
-
const defaultEditor = {
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
const defaultEditor = { ladder: null, climb_back: 'next-handoff', may_spend: false, reserve: {}, dirty: false, saving: false, status: '', statusClass: '' }
|
|
46
|
+
|
|
47
|
+
// localStorage THROWS rather than answering null in a browser with site data
|
|
48
|
+
// blocked, in a partitioned webview and on a board opened from a file. This
|
|
49
|
+
// read runs inside api(), so an unguarded one takes every fetch on the page
|
|
50
|
+
// down before it is issued and the board looks exactly like a dead server.
|
|
51
|
+
// An empty token is already a valid state: the header is only added if (token).
|
|
52
|
+
function getToken() {
|
|
53
|
+
try { return localStorage.getItem('legToken') || localStorage.getItem('batonToken') || '' } catch { return '' }
|
|
54
|
+
}
|
|
40
55
|
async function api(path, opts = {}) {
|
|
41
56
|
const headers = { 'Content-Type': 'application/json' }
|
|
42
57
|
const token = getToken()
|
|
@@ -139,8 +154,15 @@
|
|
|
139
154
|
function optionLabel(a) { return a ? (a.account && a.account !== 'default' ? `${a.agent}/${a.account}` : a.agent) : 'none' }
|
|
140
155
|
function idOf(agent) { return IDS.includes(agent) ? agent : 'fake' }
|
|
141
156
|
const tail = (id) => String(id).split('-').slice(-2).join('-')
|
|
157
|
+
// `leg#7f3a`: the repo this terminal is in and the short id the row prints,
|
|
158
|
+
// which is how a human refers to it out loud and in the verdict.
|
|
159
|
+
const shortId = (s) => tail(s.session_id).replace(new RegExp(`^${s.agent || ''}-`), '')
|
|
160
|
+
const rowName = (s) => `${s.repo_name || s.agent || 'terminal'}#${shortId(s)}`
|
|
142
161
|
const shared = () => Boolean(view && view.share && view.share.on)
|
|
143
162
|
const isMine = (s) => Boolean(view && view.you && s.owner && view.you.name === s.owner)
|
|
163
|
+
// the pipeline board belongs to the owner and the operators of the machine;
|
|
164
|
+
// a guest's End row offers no card (the route answers 403)
|
|
165
|
+
const canCards = () => !(view && view.you && view.you.role && view.you.role !== 'owner' && view.you.role !== 'operator')
|
|
144
166
|
|
|
145
167
|
// ---- 6.1 the window rail ----------------------------------------------
|
|
146
168
|
// One state value per account drives the .acct modifier, every rail cell, the
|
|
@@ -252,6 +274,319 @@
|
|
|
252
274
|
])
|
|
253
275
|
}
|
|
254
276
|
|
|
277
|
+
// ---- the binding bucket -------------------------------------------------
|
|
278
|
+
// The bucket that will actually stop the work: the one the endpoint marked
|
|
279
|
+
// active, else the highest percentage it reported, else the legacy hottest of
|
|
280
|
+
// the two windows, which is all an older record or a guest payload carries.
|
|
281
|
+
// Mirrors binding() in src/usage.mjs; the board cannot import from it.
|
|
282
|
+
const BUCKET_WORD = { weekly_scoped: 'week', weekly_all: 'week', session: 'session', spend: 'spend', seven_day: '7d', five_hour: '5h' }
|
|
283
|
+
function bindingOf(a) {
|
|
284
|
+
const buckets = Array.isArray(a && a.buckets) ? a.buckets.filter((b) => b && Number.isFinite(b.percent)) : []
|
|
285
|
+
const top = (l) => (l.length ? [...l].sort((x, y) => y.percent - x.percent)[0] : null)
|
|
286
|
+
const b = top(buckets.filter((x) => x.is_active)) || top(buckets)
|
|
287
|
+
if (b) return { kind: b.kind, model: b.model || null, percent: b.percent, resets_at: Number.isFinite(b.resets_at) ? b.resets_at : null, scope: b.model ? 'model' : 'account' }
|
|
288
|
+
const w = worstWindow(a)
|
|
289
|
+
if (!w || !Number.isFinite(w.pct)) return null
|
|
290
|
+
return { kind: a && a.seven_day === w ? 'seven_day' : 'five_hour', model: null, percent: w.pct, resets_at: Number.isFinite(w.resets_at) ? w.resets_at : null, scope: 'account' }
|
|
291
|
+
}
|
|
292
|
+
// the token's two words: `fable week`, `week`, `session`, `5h`
|
|
293
|
+
function bucketWord(b) { const word = BUCKET_WORD[b.kind] || b.kind; return b.model ? `${b.model} ${word}` : word }
|
|
294
|
+
// the same bucket inside a sentence: "63% of its week"
|
|
295
|
+
function windowPhrase(b) { return b.kind === 'session' ? 'its session' : b.kind === 'five_hour' ? 'its 5 hours' : 'its week' }
|
|
296
|
+
// the account's own window, ignoring any model bucket: what a same-login
|
|
297
|
+
// model rung still has to spend, and what an account wall would take away
|
|
298
|
+
function accountBucket(a) {
|
|
299
|
+
const flat = (Array.isArray(a && a.buckets) ? a.buckets : []).filter((b) => b && !b.model && Number.isFinite(b.percent))
|
|
300
|
+
const b = [...flat].sort((x, y) => y.percent - x.percent)[0]
|
|
301
|
+
if (b) return { kind: b.kind, model: null, percent: b.percent, resets_at: Number.isFinite(b.resets_at) ? b.resets_at : null, scope: 'account' }
|
|
302
|
+
const legacy = bindingOf(a)
|
|
303
|
+
return legacy && !legacy.model ? legacy : null
|
|
304
|
+
}
|
|
305
|
+
const Model = (m) => (m ? String(m).charAt(0).toUpperCase() + String(m).slice(1) : '')
|
|
306
|
+
// every model this login has published anything about. A model named by
|
|
307
|
+
// neither a bucket nor a wall is one Leg has never seen, and it is never
|
|
308
|
+
// guessed at.
|
|
309
|
+
function knownModels(a) {
|
|
310
|
+
const out = []
|
|
311
|
+
for (const b of (a && a.buckets) || []) if (b && b.model && !out.includes(b.model)) out.push(b.model)
|
|
312
|
+
for (const m of Object.keys((a && a.walls) || {})) if (!out.includes(m)) out.push(m)
|
|
313
|
+
return out
|
|
314
|
+
}
|
|
315
|
+
function wallFor(a, model) {
|
|
316
|
+
const w = a && a.walls ? a.walls[model] : null
|
|
317
|
+
return w && Number.isFinite(w.limited_until) && w.limited_until * 1000 > Date.now() ? w : null
|
|
318
|
+
}
|
|
319
|
+
function walledModels(a) { return knownModels(a).filter((m) => wallFor(a, m)) }
|
|
320
|
+
function openModels(a) { return knownModels(a).filter((m) => !wallFor(a, m)) }
|
|
321
|
+
function modelBucket(a, model) {
|
|
322
|
+
return (Array.isArray(a && a.buckets) ? a.buckets : []).find((b) => b && b.model === model && Number.isFinite(b.percent)) || null
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---- the forecast (spec A.5 row 4, E rule 6) -----------------------------
|
|
326
|
+
// `forecast` rides the binding bucket, computed by `burn()` in src/usage.mjs:
|
|
327
|
+
// a rate measured inside ONE window, never across a reset, and only past a
|
|
328
|
+
// gate of three samples spanning ten minutes. Under the gate there is no time
|
|
329
|
+
// at all, and every time that is printed carries the volume it came from: a
|
|
330
|
+
// figure with no sample count beside it is a guess wearing an instrument's
|
|
331
|
+
// clothes, and this one is printed in the largest type on the page.
|
|
332
|
+
function forecastOf(c) {
|
|
333
|
+
const f = c && c.forecast
|
|
334
|
+
return f && Number.isFinite(f.seconds_left) && Number.isFinite(f.samples) && Number.isFinite(f.span_s) ? f : null
|
|
335
|
+
}
|
|
336
|
+
// `2h 40m` under a day, `3d 5h` beyond, and spelled-out minutes under ten:
|
|
337
|
+
// `0h 8m` reads as an instrument, and eight minutes is a sentence.
|
|
338
|
+
function burnPhrase(secondsLeft) {
|
|
339
|
+
const m = Math.max(0, Math.round(secondsLeft / 60))
|
|
340
|
+
if (m < 10) return `${m} minute${m === 1 ? '' : 's'}`
|
|
341
|
+
if (m < 60) return `${m}m`
|
|
342
|
+
const h = Math.floor(m / 60)
|
|
343
|
+
if (h < 24) return `${h}h${m % 60 ? ` ${m % 60}m` : ''}`
|
|
344
|
+
const d = Math.floor(h / 24)
|
|
345
|
+
return `${d}d${h % 24 ? ` ${h % 24}h` : ''}`
|
|
346
|
+
}
|
|
347
|
+
function burnVolume(f, lead = 'from') { return `${lead} ${f.samples} sample${f.samples === 1 ? '' : 's'} over ${ago(f.span_s * 1000)}` }
|
|
348
|
+
function andList(xs) { return xs.length < 2 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}` }
|
|
349
|
+
|
|
350
|
+
// ---- what one terminal is waiting for ------------------------------------
|
|
351
|
+
// `waiting` carries TWO shapes under one key and they mean opposite things.
|
|
352
|
+
// `{ type: 'reset', agent, account, resets_at, since }` is the all-out
|
|
353
|
+
// countdown the runner writes: nobody is being waited on, the child is
|
|
354
|
+
// already dead. The Notification shapes below are a HUMAN being waited on.
|
|
355
|
+
// Both carry `type` and `since`, so the shape is told apart by the type and
|
|
356
|
+
// never by the presence of a field (src/sessions.mjs createSession).
|
|
357
|
+
const NOTIFY_TYPES = ['permission_prompt', 'idle_prompt', 'agent_needs_input', 'quota_auto_resume']
|
|
358
|
+
// src/taps/claude.mjs QUOTA_STAND_DOWN, character for character: two waiters
|
|
359
|
+
// on one terminal is the failure to avoid, so the row says who is waiting.
|
|
360
|
+
const QUOTA_STAND_DOWN = 'Claude Code is waiting at the limit itself; Leg is not handing this one off.'
|
|
361
|
+
// Nothing clears `waiting` on the way out: the ended transition in
|
|
362
|
+
// src/attach.mjs and the lost one in src/sessions.mjs both leave the last
|
|
363
|
+
// Notification shape on the record. A dead terminal is not waiting on
|
|
364
|
+
// anybody, so the guard is the one quietPhrase below already keeps: without
|
|
365
|
+
// it a terminal that exited at a permission prompt said "waiting on you"
|
|
366
|
+
// forever, held the tab badge and never fell into the Finished ledger.
|
|
367
|
+
function notifyWait(s) {
|
|
368
|
+
const w = s && s.waiting
|
|
369
|
+
return w && s.active && NOTIFY_TYPES.includes(w.type) ? w : null
|
|
370
|
+
}
|
|
371
|
+
function resetWait(s) {
|
|
372
|
+
const w = s && s.waiting
|
|
373
|
+
return w && !NOTIFY_TYPES.includes(w.type) ? w : null
|
|
374
|
+
}
|
|
375
|
+
// A.6 rank 3. The question is printed verbatim, capped at the 160 characters
|
|
376
|
+
// the hook itself stores: a paraphrase of what an agent asked for is the one
|
|
377
|
+
// thing a human cannot check against the terminal in front of them.
|
|
378
|
+
function waitingNote(w) {
|
|
379
|
+
if (!w) return null
|
|
380
|
+
const since = Date.parse(w.since || '')
|
|
381
|
+
const msg = String(w.message || '').slice(0, 160)
|
|
382
|
+
const asked = Number.isFinite(since) ? `, asked ${ago(Date.now() - since)} ago` : ''
|
|
383
|
+
if (w.type === 'quota_auto_resume') return { rank: 3, cat: 'standing down', tone: 'warn', text: msg || QUOTA_STAND_DOWN }
|
|
384
|
+
if (w.type === 'permission_prompt') return { rank: 3, cat: 'waiting on you', tone: 'warn', text: `waiting on you: permission to run ${msg || 'a tool'}${asked}` }
|
|
385
|
+
if (w.type === 'idle_prompt') return { rank: 3, cat: 'waiting on you', tone: 'warn', text: `waiting on you: idle${Number.isFinite(since) ? ` since ${clockAt(since)}` : ''}` }
|
|
386
|
+
return { rank: 3, cat: 'waiting on you', tone: 'warn', text: `waiting on you: ${msg || 'it asked for something'}` }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ---- A.4 rows 7 to 9 and 12: the register's data tokens ------------------
|
|
390
|
+
// Pure data in reading order, so the words can be asserted without a DOM and
|
|
391
|
+
// the row builder below stays a list of appends.
|
|
392
|
+
const QUIET_MS = 2 * 60 * 1000
|
|
393
|
+
// `claude/fable`. The agent alone when the model is unknown: a printed model
|
|
394
|
+
// nobody chose is a wrong number in disguise, so there is never a default.
|
|
395
|
+
function modelToken(s) { return s && s.agent ? (s.model ? `${s.agent}/${s.model}` : s.agent) : null }
|
|
396
|
+
// an observation, not a demand, and printed in the muted tone for every
|
|
397
|
+
// agent. A row already waiting on a human says that instead.
|
|
398
|
+
function quietPhrase(s) {
|
|
399
|
+
if (!s || !s.active || notifyWait(s)) return null
|
|
400
|
+
const t = Date.parse(s.last_activity || '')
|
|
401
|
+
if (!Number.isFinite(t)) return null
|
|
402
|
+
const idle = Date.now() - t
|
|
403
|
+
return idle >= QUIET_MS ? `quiet ${ago(idle)}` : null
|
|
404
|
+
}
|
|
405
|
+
// A.4 row 12: the bucket that will actually stop THIS terminal, which depends
|
|
406
|
+
// on the model it is running. `capacity` is binding(usage, session.model),
|
|
407
|
+
// computed per request in src/server.mjs and never persisted.
|
|
408
|
+
function capacityPhrase(s) {
|
|
409
|
+
const c = s && s.capacity
|
|
410
|
+
if (!c || !Number.isFinite(c.percent)) return null
|
|
411
|
+
const label = s.account && s.account !== 'default' ? `${s.agent}/${s.account}` : s.agent
|
|
412
|
+
// With a rate the row says the same fact in the unit the reader is actually
|
|
413
|
+
// deciding in, and carries the volume it was drawn from. Under the gate it
|
|
414
|
+
// is the percentage again (E rule 6): no time is printed from two readings.
|
|
415
|
+
const f = forecastOf(c)
|
|
416
|
+
if (f) return `about ${burnPhrase(f.seconds_left)} of ${c.scope === 'model' && c.model ? c.model : label} left, ${burnVolume(f)}`
|
|
417
|
+
const pct = Math.round(c.percent)
|
|
418
|
+
if (c.scope === 'model' && c.model) return `${pct}% of the ${c.model} week`
|
|
419
|
+
const win = c.kind === 'session' || c.kind === 'five_hour' ? '5-hour window' : 'week'
|
|
420
|
+
return `${pct}% of the ${label} ${win}`
|
|
421
|
+
}
|
|
422
|
+
function registerTokens(s) {
|
|
423
|
+
const out = []
|
|
424
|
+
const dirty = Array.isArray(s && s.files_dirty) ? s.files_dirty.length : 0
|
|
425
|
+
if (dirty) out.push({ kind: 'dirty', text: `dirty ${dirty}` })
|
|
426
|
+
// `ahead` is a git count the runner polls; an older record does not carry
|
|
427
|
+
// it, and a count nobody measured is never printed as zero
|
|
428
|
+
if (Number.isFinite(s && s.ahead) && s.ahead > 0) out.push({ kind: 'ahead', text: `ahead ${s.ahead}` })
|
|
429
|
+
const model = modelToken(s)
|
|
430
|
+
if (model) out.push({ kind: 'model', text: model })
|
|
431
|
+
const quiet = quietPhrase(s)
|
|
432
|
+
if (quiet) out.push({ kind: 'quiet', text: quiet })
|
|
433
|
+
return out
|
|
434
|
+
}
|
|
435
|
+
// A.6 rank 8.5: said only when the bucket that binds this terminal is at or
|
|
436
|
+
// past the warning line. Model-scoped, a same-login rung is the answer;
|
|
437
|
+
// account-scoped, it buys nothing and the sentence says which login is next.
|
|
438
|
+
function capacityNote(s, account) {
|
|
439
|
+
const c = s && s.capacity
|
|
440
|
+
if (!c || !Number.isFinite(c.percent) || c.percent < WARN_PCT) return null
|
|
441
|
+
const pct = Math.round(c.percent)
|
|
442
|
+
const label = s.account && s.account !== 'default' ? `${s.agent}/${s.account}` : s.agent
|
|
443
|
+
if (c.scope === 'model' && c.model) {
|
|
444
|
+
const alt = account ? openModels(account).filter((m) => m !== c.model)[0] : null
|
|
445
|
+
return { rank: 8.5, cat: 'near the model wall', tone: 'warn', text: `${c.model} at ${pct}% of its week${alt ? `; Hand off > ${s.agent}/${alt} keeps this terminal` : ''}` }
|
|
446
|
+
}
|
|
447
|
+
const next = (s.chain || []).find((x) => x && x.agent !== s.agent)
|
|
448
|
+
return { rank: 8.5, cat: 'near the login wall', tone: 'warn', text: `${label} at ${pct}%, shared by every model${next ? `; next off ${label}: ${optionLabel(next)}` : ''}` }
|
|
449
|
+
}
|
|
450
|
+
function accountOf(s) {
|
|
451
|
+
const list = (view && view.accounts) || []
|
|
452
|
+
return list.find((a) => a.agent === s.agent && (a.account || 'default') === (s.account || 'default')) || null
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---- the capacity strip -------------------------------------------------
|
|
456
|
+
// One 44px band under the verdict, one token per login, and nothing else:
|
|
457
|
+
// usage is a property of the work now, not a region of its own. The panels
|
|
458
|
+
// are not rewritten, they move behind the disclosure at the end of the strip.
|
|
459
|
+
// The token prints the BINDING bucket, because the board printing 47% for a
|
|
460
|
+
// login whose active bucket is at 63% is the defect this strip exists for.
|
|
461
|
+
function capFigure(a, b) {
|
|
462
|
+
// the same two refusals the gauge prints, in the strip's shorter grammar
|
|
463
|
+
if (a.shared === false) return 'not shared'
|
|
464
|
+
if (a.loading) return 'reading'
|
|
465
|
+
if (acctState(a) === 'walled') return Number.isFinite(a.limited_until) ? `back ${until(a.limited_until)}` : 'back when it resets'
|
|
466
|
+
// agy publishes no percentage, ever; a login that has one and has not
|
|
467
|
+
// reported it yet is a different fact and says so.
|
|
468
|
+
if (!b) return a.agent === 'agy' ? 'no figure' : 'no reading'
|
|
469
|
+
const observed = Date.parse(a.observed_at || a.updated_at || '')
|
|
470
|
+
// a reading older than the window it describes prints the clock it was
|
|
471
|
+
// taken at instead of a bucket word: it is a measurement, not a reading now
|
|
472
|
+
if (a.stale && a.agent !== 'agy' && Number.isFinite(observed)) return `${Math.round(b.percent)}% ${clockAt(observed)}`
|
|
473
|
+
return `${Math.round(b.percent)}% ${bucketWord(b)}`
|
|
474
|
+
}
|
|
475
|
+
// the spoken sentence carries what the visible token cannot: the reset, the
|
|
476
|
+
// source, the wall and the age of the reading, exactly as the gauges do.
|
|
477
|
+
function capValueText(a, b) {
|
|
478
|
+
const parts = []
|
|
479
|
+
if (a.shared === false) parts.push(`Usage for ${accountLabel(a)} is not shared with guests.`)
|
|
480
|
+
else if (!b) {
|
|
481
|
+
parts.push(a.agent === 'agy'
|
|
482
|
+
? 'agy publishes no usage percentage, ever. Leg sees the wall when agy hits it.'
|
|
483
|
+
: `No reading has come back from ${accountLabel(a)} yet.`)
|
|
484
|
+
} else {
|
|
485
|
+
parts.push(`${Math.round(b.percent)} percent of ${b.model ? `the ${b.model} ${BUCKET_WORD[b.kind] || b.kind}` : windowPhrase(b)} used.`)
|
|
486
|
+
if (Number.isFinite(b.resets_at)) parts.push(`Resets at ${until(b.resets_at)}, in ${spoken(b.resets_at * 1000 - Date.now())}.`)
|
|
487
|
+
}
|
|
488
|
+
if (acctState(a) === 'walled') parts.push(`${accountLabel(a)} is at its wall until ${until(a.limited_until)}, in ${spoken(a.limited_until * 1000 - Date.now())}.`)
|
|
489
|
+
for (const m of walledModels(a)) parts.push(`${m} is out until ${until(wallFor(a, m).limited_until)}.`)
|
|
490
|
+
if (a.source) parts.push(`Source: ${a.source}.`)
|
|
491
|
+
const observed = Date.parse(a.observed_at || a.updated_at || '')
|
|
492
|
+
if (a.stale && a.agent !== 'agy' && Number.isFinite(observed)) parts.push(`Read at ${clockAt(observed)}, ${spoken(Date.now() - observed)} ago, stale.`)
|
|
493
|
+
return parts.join(' ')
|
|
494
|
+
}
|
|
495
|
+
function capToken(a) {
|
|
496
|
+
const id = idOf(a.agent)
|
|
497
|
+
const b = bindingOf(a)
|
|
498
|
+
const walled = acctState(a) === 'walled'
|
|
499
|
+
const pct = b ? Math.max(0, Math.min(100, Math.round(b.percent))) : null
|
|
500
|
+
const token = el('span', { class: 'cap-token' }, [
|
|
501
|
+
el('span', { class: `dot id-${id}`, 'aria-hidden': 'true' }),
|
|
502
|
+
el('span', { class: `cap-name id-${id}` }, [accountLabel(a)]),
|
|
503
|
+
])
|
|
504
|
+
// no number, no instrument. A track with nothing in it is a reading of zero
|
|
505
|
+
// to anyone glancing at it, which is exactly what agy does not have.
|
|
506
|
+
if (pct !== null || walled) {
|
|
507
|
+
const stop = pct !== null && pct > 85 ? `${((85 / pct) * 100).toFixed(2)}%` : null
|
|
508
|
+
const fill = el('span', {
|
|
509
|
+
class: 'cap-fill',
|
|
510
|
+
style: walled ? 'width:100%;background:var(--danger)'
|
|
511
|
+
: stop ? `width:${pct}%;background:linear-gradient(to right,var(--id-${id}) 0 ${stop},var(--danger) ${stop} 100%)`
|
|
512
|
+
: `width:${pct}%;background:var(--id-${id})`,
|
|
513
|
+
})
|
|
514
|
+
// a walled login with no percentage is not a meter: 100 would be a number
|
|
515
|
+
// nobody measured. It keeps the track and carries the sentence instead.
|
|
516
|
+
const semantics = pct === null
|
|
517
|
+
? { role: 'img', 'aria-label': `${accountLabel(a)} capacity. ${capValueText(a, b)}` }
|
|
518
|
+
: { role: 'meter', 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(pct), 'aria-label': `${accountLabel(a)} capacity`, 'aria-valuetext': capValueText(a, b) }
|
|
519
|
+
token.appendChild(el('span', { class: 'cap-track', ...semantics }, [fill]))
|
|
520
|
+
}
|
|
521
|
+
// with no track the figure carries the whole sentence itself, the way the
|
|
522
|
+
// gauge's readout does when a window has never been read
|
|
523
|
+
const quiet = pct === null && !walled ? { role: 'img', 'aria-label': `${accountLabel(a)} capacity. ${capValueText(a, b)}` } : {}
|
|
524
|
+
token.appendChild(el('span', { class: `cap-figure${walled ? ' is-out' : ''}${pct === null && !walled ? ' cap-figure--none' : ''}`, ...quiet }, [capFigure(a, b)]))
|
|
525
|
+
return token
|
|
526
|
+
}
|
|
527
|
+
function capacityStrip(list) {
|
|
528
|
+
const box = document.getElementById('capacity-tokens')
|
|
529
|
+
if (!box) return
|
|
530
|
+
box.textContent = ''
|
|
531
|
+
for (const a of list) box.appendChild(capToken(a))
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// The model rail, on the panel head inside the drawer: one chip per model
|
|
535
|
+
// this login has published a bucket or a wall for. A walled model says when
|
|
536
|
+
// it is back, in words, because a wall is attributed from wording and a
|
|
537
|
+
// percentage is measured, and one must never be printed as the other.
|
|
538
|
+
function modelRail(a) {
|
|
539
|
+
const models = knownModels(a)
|
|
540
|
+
if (!models.length) return null
|
|
541
|
+
const rail = el('span', { class: 'model-rail', 'aria-label': `${accountLabel(a)} models` })
|
|
542
|
+
// B.6: an open chip is the one control that turns a figure into a decision,
|
|
543
|
+
// so it becomes a button that puts that rung at the top of ONE terminal's
|
|
544
|
+
// ladder: the one open in the detail region, else the first live row on
|
|
545
|
+
// this login. With no live terminal on the login there is nothing to
|
|
546
|
+
// re-point and the chip stays what it was, a reading.
|
|
547
|
+
const target = railTerminal(a)
|
|
548
|
+
for (const m of models) {
|
|
549
|
+
const wall = wallFor(a, m)
|
|
550
|
+
const b = modelBucket(a, m)
|
|
551
|
+
const text = wall ? `${m} out until ${until(wall.limited_until)}` : b ? `${m} ${Math.round(b.percent)}%` : m
|
|
552
|
+
const pickable = !wall && target && target.model !== m
|
|
553
|
+
if (!pickable) {
|
|
554
|
+
rail.appendChild(el('span', { class: `model-chip${wall ? ' is-out' : ''}${target && target.model === m ? ' is-current' : ''}`, title: wall && wall.evidence ? wall.evidence : null }, [text]))
|
|
555
|
+
continue
|
|
556
|
+
}
|
|
557
|
+
const chip = el('button', {
|
|
558
|
+
type: 'button', class: 'btn btn-text model-chip model-chip--pick',
|
|
559
|
+
'data-focus-key': `rail:${a.agent}:${a.account || 'default'}:${m}`,
|
|
560
|
+
title: `Put ${a.agent} / ${m} at the top of ${rowName(target)}'s ladder`,
|
|
561
|
+
}, [text])
|
|
562
|
+
chip.addEventListener('click', () => pickRung(target, { agent: a.agent, account: a.account || 'default', model: m }, chip))
|
|
563
|
+
rail.appendChild(chip)
|
|
564
|
+
}
|
|
565
|
+
return rail
|
|
566
|
+
}
|
|
567
|
+
// the terminal a rail chip re-points: the open one if it is on this login,
|
|
568
|
+
// else the first live row on it
|
|
569
|
+
function railTerminal(a) {
|
|
570
|
+
const live = ((view && view.sessions) || []).filter((s) => s.active && !s.hidden && s.agent === a.agent && (s.account || 'default') === (a.account || 'default') && s.can_edit_handoff_order)
|
|
571
|
+
return live.find((s) => s.session_id === drawer.id) || live[0] || null
|
|
572
|
+
}
|
|
573
|
+
// Moving a rung to the top of one terminal's ladder is the same POST the
|
|
574
|
+
// ladder editor makes, so the two cannot drift: one route, one shape.
|
|
575
|
+
async function pickRung(s, rung, btn) {
|
|
576
|
+
btn.disabled = true
|
|
577
|
+
actionNotes.delete(s.session_id)
|
|
578
|
+
const rest = (s.handoff_ladder || []).filter((r) => rungKey(r) !== rungKey(rung))
|
|
579
|
+
const kept = (s.handoff_ladder || []).find((r) => rungKey(r) === rungKey(rung))
|
|
580
|
+
const ladder = [kept || { ...rung, when: 'always', cost: rung.agent === 'agy' ? 'free' : rung.agent === 'grok' ? 'metered' : 'plan' }, ...rest]
|
|
581
|
+
try {
|
|
582
|
+
await api(`/api/sessions/${encodeURIComponent(s.session_id)}/handoff-order`, { method: 'POST', body: { handoff_ladder: ladder } })
|
|
583
|
+
actionNotes.set(s.session_id, { at: Date.now(), tone: 'ok', text: `${rungLabel(rung)} is the next rung for this terminal` })
|
|
584
|
+
} catch (err) {
|
|
585
|
+
actionNotes.set(s.session_id, { at: Date.now(), tone: 'danger', text: err.message })
|
|
586
|
+
}
|
|
587
|
+
refresh()
|
|
588
|
+
}
|
|
589
|
+
|
|
255
590
|
// A login is a raised object, and how much surface it gets is the design
|
|
256
591
|
// saying how much it matters. The login carrying the terminals gets the wide
|
|
257
592
|
// lit panel with both of its windows drawn; a login with one fact to report
|
|
@@ -265,6 +600,8 @@
|
|
|
265
600
|
panel.appendChild(el('div', { class: 'panel-head' }, [
|
|
266
601
|
el('span', { class: 'who' }, [el('span', { class: `dot id-${id}` }), el('span', { class: `acct-name id-${id}` }, [accountLabel(a)])]),
|
|
267
602
|
el('span', { class: 'who-note' }, [live]),
|
|
603
|
+
// one chip per model this login has published a bucket or a wall for
|
|
604
|
+
modelRail(a),
|
|
268
605
|
]))
|
|
269
606
|
|
|
270
607
|
if (state === 'notshared' || state === 'loading') {
|
|
@@ -310,60 +647,299 @@
|
|
|
310
647
|
}
|
|
311
648
|
|
|
312
649
|
// The headline is the one fact that decides what happens next, said as a
|
|
313
|
-
// sentence. It is never the number that the
|
|
650
|
+
// sentence. It is never the number that the strip under it already prints:
|
|
314
651
|
// the same figure in the two largest slots on the page is one fact taking up
|
|
315
652
|
// two, which is what the rejected head did.
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
653
|
+
//
|
|
654
|
+
// VERDICT_CH is a MEASUREMENT, not a taste: at 1280 the verdict column is
|
|
655
|
+
// 26ch (891px) and 300 random sentences per length, drawn from this table's
|
|
656
|
+
// own vocabulary, still fit two 56.16px lines at 60 characters. 56 is that
|
|
657
|
+
// ceiling with four characters of slack for a longer login name, and
|
|
658
|
+
// test/board-verdict.test.mjs holds every branch under it.
|
|
659
|
+
const VERDICT_CH = 56
|
|
660
|
+
// the sub is two lines of 17px inside `max-width: 54ch`, which is about 120
|
|
661
|
+
// characters; clauses are added while they fit and dropped whole after that.
|
|
662
|
+
const SUB_CH = 120
|
|
663
|
+
// mirrors WARN_PCT in src/usage.mjs, which the board cannot import from. A
|
|
664
|
+
// bucket at or past it is close enough to the wall that the row says so.
|
|
665
|
+
const WARN_PCT = 85
|
|
666
|
+
// Each headline is written as a preferred form and shorter fallbacks, so a
|
|
667
|
+
// login called `claude/very-long-account` costs a clause, never a third line.
|
|
668
|
+
function headline(...forms) {
|
|
669
|
+
const real = forms.filter(Boolean)
|
|
670
|
+
for (const f of real) if (f.length <= VERDICT_CH) return f
|
|
671
|
+
const last = String(real[real.length - 1] || '')
|
|
672
|
+
const cut = last.slice(0, VERDICT_CH - 1)
|
|
673
|
+
const space = cut.lastIndexOf(' ')
|
|
674
|
+
return `${(space > 20 ? cut.slice(0, space) : cut).replace(/[,.;:]$/, '')}.`
|
|
675
|
+
}
|
|
676
|
+
function subLine(...parts) {
|
|
677
|
+
const out = []
|
|
678
|
+
for (const p of parts.filter(Boolean)) {
|
|
679
|
+
const next = out.concat(p).join(' ')
|
|
680
|
+
if (next.length <= SUB_CH) out.push(p)
|
|
681
|
+
}
|
|
682
|
+
return out.join(' ')
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// A.5, top to bottom. A bucket whose state is unknown is never named, and
|
|
686
|
+
// "Measured Ns ago" appears only when the reading is actually stale.
|
|
687
|
+
// C.5: the verdict never mentions cards unless one is waiting on a human.
|
|
688
|
+
// `cards_waiting` on the payload is {count, first:{id,title,station,since}};
|
|
689
|
+
// an older server sends a bare number and a guest is sent nothing at all, and
|
|
690
|
+
// neither of those can name a card, so neither prints a sentence about one.
|
|
691
|
+
function waitingCard(cards) {
|
|
692
|
+
const first = cards && typeof cards === 'object' ? cards.first : null
|
|
693
|
+
return first && first.id ? first : null
|
|
694
|
+
}
|
|
695
|
+
// `card 3e1c`: the tail of the card id, which is how a card is named on its
|
|
696
|
+
// own row and out loud.
|
|
697
|
+
function cardName(id) { return `card ${String(id).split('-').filter(Boolean).slice(-1)[0] || id}` }
|
|
698
|
+
function verdictLines(list, sessions, cards) {
|
|
699
|
+
const accounts = (list || []).filter((a) => a && a.agent && !a.loading)
|
|
700
|
+
if (!accounts.length) return { line: 'Reading the logins.', sub: '' }
|
|
701
|
+
const live = (sessions || []).filter((s) => s.active)
|
|
702
|
+
const acctOf = (s) => accounts.find((a) => a.agent === s.agent && (a.account || 'default') === (s.account || 'default'))
|
|
703
|
+
const liveOn = (a) => live.filter((s) => acctOf(s) === a).length
|
|
704
|
+
const walled = accounts.filter((a) => acctState(a) === 'walled')
|
|
705
|
+
const carrying = accounts.filter((a) => liveOn(a) > 0)
|
|
706
|
+
const subject = (carrying.length ? closestToWall(carrying) : closestToWall(accounts)) || accounts[0]
|
|
707
|
+
const b = bindingOf(subject)
|
|
708
|
+
const other = (a) => accounts.filter((x) => x !== a)
|
|
709
|
+
const openElsewhere = other(subject).filter((a) => acctState(a) !== 'walled')
|
|
710
|
+
const leftOf = (bb) => Math.max(0, 100 - Math.round(bb.percent))
|
|
711
|
+
const figure = (a) => { const bb = bindingOf(a); return bb ? (bb.model ? `${accountLabel(a)} is at ${Math.round(bb.percent)}% of the ${Model(bb.model)} week.` : `${accountLabel(a)} is at ${Math.round(bb.percent)}% of ${windowPhrase(bb)}.`) : null }
|
|
712
|
+
const wallClause = (a) => (Number.isFinite(a.limited_until) ? `${accountLabel(a)} is at its limit until ${until(a.limited_until)}.` : `${accountLabel(a)} is at its limit.`)
|
|
713
|
+
const otherWalls = () => walled.filter((a) => a !== subject).map(wallClause).join(' ') || null
|
|
714
|
+
// a reading taken two hours ago with terminals running since is a floor,
|
|
715
|
+
// not a measurement, and the direction it is wrong in is the whole point
|
|
716
|
+
const staleClause = (a, bb) => {
|
|
717
|
+
const observed = Date.parse((a && (a.observed_at || a.updated_at)) || '')
|
|
718
|
+
if (!a || !a.stale || a.agent === 'agy' || !Number.isFinite(observed) || !bb) return null
|
|
719
|
+
const n = liveOn(a)
|
|
720
|
+
if (!n) return `Measured ${ago(Date.now() - observed)} ago.`
|
|
721
|
+
return `Measured ${ago(Date.now() - observed)} ago. ${n} terminal${n === 1 ? ' runs' : 's run'} on it, so the real figure is higher, never lower.`
|
|
347
722
|
}
|
|
348
723
|
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
724
|
+
// 1. a human is blocked. Attention is the scarcer thing, so it outranks
|
|
725
|
+
// usage. `waiting` carries {type, message, since} from the Notification
|
|
726
|
+
// hook; the all-out countdown on the same key carries {type: 'reset',
|
|
727
|
+
// agent, account, resets_at, since} and is not a human being waited on, so
|
|
728
|
+
// the TYPE is checked and never the presence of `since`. `quota_auto_resume`
|
|
729
|
+
// is left out here too: nobody asked the human anything, Claude Code is
|
|
730
|
+
// holding its own turn, and the row says so in its own sentence.
|
|
731
|
+
const blocked = live
|
|
732
|
+
.filter((s) => notifyWait(s) && s.waiting.type !== 'quota_auto_resume' && Number.isFinite(Date.parse(s.waiting.since)))
|
|
733
|
+
.sort((x, y) => Date.parse(x.waiting.since) - Date.parse(y.waiting.since))[0]
|
|
734
|
+
if (blocked) {
|
|
735
|
+
const who = rowName(blocked)
|
|
736
|
+
const waited = spoken(Date.now() - Date.parse(blocked.waiting.since))
|
|
737
|
+
const others = live.length - 1
|
|
738
|
+
return {
|
|
739
|
+
line: headline(`${who} has waited on you for ${waited}.`, `${who} has waited on you for ${ago(Date.now() - Date.parse(blocked.waiting.since))}.`, `${who} is waiting on you.`),
|
|
740
|
+
sub: subLine(
|
|
741
|
+
// the question in the words of the thing that asked it, and each
|
|
742
|
+
// type asks a different question: a permission prompt names a tool,
|
|
743
|
+
// an idle prompt names a clock, and the third carries its own text
|
|
744
|
+
blocked.waiting.type === 'permission_prompt' && blocked.waiting.message ? `It asked to run ${String(blocked.waiting.message).slice(0, 80)}.`
|
|
745
|
+
: blocked.waiting.type === 'idle_prompt' ? `It has had no input since ${clockAt(Date.parse(blocked.waiting.since))}.`
|
|
746
|
+
: blocked.waiting.message ? `It asked: ${String(blocked.waiting.message).slice(0, 80)}` : null,
|
|
747
|
+
others > 0 ? `The other ${others === 1 ? 'terminal is' : `${others} terminals are`} still running.` : null,
|
|
748
|
+
),
|
|
749
|
+
}
|
|
358
750
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
751
|
+
|
|
752
|
+
// 1b. the same rule one level down: no terminal is blocked, but a card is,
|
|
753
|
+
// and a card waiting at a review station is a human being waited on too. It
|
|
754
|
+
// outranks every usage branch for the reason branch 1 does, and it is the
|
|
755
|
+
// ONLY thing that puts a card in the verdict (C.5).
|
|
756
|
+
const card = waitingCard(cards)
|
|
757
|
+
const cardSince = card ? Date.parse(card.since || '') : NaN
|
|
758
|
+
if (card) {
|
|
759
|
+
const who = cardName(card.id)
|
|
760
|
+
return {
|
|
761
|
+
line: Number.isFinite(cardSince)
|
|
762
|
+
? headline(`${who} has waited on you for ${spoken(Date.now() - cardSince)}.`, `${who} has waited on you for ${ago(Date.now() - cardSince)}.`, `${who} is waiting on you.`)
|
|
763
|
+
: headline(`${who} is waiting on you.`),
|
|
764
|
+
sub: subLine(card.station ? `It is at the ${card.station} station.` : null, 'Approve or Reassign on its row.'),
|
|
765
|
+
}
|
|
364
766
|
}
|
|
365
|
-
|
|
366
|
-
|
|
767
|
+
|
|
768
|
+
// 2. every login at its limit: nothing anywhere can be started, whatever
|
|
769
|
+
// is running. Said before the per-login branches because it is the whole
|
|
770
|
+
// board's state, not this login's.
|
|
771
|
+
if (walled.length === accounts.length && walled.length > 1) {
|
|
772
|
+
const first = [...walled].sort((x, y) => (x.limited_until || Infinity) - (y.limited_until || Infinity))[0]
|
|
773
|
+
return {
|
|
774
|
+
line: headline(`Every login is at its limit; ${accountLabel(first)} is back first.`, `Every login is at its limit.`),
|
|
775
|
+
sub: subLine(Number.isFinite(first.limited_until) ? `${accountLabel(first)} returns ${until(first.limited_until)}.` : null),
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (live.length) {
|
|
780
|
+
// 3. an account-scoped bucket binds, and this login has model rungs that
|
|
781
|
+
// therefore buy nothing. Only said where models are KNOWN: on a login
|
|
782
|
+
// with no model buckets there is no switch to warn anyone off.
|
|
783
|
+
if (b && b.scope === 'account' && knownModels(subject).length) {
|
|
784
|
+
const alt = openModels(subject)[0]
|
|
785
|
+
// the rung a hand-off off this login would ACTUALLY take, from the
|
|
786
|
+
// chooser's own answer for a terminal running here, and only the
|
|
787
|
+
// nearest open login when no live row has one. A login that is open is
|
|
788
|
+
// not the same fact as a rung that is eligible: a reserve, a cost gate
|
|
789
|
+
// or a `below N%` rung can rule one out, and the sentence that names it
|
|
790
|
+
// is the sentence the reader acts on.
|
|
791
|
+
const eligible = live.filter((s) => acctOf(s) === subject).map((s) => s.eligible_next).find(Boolean)
|
|
792
|
+
const next = eligible || openElsewhere[0]
|
|
793
|
+
return {
|
|
794
|
+
line: headline(`${accountLabel(subject)} has ${leftOf(b)}% left, shared by every model.`, `${accountLabel(subject)} has ${leftOf(b)}% left, for every model.`),
|
|
795
|
+
sub: subLine(
|
|
796
|
+
alt ? `Switching to ${alt} buys nothing.` : null,
|
|
797
|
+
next ? `Next off ${accountLabel(subject)}: ${eligible ? rungLabel(next) : accountLabel(next)}.` : 'Nothing else is open.',
|
|
798
|
+
staleClause(subject, b),
|
|
799
|
+
),
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// 4. a model bucket is walled while the account window is open: the one
|
|
804
|
+
// case where a same-login switch is the answer.
|
|
805
|
+
const out = acctState(subject) === 'walled' ? [] : walledModels(subject)
|
|
806
|
+
if (out.length) {
|
|
807
|
+
const wall = wallFor(subject, out[0])
|
|
808
|
+
const open = openModels(subject)[0]
|
|
809
|
+
const acct = accountBucket(subject)
|
|
810
|
+
return {
|
|
811
|
+
line: headline(
|
|
812
|
+
`${Model(out[0])} is out until ${until(wall.limited_until)}; ${open || 'no other model'} is open.`,
|
|
813
|
+
`${Model(out[0])} is out until ${until(wall.limited_until)}.`,
|
|
814
|
+
),
|
|
815
|
+
sub: subLine(
|
|
816
|
+
acct ? `${accountLabel(subject)} still has ${leftOf(acct)}% of ${windowPhrase(acct)}.` : null,
|
|
817
|
+
open ? `Hand off > ${subject.agent}/${open} keeps this terminal.` : null,
|
|
818
|
+
),
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// 5. a model bucket came back and a terminal is still on the rung Leg
|
|
823
|
+
// dropped it to. A wall Leg recorded whose clock has run out is the only
|
|
824
|
+
// evidence that a model returned, and a live row on this login running a
|
|
825
|
+
// DIFFERENT model is the only evidence anyone is still downshifted; with
|
|
826
|
+
// neither the sentence is never guessed at.
|
|
827
|
+
//
|
|
828
|
+
// Spec A.5 lists this row under "several logins carry work", where it can
|
|
829
|
+
// never fire: the two branches below return for any login that has a
|
|
830
|
+
// figure at all. It is a state CHANGE, and it outranks the two branches
|
|
831
|
+
// that restate a standing percentage (DEVIATIONS.md, step 3).
|
|
832
|
+
const back = knownModels(subject)
|
|
833
|
+
.filter((m) => { const w = subject.walls && subject.walls[m]; return w && Number.isFinite(w.limited_until) && w.limited_until * 1000 <= Date.now() && !wallFor(subject, m) })
|
|
834
|
+
.find((m) => live.some((s) => acctOf(s) === subject && s.model && s.model !== m))
|
|
835
|
+
if (back) {
|
|
836
|
+
const down = live.find((s) => acctOf(s) === subject && s.model && s.model !== back)
|
|
837
|
+
return {
|
|
838
|
+
line: headline(`${Model(back)} is back; ${rowName(down)} is still on ${down.model}.`, `${Model(back)} is back.`),
|
|
839
|
+
sub: subLine(`Leg climbs back at the next hand-off.`, `Back to ${back} on the row does it now.`),
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// 5.5. the burn rate (A.5 row 4): a time beats a percentage, because the
|
|
844
|
+
// decision is about the afternoon and not about the number. The rate
|
|
845
|
+
// rides each session's own `capacity` (binding(usage, model) in
|
|
846
|
+
// src/server.mjs carries burn()); the accounts payload is buckets, not
|
|
847
|
+
// rates, so a live row bound to the SAME bucket as the strip's figure is
|
|
848
|
+
// where the board reads it. A guest payload has no capacity at all, so a
|
|
849
|
+
// guest board never prints a time, which is the rule the strip follows
|
|
850
|
+
// already. Placed under the model-came-back branch and above the two
|
|
851
|
+
// standing-percentage branches: a state CHANGE still outranks a figure.
|
|
852
|
+
const rate = b
|
|
853
|
+
? live.map((s) => ((acctOf(s) === subject && s.capacity && s.capacity.kind === b.kind && (s.capacity.model || null) === (b.model || null)) ? forecastOf(s.capacity) : null)).find(Boolean) || null
|
|
854
|
+
: null
|
|
855
|
+
if (rate) {
|
|
856
|
+
const who = b.model ? Model(b.model) : accountLabel(subject)
|
|
857
|
+
// which other models this login has published a bucket or a wall for:
|
|
858
|
+
// said because a Fable figure is NOT the login's figure, and the reader
|
|
859
|
+
// who takes it for one plans the wrong afternoon. A model nobody has
|
|
860
|
+
// seen is never named, so a login with one bucket says nothing here.
|
|
861
|
+
const others = b.model ? knownModels(subject).filter((m) => m !== b.model).map(Model) : []
|
|
862
|
+
return {
|
|
863
|
+
line: headline(`About ${burnPhrase(rate.seconds_left)} of ${who} left.`, `About ${burnPhrase(rate.seconds_left)} left.`),
|
|
864
|
+
sub: subLine(
|
|
865
|
+
`${burnVolume(rate, 'From')}.`,
|
|
866
|
+
b.model
|
|
867
|
+
? (others.length ? `${andList(others)} ${others.length === 1 ? 'has its own bucket' : 'have their own buckets'}.` : null)
|
|
868
|
+
: 'Shared by every model.',
|
|
869
|
+
staleClause(subject, b),
|
|
870
|
+
),
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// 6. one login carries every live terminal: one login, one point of
|
|
875
|
+
// failure, and that is what the sentence says.
|
|
876
|
+
if (carrying.length === 1 && b) {
|
|
877
|
+
const who = b.model ? Model(b.model) : accountLabel(subject)
|
|
878
|
+
return {
|
|
879
|
+
line: headline(
|
|
880
|
+
`${who} is at ${Math.round(b.percent)}% of ${windowPhrase(b)}, the only login open.`,
|
|
881
|
+
`${who} is at ${Math.round(b.percent)}% of ${windowPhrase(b)}.`,
|
|
882
|
+
),
|
|
883
|
+
sub: subLine(staleClause(subject, b), otherWalls()),
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// 7. several logins carry work: name the one closest to a wall, and the
|
|
888
|
+
// volume, then put the next login's figure in the sub.
|
|
889
|
+
if (b) {
|
|
890
|
+
const second = other(subject).map(figure).filter(Boolean)[0]
|
|
891
|
+
return {
|
|
892
|
+
line: headline(
|
|
893
|
+
`${accountLabel(subject)} has ${leftOf(b)}% left, and ${live.length} terminal${live.length === 1 ? ' is' : 's are'} working.`,
|
|
894
|
+
`${accountLabel(subject)} has ${leftOf(b)}% left.`,
|
|
895
|
+
),
|
|
896
|
+
sub: subLine(second, staleClause(subject, b), otherWalls()),
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// live, and not one login has published a figure. Never a guess.
|
|
901
|
+
return {
|
|
902
|
+
line: headline(`${live.length} terminal${live.length === 1 ? ' is' : 's are'} working, and no login has a figure.`, `${live.length} terminal${live.length === 1 ? ' is' : 's are'} working.`),
|
|
903
|
+
sub: subLine(otherWalls(), 'No login has reported a usage figure yet.'),
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// 8 to 11. nothing is running.
|
|
908
|
+
const bestOpen = openElsewhere.concat(acctState(subject) === 'walled' ? [] : [subject]).map((a) => ({ a, b: bindingOf(a) })).filter((x) => x.b).sort((x, y) => y.b.percent - x.b.percent)[0]
|
|
909
|
+
const openLine = bestOpen ? `${bestOpen.b.model ? Model(bestOpen.b.model) : accountLabel(bestOpen.a)} is at ${Math.round(bestOpen.b.percent)}% of ${windowPhrase(bestOpen.b)}.` : null
|
|
910
|
+
if (walled.length) {
|
|
911
|
+
const first = [...walled].sort((x, y) => (x.limited_until || Infinity) - (y.limited_until || Infinity))[0]
|
|
912
|
+
return {
|
|
913
|
+
line: headline(
|
|
914
|
+
`Nothing is running. ${accountLabel(first)} is back ${until(first.limited_until)}.`,
|
|
915
|
+
`Nothing is running. ${accountLabel(first)} is at its limit.`,
|
|
916
|
+
),
|
|
917
|
+
sub: subLine(openLine),
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
if (bestOpen) return { line: headline(`Nothing is running. ${openLine}`, 'Nothing is running.'), sub: '' }
|
|
921
|
+
return { line: 'Nothing is running, and no login has a figure.', sub: '' }
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// The login panels are behind one disclosure now, and whether it is open is
|
|
925
|
+
// the reader's decision, kept across reloads. localStorage throws in a
|
|
926
|
+
// private window and on a board opened from a file, so it is never load
|
|
927
|
+
// bearing: the strip and the panels both render either way.
|
|
928
|
+
const CAP_KEY = 'legCapacityOpen'
|
|
929
|
+
let capacityOpen = (() => { try { return localStorage.getItem(CAP_KEY) === '1' } catch { return false } })()
|
|
930
|
+
function renderCapacityToggle() {
|
|
931
|
+
const btn = document.getElementById('capacity-toggle')
|
|
932
|
+
const drawer = document.getElementById('capacity-drawer')
|
|
933
|
+
if (btn) {
|
|
934
|
+
btn.setAttribute('aria-expanded', capacityOpen ? 'true' : 'false')
|
|
935
|
+
btn.textContent = capacityOpen ? 'Hide capacity and models' : 'Capacity and models >'
|
|
936
|
+
}
|
|
937
|
+
if (drawer) drawer.hidden = !capacityOpen
|
|
938
|
+
}
|
|
939
|
+
function toggleCapacity() {
|
|
940
|
+
capacityOpen = !capacityOpen
|
|
941
|
+
try { localStorage.setItem(CAP_KEY, capacityOpen ? '1' : '0') } catch { /* private window: the drawer still opens, it just does not remember */ }
|
|
942
|
+
renderCapacityToggle()
|
|
367
943
|
}
|
|
368
944
|
|
|
369
945
|
// 6.1.5, all eight states. The walled state is an ADDITIONAL state of the
|
|
@@ -375,11 +951,14 @@
|
|
|
375
951
|
box.textContent = ''
|
|
376
952
|
const list = accounts || []
|
|
377
953
|
|
|
378
|
-
const { line, sub } = verdictLines(list, view ? view.sessions : [])
|
|
954
|
+
const { line, sub } = verdictLines(list, view ? view.sessions : [], view ? view.cards_waiting : null)
|
|
379
955
|
const h1 = document.getElementById('verdict-line')
|
|
380
956
|
const p = document.getElementById('verdict-sub')
|
|
381
957
|
if (h1) h1.textContent = line
|
|
382
958
|
if (p) p.textContent = sub
|
|
959
|
+
// the strip is the only usage on screen until the reader opens the drawer
|
|
960
|
+
capacityStrip(list)
|
|
961
|
+
renderCapacityToggle()
|
|
383
962
|
|
|
384
963
|
if (!list.length) return
|
|
385
964
|
// Size encodes importance. The login the terminals are on gets the wide lit
|
|
@@ -454,12 +1033,24 @@
|
|
|
454
1033
|
? `${o.agent} (${tail(o.session_id)}) is changing ${files} in another checkout; whoever lands second rebases`
|
|
455
1034
|
: `${o.agent} (${tail(o.session_id)}) is editing ${files} too` })
|
|
456
1035
|
}
|
|
1036
|
+
// A.6: a terminal parked at a permission prompt is waiting on a human, and
|
|
1037
|
+
// at rank 3 it raises the row, sorts it and counts it in the region head
|
|
1038
|
+
// through `needsYou` without a second predicate.
|
|
1039
|
+
const waits = waitingNote(notifyWait(s))
|
|
1040
|
+
if (waits) out.push(waits)
|
|
457
1041
|
for (const r of s.requests || []) out.push({ rank: 4, cat: 'handoff request', tone: 'warn', text: `${r.by} asked to take this terminal at ${clockAt(Date.parse(r.at))}` })
|
|
458
|
-
|
|
1042
|
+
// the OTHER shape on `waiting`: the all-out countdown, where the child is
|
|
1043
|
+
// already dead and nobody is waiting on a human
|
|
1044
|
+
if (s.status === 'waiting' && resetWait(s)) out.push({ rank: 5, cat: 'waiting', tone: 'warn', text: `waiting for ${optionLabel(s.waiting)} at ${until(s.waiting.resets_at)}` })
|
|
459
1045
|
if (s.status === 'handing_off' && s.handoff && s.handoff.to) out.push({ rank: 6, cat: 'handing off', tone: 'warn', text: `handing off to ${optionLabel(s.handoff.to)}, ${s.handoff.reason}${s.handoff.at ? `, ${ago(Date.now() - Date.parse(s.handoff.at))}` : ''}` })
|
|
460
1046
|
// rank 8 does not restate the percentage: the head prints it in 30px type a
|
|
461
1047
|
// few inches above. It names what the head does not carry, the fallback.
|
|
462
1048
|
if (s.warning) out.push({ rank: 8, cat: 'near limit', tone: 'warn', text: `near the ${s.warning.window} wall, next: ${s.chain && s.chain[0] ? optionLabel(s.chain[0]) : 'no eligible fallback'}` })
|
|
1049
|
+
// rank 8.5: the bucket that binds THIS terminal is near its wall. It sorts
|
|
1050
|
+
// under the account's own warning and above the activity fallback, because
|
|
1051
|
+
// it is the more specific of the two: per model, not per login.
|
|
1052
|
+
const cap = capacityNote(s, accountOf(s))
|
|
1053
|
+
if (cap) out.push(cap)
|
|
463
1054
|
// rank 10 is a genuine fallback, so a state nobody wrote a fixture for still
|
|
464
1055
|
// gets a correct sentence rather than an empty slot.
|
|
465
1056
|
out.push({ rank: 10, cat: 'activity', tone: 'muted', text: `turn ${s.turns || 0}${s.last_activity ? `, last activity ${clockAt(Date.parse(s.last_activity))}` : ''}` })
|
|
@@ -517,32 +1108,181 @@
|
|
|
517
1108
|
return el('div', { class: 'kv' }, rows)
|
|
518
1109
|
}
|
|
519
1110
|
|
|
520
|
-
|
|
1111
|
+
// ---- B.3 the fallback ladder, as an editable list -----------------------
|
|
1112
|
+
// A rung is a destination, not an agent: {agent, account, model, when, cost}.
|
|
1113
|
+
// Everything below is pure, so the round trip from a saved ladder to the
|
|
1114
|
+
// controls and back is testable without a DOM.
|
|
1115
|
+
function moveRung(ladder, index, delta) {
|
|
521
1116
|
const target = index + delta
|
|
522
|
-
if (target < 0 || target >=
|
|
523
|
-
const next = [...
|
|
1117
|
+
if (target < 0 || target >= ladder.length) return [...ladder]
|
|
1118
|
+
const next = [...ladder]
|
|
524
1119
|
;[next[index], next[target]] = [next[target], next[index]]
|
|
525
1120
|
return next
|
|
526
1121
|
}
|
|
1122
|
+
// `claude / opus`, `codex`, `claude/work / sonnet`: the login first, then the
|
|
1123
|
+
// model, and a model is never invented for an agent that published none.
|
|
1124
|
+
function rungLabel(r) {
|
|
1125
|
+
if (!r || !r.agent) return 'none'
|
|
1126
|
+
const login = r.account && r.account !== 'default' ? `${r.agent}/${r.account}` : r.agent
|
|
1127
|
+
return r.model ? `${login} / ${r.model}` : login
|
|
1128
|
+
}
|
|
1129
|
+
const rungKey = (r) => `${r.agent}--${r.account || 'default'}--${r.model || ''}`
|
|
1130
|
+
// What a rung spends, in words. `plan` is the subscription already paid for,
|
|
1131
|
+
// which is why it is the only cost that reads as nothing extra.
|
|
1132
|
+
const COST_WORDS = { free: 'free', plan: 'on the plan', credits: 'spends usage credits', metered: 'spends metered credits' }
|
|
1133
|
+
function costWord(cost) { return COST_WORDS[cost] || COST_WORDS.plan }
|
|
1134
|
+
|
|
1135
|
+
// B.6, one option in the Hand off picker. Three fields, always in this order:
|
|
1136
|
+
// the rung, what taking it does to the conversation, and what it costs you
|
|
1137
|
+
// right now. A rung that cannot be taken carries the SERVER'S reason
|
|
1138
|
+
// verbatim, never a board paraphrase: that sentence is the one the chooser
|
|
1139
|
+
// itself would print in the ledger, and two wordings for one refusal is how a
|
|
1140
|
+
// greyed row starts lying. The separator is a middot because a browser
|
|
1141
|
+
// collapses runs of spaces inside an <option>.
|
|
1142
|
+
function handoffOptionText(t) {
|
|
1143
|
+
const mode = t.keeps_conversation ? 'same terminal, keeps the conversation' : 'new agent, from the bundle'
|
|
1144
|
+
let state
|
|
1145
|
+
if (!t.available) state = `${t.reason || 'not available right now'}${Number.isFinite(t.resets_at) ? ` until ${until(t.resets_at)}` : ''}`
|
|
1146
|
+
else if (t.reason) state = t.reason
|
|
1147
|
+
else state = ['credits', 'metered'].includes(t.cost) ? `ready, ${costWord(t.cost)}` : 'ready'
|
|
1148
|
+
return [rungLabel(t), mode, state].join(' · ')
|
|
1149
|
+
}
|
|
1150
|
+
// A destination is a rung, so it is remembered as one. The index is still the
|
|
1151
|
+
// option's value (an account name is not ours to parse), but an index is a
|
|
1152
|
+
// position in a list the poll rewrites, and the pick has to outlive that.
|
|
1153
|
+
function pickKey(t) { return t && t.agent ? `${t.agent}/${t.account || 'default'}/${t.model || ''}` : null }
|
|
1154
|
+
function pickIndex(targets, key) {
|
|
1155
|
+
if (!key) return ''
|
|
1156
|
+
const at = (targets || []).findIndex((t) => pickKey(t) === key)
|
|
1157
|
+
return at < 0 ? '' : String(at)
|
|
1158
|
+
}
|
|
1159
|
+
// `when` is one string on the record and two controls on screen.
|
|
1160
|
+
function whenKind(when) { return String(when || 'always').startsWith('below:') ? 'below' : (when === 'walled-only' ? 'walled-only' : 'always') }
|
|
1161
|
+
// The server's own range is 0 to 100 (WHEN_RE in src/preferences.mjs), and
|
|
1162
|
+
// what is stored is what is shown: a zero is a reading, so a rung saved as
|
|
1163
|
+
// `below:0` renders as 0 and carries its consequence (whenFlag) rather than
|
|
1164
|
+
// being redrawn as a 50 nobody chose. Only a `below:` with no number at all
|
|
1165
|
+
// falls back.
|
|
1166
|
+
function whenPct(when) {
|
|
1167
|
+
const n = Number(String(when || '').slice('below:'.length))
|
|
1168
|
+
return Number.isFinite(n) && n >= 0 && n <= 100 && String(when || '').slice('below:'.length).trim() !== '' ? n : 50
|
|
1169
|
+
}
|
|
1170
|
+
function whenString(kind, pct) {
|
|
1171
|
+
if (kind === 'walled-only') return 'walled-only'
|
|
1172
|
+
if (kind !== 'below') return 'always'
|
|
1173
|
+
const n = Math.max(0, Math.min(100, Math.round(Number(pct) || 0)))
|
|
1174
|
+
return `below:${n}`
|
|
1175
|
+
}
|
|
1176
|
+
// An empty box is not a percentage. Clearing the number to type a new one and
|
|
1177
|
+
// tabbing away used to store `below:1`, a rung that is never taken; the rung
|
|
1178
|
+
// goes back to `always`, which is the rule with no number in it.
|
|
1179
|
+
function whenFromBox(value) {
|
|
1180
|
+
const raw = String(value === null || value === undefined ? '' : value).trim()
|
|
1181
|
+
return raw === '' ? 'always' : whenString('below', raw)
|
|
1182
|
+
}
|
|
1183
|
+
// A stored number the editor would never produce is shown with what it does,
|
|
1184
|
+
// never rewritten: both ends of the server's range are legal and neither is
|
|
1185
|
+
// a percentage anybody means.
|
|
1186
|
+
function whenFlag(when) {
|
|
1187
|
+
if (whenKind(when) !== 'below') return null
|
|
1188
|
+
const n = whenPct(when)
|
|
1189
|
+
if (n === 0) return 'below 0% is never true: this rung is never taken'
|
|
1190
|
+
if (n === 100) return 'below 100% is always true: this rung is taken like always'
|
|
1191
|
+
return null
|
|
1192
|
+
}
|
|
1193
|
+
const WHEN_WORDS = [['always', 'always'], ['below', 'below N%'], ['walled-only', 'walled only']]
|
|
527
1194
|
|
|
528
|
-
|
|
1195
|
+
// The editor. Numbered rows, one select and one optional number per rung, and
|
|
1196
|
+
// three buttons that survive a rebuild by data-focus-key the way the order
|
|
1197
|
+
// editor's did. No drag: a list this short is faster with two buttons, and a
|
|
1198
|
+
// drag has no keyboard.
|
|
1199
|
+
function ladderRows(ladder, onChange, scope) {
|
|
529
1200
|
const box = el('div', {})
|
|
530
|
-
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
})
|
|
537
|
-
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
1201
|
+
ladder.forEach((r, index) => {
|
|
1202
|
+
const key = rungKey(r)
|
|
1203
|
+
const label = rungLabel(r)
|
|
1204
|
+
const row = el('div', { class: 'ladder-row' })
|
|
1205
|
+
row.appendChild(el('span', { class: 'ladder-num' }, [`${index + 1}.`]))
|
|
1206
|
+
row.appendChild(el('span', { class: `dot id-${idOf(r.agent)}`, 'aria-hidden': 'true' }))
|
|
1207
|
+
row.appendChild(el('span', { class: `ladder-name chip-id-${idOf(r.agent)}` }, [label]))
|
|
1208
|
+
|
|
1209
|
+
const kind = whenKind(r.when)
|
|
1210
|
+
const whenId = `ladder-when-${scope}-${key}`
|
|
1211
|
+
const when = el('select', { id: whenId, class: 'ladder-when', 'aria-label': `When to take ${label}`, 'data-focus-key': `ladder:${scope}:${key}:when` })
|
|
1212
|
+
for (const [value, text] of WHEN_WORDS) {
|
|
1213
|
+
const opt = el('option', { value }, [text])
|
|
1214
|
+
if (value === kind) opt.setAttribute('selected', 'selected')
|
|
1215
|
+
when.appendChild(opt)
|
|
1216
|
+
}
|
|
1217
|
+
when.value = kind
|
|
1218
|
+
when.addEventListener('change', () => onChange(ladder.map((x, i) => (i === index ? { ...x, when: whenString(when.value, whenPct(r.when)) } : x))))
|
|
1219
|
+
// one cell for the rule and its number, so the selects line up in a
|
|
1220
|
+
// column whether or not a rung carries a percentage
|
|
1221
|
+
const rule = el('span', { class: 'ladder-rule' }, [when])
|
|
1222
|
+
row.appendChild(rule)
|
|
1223
|
+
if (kind === 'below') {
|
|
1224
|
+
const pct = el('input', {
|
|
1225
|
+
type: 'number', min: '0', max: '100', class: 'ladder-pct', value: String(whenPct(r.when)),
|
|
1226
|
+
'aria-label': `Take ${label} only under this percent`, 'data-focus-key': `ladder:${scope}:${key}:pct`,
|
|
1227
|
+
})
|
|
1228
|
+
pct.addEventListener('change', () => onChange(ladder.map((x, i) => (i === index ? { ...x, when: whenFromBox(pct.value) } : x))))
|
|
1229
|
+
rule.appendChild(pct)
|
|
1230
|
+
rule.appendChild(el('span', { class: 'ladder-cost' }, ['%']))
|
|
1231
|
+
const flag = whenFlag(r.when)
|
|
1232
|
+
if (flag) rule.appendChild(el('span', { class: 'ladder-flag tone-warn' }, [flag]))
|
|
1233
|
+
}
|
|
1234
|
+
row.appendChild(el('span', { class: 'ladder-cost ladder-costcol' }, [costWord(r.cost)]))
|
|
1235
|
+
|
|
1236
|
+
const up = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-label': `Move ${label} earlier`, disabled: index === 0 ? '' : null, 'data-focus-key': `ladder:${scope}:${key}:up` }, ['Up'])
|
|
1237
|
+
const down = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-label': `Move ${label} later`, disabled: index === ladder.length - 1 ? '' : null, 'data-focus-key': `ladder:${scope}:${key}:down` }, ['Down'])
|
|
1238
|
+
const drop = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-label': `Remove ${label} from the ladder`, disabled: ladder.length < 2 ? '' : null, 'data-focus-key': `ladder:${scope}:${key}:remove` }, ['Remove'])
|
|
1239
|
+
up.addEventListener('click', () => onChange(moveRung(ladder, index, -1)))
|
|
1240
|
+
down.addEventListener('click', () => onChange(moveRung(ladder, index, 1)))
|
|
1241
|
+
drop.addEventListener('click', () => onChange(ladder.filter((_, i) => i !== index)))
|
|
1242
|
+
row.append(up, down, drop)
|
|
1243
|
+
box.appendChild(row)
|
|
542
1244
|
})
|
|
543
1245
|
return box
|
|
544
1246
|
}
|
|
545
1247
|
|
|
1248
|
+
// `[+ Add a rung]`: an agent and one of its models, or `default` for the
|
|
1249
|
+
// model the CLI picks itself. An agent Leg knows no model names for offers
|
|
1250
|
+
// `default` alone rather than a guess.
|
|
1251
|
+
// A dead control is worse than none: pressing Add on a rung the ladder
|
|
1252
|
+
// already carries used to do nothing at all, with no row, no sentence and no
|
|
1253
|
+
// hover reason, so the reader pressed it again. It names the rung it found
|
|
1254
|
+
// and where it already is.
|
|
1255
|
+
function duplicateRung(ladder, rung) {
|
|
1256
|
+
const at = (ladder || []).findIndex((r) => rungKey(r) === rungKey(rung))
|
|
1257
|
+
return at < 0 ? null : `${rungLabel(rung)} is already rung ${at + 1}.`
|
|
1258
|
+
}
|
|
1259
|
+
function addRungRow(ladder, onChange, scope, onRefuse) {
|
|
1260
|
+
const row = el('div', { class: 'ladder-add' })
|
|
1261
|
+
const agentId = `ladder-add-agent-${scope}`
|
|
1262
|
+
const modelId = `ladder-add-model-${scope}`
|
|
1263
|
+
const agent = el('select', { id: agentId, class: 'ladder-when', 'aria-label': 'Agent for the new rung', 'data-focus-key': `ladder:${scope}:add:agent` })
|
|
1264
|
+
for (const a of LADDER_AGENTS) agent.appendChild(el('option', { value: a }, [a]))
|
|
1265
|
+
agent.value = LADDER_AGENTS[0]
|
|
1266
|
+
const model = el('select', { id: modelId, class: 'ladder-when', 'aria-label': 'Model for the new rung', 'data-focus-key': `ladder:${scope}:add:model` })
|
|
1267
|
+
const fillModels = () => {
|
|
1268
|
+
model.textContent = ''
|
|
1269
|
+
model.appendChild(el('option', { value: '' }, ['default']))
|
|
1270
|
+
for (const m of MODEL_ALIASES[agent.value] || []) model.appendChild(el('option', { value: m }, [m]))
|
|
1271
|
+
model.value = ''
|
|
1272
|
+
}
|
|
1273
|
+
fillModels()
|
|
1274
|
+
agent.addEventListener('change', fillModels)
|
|
1275
|
+
const add = el('button', { type: 'button', class: 'btn btn-secondary', 'data-focus-key': `ladder:${scope}:add:go` }, ['+ Add a rung'])
|
|
1276
|
+
add.addEventListener('click', () => {
|
|
1277
|
+
const rung = { agent: agent.value, account: 'default', model: model.value || null, when: 'always', cost: agent.value === 'agy' ? 'free' : agent.value === 'grok' ? 'metered' : 'plan' }
|
|
1278
|
+
const already = duplicateRung(ladder, rung)
|
|
1279
|
+
if (already) { if (onRefuse) onRefuse(already); return }
|
|
1280
|
+
onChange([...ladder, rung])
|
|
1281
|
+
})
|
|
1282
|
+
row.append(el('label', { for: agentId }, ['Add']), agent, el('label', { for: modelId }, ['model']), model, add)
|
|
1283
|
+
return row
|
|
1284
|
+
}
|
|
1285
|
+
|
|
546
1286
|
// Every region on this page is wiped and rebuilt on a timer, so a control the
|
|
547
1287
|
// reader had tabbed to is a different element three seconds later and focus
|
|
548
1288
|
// lands back on <body>. Controls that survive a rebuild by identity carry a
|
|
@@ -592,8 +1332,52 @@
|
|
|
592
1332
|
|
|
593
1333
|
// Absolute priority, not a rotation: the saved list decides, minus the agent
|
|
594
1334
|
// already running here, so an agent placed last stays last.
|
|
595
|
-
|
|
596
|
-
|
|
1335
|
+
// The rungs below the one a terminal is on: what it would try next, in order.
|
|
1336
|
+
function rungsAfter(current, ladder) {
|
|
1337
|
+
return ladder.filter((r) => !(r.agent === current.agent && (r.account || 'default') === (current.account || 'default') && (r.model || null) === (current.model || null)))
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// B.6 `Back to fable`. Two conditions, both of which must be KNOWN true from
|
|
1341
|
+
// data this page already has: the row is running a model below the top rung
|
|
1342
|
+
// of its own login's ladder, and that top rung is open. "Open" means no
|
|
1343
|
+
// active wall on the model, no wall on the login, and either no bucket for it
|
|
1344
|
+
// at all or a bucket under 100. A login this board has no record for is not
|
|
1345
|
+
// "open", it is unknown, and an unknown destination gets no button: the whole
|
|
1346
|
+
// point of the control is that it will work when pressed.
|
|
1347
|
+
function topRungFor(s) {
|
|
1348
|
+
const ladder = Array.isArray(s && s.handoff_ladder) ? s.handoff_ladder : []
|
|
1349
|
+
return ladder.find((r) => r.agent === s.agent && (r.account || 'default') === (s.account || 'default')) || null
|
|
1350
|
+
}
|
|
1351
|
+
function climbTarget(s, accounts) {
|
|
1352
|
+
if (!s || !s.active || !s.model || s.hidden) return null
|
|
1353
|
+
const top = topRungFor(s)
|
|
1354
|
+
if (!top || !top.model || top.model === s.model) return null
|
|
1355
|
+
const names = MODEL_ALIASES[s.agent] || []
|
|
1356
|
+
const best = names.indexOf(top.model)
|
|
1357
|
+
const here = names.indexOf(s.model)
|
|
1358
|
+
if (best < 0 || here < 0 || here <= best) return null
|
|
1359
|
+
const acct = (accounts || []).find((x) => x.agent === s.agent && (x.account || 'default') === (s.account || 'default'))
|
|
1360
|
+
if (!acct || acctState(acct) === 'walled' || wallFor(acct, top.model)) return null
|
|
1361
|
+
const b = modelBucket(acct, top.model)
|
|
1362
|
+
if (b && b.percent >= 100) return null
|
|
1363
|
+
return top
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// B.5: the one sentence beside the checkbox, and the one that replaces it on
|
|
1367
|
+
// a login whose credits are off, where there is nothing to spend and so no
|
|
1368
|
+
// decision to offer. A dead control is worse than none.
|
|
1369
|
+
const MAY_SPEND_SENTENCE = 'A rung that spends usage credits or metered balance may be taken by an automatic hand-off.'
|
|
1370
|
+
const NO_CREDITS_SENTENCE = 'Usage credits are off, so there is nothing to spend through the wall.'
|
|
1371
|
+
// B.7, printed under the choice because it is not one: killing a working
|
|
1372
|
+
// agent to save budget loses the turn.
|
|
1373
|
+
const CLIMB_RULE = 'Leg never interrupts a running turn to climb.'
|
|
1374
|
+
const CLIMB_WORDS = {
|
|
1375
|
+
'next-handoff': 'Leg climbs back to the top rung at the next hand-off.',
|
|
1376
|
+
never: 'Stay on the lower rung until you press Back to fable.',
|
|
1377
|
+
}
|
|
1378
|
+
function creditsOff(v) {
|
|
1379
|
+
const a = ((v && v.accounts) || []).find((x) => x.agent === 'claude' && (x.account || 'default') === 'default')
|
|
1380
|
+
return Boolean(a && a.extra_usage && a.extra_usage.enabled === false)
|
|
597
1381
|
}
|
|
598
1382
|
|
|
599
1383
|
function renderDefaultOrder(v) {
|
|
@@ -601,19 +1385,93 @@
|
|
|
601
1385
|
if (!field) return
|
|
602
1386
|
field.hidden = !v.preferences
|
|
603
1387
|
if (!v.preferences) return
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
defaultEditor.
|
|
1388
|
+
const fresh = !defaultEditor.ladder || (!defaultEditor.dirty && !defaultEditor.saving)
|
|
1389
|
+
if (fresh) {
|
|
1390
|
+
defaultEditor.ladder = (v.preferences.handoff_ladder || []).map((r) => ({ ...r }))
|
|
1391
|
+
defaultEditor.climb_back = v.preferences.climb_back || 'next-handoff'
|
|
1392
|
+
defaultEditor.may_spend = Boolean(v.preferences.may_spend)
|
|
1393
|
+
defaultEditor.reserve = { ...(v.preferences.reserve || {}) }
|
|
1394
|
+
}
|
|
1395
|
+
const touch = (next) => {
|
|
1396
|
+
if (next) defaultEditor.ladder = next
|
|
610
1397
|
defaultEditor.dirty = true
|
|
611
1398
|
defaultEditor.status = ''
|
|
612
1399
|
renderDefaultOrder(view)
|
|
613
|
-
}
|
|
1400
|
+
}
|
|
1401
|
+
const focus = takeFocus(document)
|
|
1402
|
+
|
|
1403
|
+
const list = document.getElementById('default-order-list')
|
|
1404
|
+
list.textContent = ''
|
|
1405
|
+
list.appendChild(ladderRows(defaultEditor.ladder, touch, 'default'))
|
|
1406
|
+
list.appendChild(addRungRow(defaultEditor.ladder, touch, 'default', (msg) => {
|
|
1407
|
+
defaultEditor.status = msg
|
|
1408
|
+
defaultEditor.statusClass = 'bad'
|
|
1409
|
+
renderDefaultOrder(view)
|
|
1410
|
+
}))
|
|
1411
|
+
|
|
1412
|
+
// may_spend
|
|
1413
|
+
const spend = document.getElementById('ladder-spend')
|
|
1414
|
+
if (spend) {
|
|
1415
|
+
spend.textContent = ''
|
|
1416
|
+
const box = el('input', { type: 'checkbox', id: 'ladder-may-spend', 'aria-label': 'Let an automatic hand-off spend', 'data-focus-key': 'ladder:default:may-spend' })
|
|
1417
|
+
if (defaultEditor.may_spend) box.setAttribute('checked', 'checked')
|
|
1418
|
+
box.checked = defaultEditor.may_spend
|
|
1419
|
+
box.addEventListener('change', () => { defaultEditor.may_spend = Boolean(box.checked); touch(null) })
|
|
1420
|
+
spend.appendChild(el('label', { class: 'notify-toggle', for: 'ladder-may-spend' }, [box, ' Let an automatic hand-off spend']))
|
|
1421
|
+
spend.appendChild(el('p', { class: 'field-help' }, [MAY_SPEND_SENTENCE]))
|
|
1422
|
+
// B.5: with credits off and `can_toggle` false there is nothing to turn
|
|
1423
|
+
// on from here, so this states the fact and offers no button. A control
|
|
1424
|
+
// that cannot do anything is worse than none.
|
|
1425
|
+
if (creditsOff(v)) spend.appendChild(el('p', { class: 'field-help' }, [NO_CREDITS_SENTENCE]))
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// climb back
|
|
1429
|
+
const climb = document.getElementById('ladder-climb')
|
|
1430
|
+
if (climb) {
|
|
1431
|
+
climb.textContent = ''
|
|
1432
|
+
climb.appendChild(el('legend', {}, ['Climbing back']))
|
|
1433
|
+
for (const [value, sentence] of Object.entries(CLIMB_WORDS)) {
|
|
1434
|
+
const id = `ladder-climb-${value}`
|
|
1435
|
+
const radio = el('input', { type: 'radio', name: 'ladder-climb-back', id, value, 'aria-label': sentence, 'data-focus-key': `ladder:default:climb:${value}` })
|
|
1436
|
+
if (defaultEditor.climb_back === value) radio.setAttribute('checked', 'checked')
|
|
1437
|
+
radio.checked = defaultEditor.climb_back === value
|
|
1438
|
+
radio.addEventListener('change', () => { defaultEditor.climb_back = value; touch(null) })
|
|
1439
|
+
climb.appendChild(el('label', { class: 'notify-toggle', for: id }, [radio, ` ${sentence}`]))
|
|
1440
|
+
}
|
|
1441
|
+
climb.appendChild(el('p', { class: 'field-help' }, [CLIMB_RULE]))
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// reserve, one number per login
|
|
1445
|
+
const reserve = document.getElementById('ladder-reserve')
|
|
1446
|
+
if (reserve) {
|
|
1447
|
+
reserve.textContent = ''
|
|
1448
|
+
const logins = []
|
|
1449
|
+
for (const r of defaultEditor.ladder) if (!logins.includes(r.agent)) logins.push(r.agent)
|
|
1450
|
+
for (const agent of logins) {
|
|
1451
|
+
const id = `ladder-reserve-${agent}`
|
|
1452
|
+
const n = el('input', {
|
|
1453
|
+
type: 'number', min: '0', max: '100', class: 'ladder-pct', id,
|
|
1454
|
+
value: Number.isFinite(Number(defaultEditor.reserve[agent])) ? String(defaultEditor.reserve[agent]) : '0',
|
|
1455
|
+
'aria-label': `Keep this percent of ${agent} for your own terminals`,
|
|
1456
|
+
'data-focus-key': `ladder:default:reserve:${agent}`,
|
|
1457
|
+
})
|
|
1458
|
+
n.addEventListener('change', () => {
|
|
1459
|
+
const pct = Math.max(0, Math.min(100, Math.round(Number(n.value) || 0)))
|
|
1460
|
+
if (pct > 0) defaultEditor.reserve[agent] = pct
|
|
1461
|
+
else delete defaultEditor.reserve[agent]
|
|
1462
|
+
touch(null)
|
|
1463
|
+
})
|
|
1464
|
+
reserve.appendChild(el('div', { class: 'ladder-row' }, [
|
|
1465
|
+
el('label', { for: id }, [`Keep`]), n,
|
|
1466
|
+
el('span', { class: 'ladder-cost' }, [`% of ${agent} for your own terminals`]),
|
|
1467
|
+
]))
|
|
1468
|
+
}
|
|
1469
|
+
reserve.appendChild(el('p', { class: 'field-help' }, ['0 keeps nothing back. An automatic hand-off skips a rung past the floor; a hand-off you press yourself still takes it, and the picker says so.']))
|
|
1470
|
+
}
|
|
1471
|
+
|
|
614
1472
|
const save = document.getElementById('default-order-save')
|
|
615
1473
|
save.disabled = defaultEditor.saving || !defaultEditor.dirty
|
|
616
|
-
save.textContent = defaultEditor.saving ? 'Saving…' : 'Save
|
|
1474
|
+
save.textContent = defaultEditor.saving ? 'Saving…' : 'Save ladder'
|
|
617
1475
|
const status = document.getElementById('default-order-status')
|
|
618
1476
|
status.textContent = defaultEditor.status
|
|
619
1477
|
status.className = `field-status ${defaultEditor.statusClass}`
|
|
@@ -625,13 +1483,22 @@
|
|
|
625
1483
|
defaultEditor.status = ''
|
|
626
1484
|
renderDefaultOrder(view)
|
|
627
1485
|
try {
|
|
628
|
-
const data = await api('/api/settings', {
|
|
1486
|
+
const data = await api('/api/settings', {
|
|
1487
|
+
method: 'PATCH',
|
|
1488
|
+
body: {
|
|
1489
|
+
handoff_ladder: defaultEditor.ladder,
|
|
1490
|
+
climb_back: defaultEditor.climb_back,
|
|
1491
|
+
may_spend: defaultEditor.may_spend,
|
|
1492
|
+
reserve: defaultEditor.reserve,
|
|
1493
|
+
},
|
|
1494
|
+
})
|
|
629
1495
|
if (view) view.preferences = data.preferences
|
|
630
|
-
defaultEditor.
|
|
1496
|
+
defaultEditor.ladder = (data.preferences.handoff_ladder || []).map((r) => ({ ...r }))
|
|
631
1497
|
defaultEditor.dirty = false
|
|
632
|
-
defaultEditor.status =
|
|
1498
|
+
defaultEditor.status = `Saved for new terminals. Order off the ladder: ${(data.preferences.handoff_order || []).join(', ')}.`
|
|
633
1499
|
defaultEditor.statusClass = 'ok'
|
|
634
1500
|
} catch (err) {
|
|
1501
|
+
// the server's own sentence, verbatim: it is the one that names the rung
|
|
635
1502
|
defaultEditor.status = err.message
|
|
636
1503
|
defaultEditor.statusClass = 'bad'
|
|
637
1504
|
} finally {
|
|
@@ -661,8 +1528,9 @@
|
|
|
661
1528
|
sysMessage('removed the Leg record; the worktree and the branch are kept', 'ok')
|
|
662
1529
|
} else {
|
|
663
1530
|
await api(`/api/sessions/${encodeURIComponent(id)}/${action}`, { method: 'POST', body })
|
|
664
|
-
if (action === 'handoff') actionNotes.set(id, { at: Date.now(), tone: 'warn', text: 'hand-off requested; this terminal switches agents in a few seconds' })
|
|
1531
|
+
if (action === 'handoff') actionNotes.set(id, { at: Date.now(), tone: 'warn', text: body && body.agent ? `hand-off to ${rungLabel(body)} requested; this terminal switches agents in a few seconds` : 'hand-off requested; this terminal switches agents in a few seconds' })
|
|
665
1532
|
else if (action === 'end') actionNotes.set(id, { at: Date.now(), tone: 'warn', text: 'end requested; the agent stops after its current turn' })
|
|
1533
|
+
else if (action === 'end-as-card') actionNotes.set(id, { at: Date.now(), tone: 'ok', text: 'ended; a background card continues the task: in this checkout when the terminal had one of its own, else in a checkout of its own with the uncommitted work carried over. It is under Background, and Take over on it brings the work back to a terminal.' })
|
|
666
1534
|
else if (action === 'land/fix') actionNotes.set(id, { at: Date.now(), tone: 'ok', text: 'applied fix' })
|
|
667
1535
|
}
|
|
668
1536
|
refresh()
|
|
@@ -680,54 +1548,111 @@
|
|
|
680
1548
|
// `now: codex · then · claude`, a five-item list with two items called
|
|
681
1549
|
// "then". The agent names keep their identity colour inside it.
|
|
682
1550
|
const sequence = el('div', { class: 'chain-rail', 'aria-label': 'Terminal handoff sequence' })
|
|
683
|
-
const chain = el('span', { class: 'chip' }, ['now: ', el('span', { class: `chip-id-${idOf(s.agent)}` }, [
|
|
1551
|
+
const chain = el('span', { class: 'chip' }, ['now: ', el('span', { class: `chip-id-${idOf(s.agent)}` }, [rungLabel(s)])])
|
|
684
1552
|
// the fonts carry no arrow glyph, so the word does the arrow's job
|
|
685
|
-
for (const next of s.chain || []) chain.append(document.createTextNode(', then '), el('span', { class: `chip-id-${idOf(next.agent)}` }, [
|
|
1553
|
+
for (const next of s.chain || []) chain.append(document.createTextNode(', then '), el('span', { class: `chip-id-${idOf(next.agent)}` }, [rungLabel(next)]))
|
|
686
1554
|
sequence.appendChild(chain)
|
|
687
1555
|
wrap.appendChild(sequence)
|
|
688
|
-
|
|
689
|
-
|
|
1556
|
+
// the rung, not just the login: `claude / opus` and `claude / sonnet` are
|
|
1557
|
+
// two destinations and the sequence above already names both
|
|
1558
|
+
const preferred = s.preferred_next ? rungLabel(s.preferred_next) : 'none'
|
|
1559
|
+
const eligible = s.eligible_next ? rungLabel(s.eligible_next) : 'none'
|
|
690
1560
|
if (!s.handoff_availability_known) wrap.appendChild(el('p', { class: 'sentence tone-muted' }, [`preferred: ${preferred}, and current eligibility is unavailable for this older terminal`]))
|
|
691
1561
|
else if (!s.eligible_next) wrap.appendChild(el('p', { class: 'sentence tone-warn' }, [`preferred: ${preferred}. No fallback is eligible now; Leg waits if every account is at its limit.`]))
|
|
692
1562
|
else if (eligible !== preferred) wrap.appendChild(el('p', { class: 'sentence tone-muted' }, [`preferred: ${preferred}, first eligible now: ${eligible}`]))
|
|
693
1563
|
else wrap.appendChild(el('p', { class: 'sentence tone-muted' }, [`first eligible now: ${eligible}`]))
|
|
694
1564
|
wrap.appendChild(el('p', { class: 'blocker' }, ['Used after a usage limit or Hand off now. A normal exit ends this terminal.']))
|
|
695
1565
|
|
|
1566
|
+
// The picker. The Hand off now button on the panel stays the one-click
|
|
1567
|
+
// path (it takes the order); this names a destination instead. An option
|
|
1568
|
+
// that cannot be picked carries the reason in its own label, so nothing is
|
|
1569
|
+
// greyed out without saying why.
|
|
1570
|
+
const targets = Array.isArray(s.handoff_targets) ? s.handoff_targets : []
|
|
1571
|
+
if (s.active && targets.length) {
|
|
1572
|
+
const pick = el('div', { class: 'form-row' })
|
|
1573
|
+
const selectId = `handoff-to-${s.session_id}`
|
|
1574
|
+
pick.appendChild(el('label', { for: selectId }, ['Hand off now to']))
|
|
1575
|
+
const select = el('select', { id: selectId, 'aria-label': 'Hand off now to' })
|
|
1576
|
+
select.appendChild(el('option', { value: '' }, ['the next option in the order']))
|
|
1577
|
+
targets.forEach((t, i) => {
|
|
1578
|
+
// the index is the value: an account name is not ours to parse
|
|
1579
|
+
select.appendChild(el('option', { value: String(i), disabled: t.available ? null : 'disabled' }, [handoffOptionText(t)]))
|
|
1580
|
+
})
|
|
1581
|
+
// The drawer rebuilds itself every 3 seconds, and a destination chosen
|
|
1582
|
+
// four seconds ago was silently back to "the next option in the order"
|
|
1583
|
+
// while the confirm row still said the rung's name. The pick is held on
|
|
1584
|
+
// `drawer`, beside turnCap and expanded, and it is keyed by the RUNG, not
|
|
1585
|
+
// by the index: the poll can reorder the rows under it.
|
|
1586
|
+
select.value = pickIndex(targets, drawer.pick)
|
|
1587
|
+
if (drawer.pick && select.value === '') drawer.pick = null
|
|
1588
|
+
select.addEventListener('change', () => {
|
|
1589
|
+
const at = select.value === '' ? null : targets[Number(select.value)]
|
|
1590
|
+
drawer.pick = at ? pickKey(at) : null
|
|
1591
|
+
})
|
|
1592
|
+
const go = el('button', { type: 'button', class: 'btn btn-secondary' }, ['Hand off'])
|
|
1593
|
+
go.addEventListener('click', () => {
|
|
1594
|
+
const t = select.value === '' ? null : targets[Number(select.value)]
|
|
1595
|
+
// the confirm row names the destination and says whether the
|
|
1596
|
+
// conversation survives, because those are the two things that differ
|
|
1597
|
+
// between one rung and the next and neither is guessable from the row
|
|
1598
|
+
pendingConfirm = {
|
|
1599
|
+
id: s.session_id,
|
|
1600
|
+
question: t
|
|
1601
|
+
? `Hands off to ${rungLabel(t)}. ${t.keeps_conversation ? 'Same terminal, and the conversation is kept.' : 'A new agent starts in this terminal, primed from the bundle.'}`
|
|
1602
|
+
: 'Hands off to the first open rung of this terminal\'s ladder. The current turn stops.',
|
|
1603
|
+
verb: 'Hand off',
|
|
1604
|
+
action: 'handoff',
|
|
1605
|
+
body: t ? { agent: t.agent, account: t.account, ...(t.model ? { model: t.model } : {}) } : null,
|
|
1606
|
+
}
|
|
1607
|
+
renderSessions(view)
|
|
1608
|
+
})
|
|
1609
|
+
pick.appendChild(el('div', { class: 'chain-rail' }, [select, go]))
|
|
1610
|
+
if (!targets.some((t) => t.available)) {
|
|
1611
|
+
pick.appendChild(el('p', { class: 'field-help' }, ['Every destination is at its limit or not installed; a hand-off now waits for the first reset.']))
|
|
1612
|
+
}
|
|
1613
|
+
wrap.appendChild(pick)
|
|
1614
|
+
}
|
|
1615
|
+
|
|
696
1616
|
const editableNow = ['starting', 'running', 'warning', 'limit', 'waiting'].includes(s.status)
|
|
697
1617
|
if (!s.hidden && editableNow) {
|
|
698
1618
|
let state = sessionEditors.get(s.session_id)
|
|
699
|
-
|
|
1619
|
+
// this terminal's own ladder, else the machine default it would take on
|
|
1620
|
+
// its next launch. A terminal that cannot be edited in place still shows
|
|
1621
|
+
// the list it is about to inherit, so the save below means something.
|
|
1622
|
+
const source = (s.can_edit_handoff_order ? s.handoff_ladder : (view?.preferences?.handoff_ladder ?? s.handoff_ladder)) || []
|
|
1623
|
+
const copy = () => source.map((r) => ({ ...r }))
|
|
700
1624
|
if (!state) {
|
|
701
|
-
state = { open: false,
|
|
1625
|
+
state = { open: false, ladder: copy(), dirty: false, saving: false, status: '', statusClass: '' }
|
|
702
1626
|
sessionEditors.set(s.session_id, state)
|
|
703
|
-
} else if (!state.dirty && !state.saving) state.
|
|
704
|
-
const change = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-expanded': state.open ? 'true' : 'false', 'data-focus-key': `order-toggle:${s.session_id}` }, [state.open ? 'Close
|
|
1627
|
+
} else if (!state.dirty && !state.saving) state.ladder = copy()
|
|
1628
|
+
const change = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-expanded': state.open ? 'true' : 'false', 'data-focus-key': `order-toggle:${s.session_id}` }, [state.open ? 'Close ladder editor' : 'Change the ladder'])
|
|
705
1629
|
change.addEventListener('click', () => { state.open = !state.open; renderDrawer() })
|
|
706
1630
|
wrap.appendChild(change)
|
|
707
1631
|
if (state.open) {
|
|
708
1632
|
const editor = el('div', { class: 'detail-section' })
|
|
709
1633
|
editor.appendChild(el('p', { class: 'blocker' }, [s.can_edit_handoff_order
|
|
710
|
-
? '
|
|
711
|
-
: 'This terminal started before
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
state.status =
|
|
1634
|
+
? 'Each rung is an agent, a login and a model. Leg walks down from the top and takes the first rung that is open; the rung this terminal is already on is skipped.'
|
|
1635
|
+
: 'This terminal started before ladder changes were available. Save this ladder as the default, then restart when ready.']))
|
|
1636
|
+
const touch = (next) => { state.ladder = next; state.dirty = true; state.status = ''; renderDrawer() }
|
|
1637
|
+
editor.appendChild(ladderRows(state.ladder, touch, s.session_id))
|
|
1638
|
+
editor.appendChild(addRungRow(state.ladder, touch, s.session_id, (msg) => {
|
|
1639
|
+
state.status = msg
|
|
1640
|
+
state.statusClass = 'bad'
|
|
716
1641
|
renderDrawer()
|
|
717
|
-
}
|
|
718
|
-
editor.appendChild(el('p', { class: 'blocker' }, [`Draft
|
|
1642
|
+
}))
|
|
1643
|
+
editor.appendChild(el('p', { class: 'blocker' }, [`Draft ladder after ${rungLabel(s)}: ${rungsAfter(s, state.ladder).map(rungLabel).join(', then ') || 'nothing left'}`]))
|
|
719
1644
|
const status = el('span', { class: `field-status ${state.statusClass}`, 'aria-live': 'polite' }, [state.status])
|
|
720
1645
|
const save = el('button', { type: 'button', class: 'btn btn-secondary', disabled: state.saving || !state.dirty ? '' : null, 'data-focus-key': `order-save:${s.session_id}` }, [state.saving ? 'Saving…' : s.can_edit_handoff_order ? 'Save for this terminal' : 'Save as default for next launch'])
|
|
721
1646
|
save.addEventListener('click', async () => {
|
|
722
1647
|
state.saving = true; state.status = ''; renderDrawer()
|
|
723
1648
|
try {
|
|
724
1649
|
if (s.can_edit_handoff_order) {
|
|
725
|
-
await api(`/api/sessions/${encodeURIComponent(s.session_id)}/handoff-order`, { method: 'POST', body: {
|
|
1650
|
+
await api(`/api/sessions/${encodeURIComponent(s.session_id)}/handoff-order`, { method: 'POST', body: { handoff_ladder: state.ladder } })
|
|
726
1651
|
state.status = 'Saved for this terminal.'
|
|
727
1652
|
} else {
|
|
728
|
-
const data = await api('/api/settings', { method: 'PATCH', body: {
|
|
1653
|
+
const data = await api('/api/settings', { method: 'PATCH', body: { handoff_ladder: state.ladder } })
|
|
729
1654
|
if (view) view.preferences = data.preferences
|
|
730
|
-
defaultEditor.
|
|
1655
|
+
defaultEditor.ladder = (data.preferences.handoff_ladder || []).map((r) => ({ ...r }))
|
|
731
1656
|
defaultEditor.dirty = false
|
|
732
1657
|
state.status = 'Saved as the default. Restart this terminal when ready.'
|
|
733
1658
|
}
|
|
@@ -735,6 +1660,7 @@
|
|
|
735
1660
|
state.statusClass = 'ok'
|
|
736
1661
|
await refresh()
|
|
737
1662
|
} catch (err) {
|
|
1663
|
+
// the 409 or the 400 in the server's own words
|
|
738
1664
|
state.status = err.message
|
|
739
1665
|
state.statusClass = 'bad'
|
|
740
1666
|
} finally {
|
|
@@ -771,6 +1697,13 @@
|
|
|
771
1697
|
statusMark(s.status, s.session_id, urgent ? 'waiting on you' : null),
|
|
772
1698
|
el('span', { class: 'term-where', title: s.cwd || null }, [`${s.repo_name || s.cwd || 'unknown repo'}${branch ? ` on ${branch}` : ''}`]),
|
|
773
1699
|
])
|
|
1700
|
+
// A.4 rows 7 to 9, in reading order and on the SAME line: what changed,
|
|
1701
|
+
// which model actually answered, and whether it has gone quiet. The model
|
|
1702
|
+
// token is the agent and the model it resolved to, never a default.
|
|
1703
|
+
for (const t of registerTokens(s)) {
|
|
1704
|
+
if (t.kind === 'model') register.appendChild(el('span', { class: `term-model chip-id-${idOf(s.agent)}` }, [t.text]))
|
|
1705
|
+
else register.appendChild(el('span', { class: `term-${t.kind}` }, [t.text]))
|
|
1706
|
+
}
|
|
774
1707
|
if (s.account !== 'default') register.appendChild(el('span', { class: 'chip' }, [s.account]))
|
|
775
1708
|
if (shared() && s.owner) register.appendChild(el('span', { class: 'chip' }, [isMine(s) ? `${s.owner}, you` : s.owner]))
|
|
776
1709
|
if (s.lineage && s.lineage.from) register.appendChild(el('span', { class: 'chip' }, [`from ${s.lineage.from}`]))
|
|
@@ -811,6 +1744,7 @@
|
|
|
811
1744
|
if (open) for (const n of rest) body.appendChild(el('p', { class: `sentence tone-${n.tone}` }, [n.text]))
|
|
812
1745
|
}
|
|
813
1746
|
const touched = s.files || []
|
|
1747
|
+
let files = null
|
|
814
1748
|
if (!s.hidden && touched.length) {
|
|
815
1749
|
// comma-separated text, not chips: six file names are a sentence, and a
|
|
816
1750
|
// file that is also in an overlap is named in that sentence anyway
|
|
@@ -821,7 +1755,37 @@
|
|
|
821
1755
|
line.appendChild(el('span', { class: `file${overlapFiles.has(f) ? ' is-overlap' : ''}`, title: f }, [fileLabel(f)]))
|
|
822
1756
|
})
|
|
823
1757
|
if (touched.length > 6) line.appendChild(document.createTextNode(`, and ${touched.length - 6} more`))
|
|
824
|
-
|
|
1758
|
+
files = line
|
|
1759
|
+
}
|
|
1760
|
+
// A.4 row 12, at the right of the files line: the bucket that will stop
|
|
1761
|
+
// THIS terminal, which is per model and so is a fact about the row. The
|
|
1762
|
+
// region head carries the share clause that stops anyone adding three
|
|
1763
|
+
// rows' figures together (A.7).
|
|
1764
|
+
const phrase = s.hidden ? null : capacityPhrase(s)
|
|
1765
|
+
// B.6: the climb is a link on the capacity line, not a fifth button in the
|
|
1766
|
+
// 2x2 grid. It belongs beside the figure that explains why the row was
|
|
1767
|
+
// dropped a rung in the first place, and the grid is the shipped shape.
|
|
1768
|
+
const top = climbTarget(s, view && view.accounts)
|
|
1769
|
+
let climb = null
|
|
1770
|
+
if (top) {
|
|
1771
|
+
const back = (s.handoff_targets || []).find((t) => t.agent === top.agent && t.account === top.account && t.model === top.model)
|
|
1772
|
+
climb = el('button', { type: 'button', class: 'btn btn-text term-climb', 'data-focus-key': `climb:${s.session_id}` }, [`Back to ${top.model}`])
|
|
1773
|
+
climb.addEventListener('click', () => {
|
|
1774
|
+
pendingConfirm = {
|
|
1775
|
+
id: s.session_id,
|
|
1776
|
+
question: `Hands off now. The current turn stops and ${top.model} continues from ${back && back.keeps_conversation ? 'the conversation' : 'the bundle'}.`,
|
|
1777
|
+
verb: `Back to ${top.model}`,
|
|
1778
|
+
action: 'handoff',
|
|
1779
|
+
body: { agent: top.agent, account: top.account, model: top.model },
|
|
1780
|
+
}
|
|
1781
|
+
renderSessions(view)
|
|
1782
|
+
})
|
|
1783
|
+
}
|
|
1784
|
+
if (files || phrase || climb) {
|
|
1785
|
+
body.appendChild(el('div', { class: 'term-meta' }, [
|
|
1786
|
+
files,
|
|
1787
|
+
phrase || climb ? el('span', { class: 'term-capacity' }, [phrase, climb]) : null,
|
|
1788
|
+
]))
|
|
825
1789
|
}
|
|
826
1790
|
row.appendChild(body)
|
|
827
1791
|
|
|
@@ -843,11 +1807,20 @@
|
|
|
843
1807
|
// TypeError going only to the console.
|
|
844
1808
|
const pending = pendingConfirm
|
|
845
1809
|
term.appendChild(row)
|
|
846
|
-
|
|
1810
|
+
const confirm = confirmRow(pending.question, pending.verb, (btn) => act(s.session_id, pending.action, btn, pending.body ?? null))
|
|
1811
|
+
// a second verb on the same row, never a fifth grid button: End grows
|
|
1812
|
+
// 'End, and keep going as a card' (spec C.4), the moment being 'I have
|
|
1813
|
+
// to leave, keep going'. It sits before Cancel and takes the snapshot too.
|
|
1814
|
+
if (pending.alt) {
|
|
1815
|
+
const more = el('button', { type: 'button', class: 'btn btn-secondary', title: pending.alt.title || null }, [pending.alt.verb])
|
|
1816
|
+
more.addEventListener('click', () => { pendingConfirm = null; act(s.session_id, pending.alt.action, more, null) })
|
|
1817
|
+
confirm.insertBefore(more, confirm.lastChild)
|
|
1818
|
+
}
|
|
1819
|
+
term.appendChild(confirm)
|
|
847
1820
|
return term
|
|
848
1821
|
}
|
|
849
1822
|
const actions = el('div', { class: 'term-actions' })
|
|
850
|
-
const ask = (question, verb, action) => () => { pendingConfirm = { id: s.session_id, question, verb, action }; renderSessions(view) }
|
|
1823
|
+
const ask = (question, verb, action, alt = null) => () => { pendingConfirm = { id: s.session_id, question, verb, action, alt }; renderSessions(view) }
|
|
851
1824
|
if (s.hidden) {
|
|
852
1825
|
if (s.active) {
|
|
853
1826
|
const q = el('button', { type: 'button', class: 'btn btn-secondary', title: `ask ${s.owner || 'the owner'} to hand this terminal off; they approve it on their own board` }, ['Request handoff'])
|
|
@@ -920,7 +1893,10 @@
|
|
|
920
1893
|
}
|
|
921
1894
|
if (s.active) {
|
|
922
1895
|
const h = el('button', { type: 'button', class: `btn ${blocker ? 'btn-primary' : 'btn-secondary'}`, title: 'save the bundle, stop this agent, start the next option in the same terminal', 'data-focus-key': `handoff:${s.session_id}` }, ['Hand off now'])
|
|
923
|
-
|
|
1896
|
+
// the same confirm row End and the picker already use. This stops the
|
|
1897
|
+
// current turn of a working agent, and the `h` key presses this button:
|
|
1898
|
+
// an action that costs a turn asks first, whichever hand pressed it.
|
|
1899
|
+
h.addEventListener('click', ask('Hands off to the first open rung of this terminal\'s ladder. The current turn stops.', 'Hand off', 'handoff'))
|
|
924
1900
|
actions.appendChild(h)
|
|
925
1901
|
}
|
|
926
1902
|
const details = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-expanded': drawer.id === s.session_id ? 'true' : 'false', 'data-focus-key': `details:${s.session_id}` }, ['Details'])
|
|
@@ -928,7 +1904,11 @@
|
|
|
928
1904
|
actions.appendChild(details)
|
|
929
1905
|
if (s.active) {
|
|
930
1906
|
const e = el('button', { type: 'button', class: 'btn btn-danger', 'data-focus-key': `end:${s.session_id}` }, ['End'])
|
|
931
|
-
|
|
1907
|
+
// the second verb only where a card can continue: a terminal outside a
|
|
1908
|
+
// git repository has no branch for a card to work on (the route says so
|
|
1909
|
+
// with a 409, and a control that can only fail is not offered)
|
|
1910
|
+
e.addEventListener('click', ask('End this terminal? The agent stops and the bundle is kept.', 'End', 'end',
|
|
1911
|
+
s.repo && canCards() ? { verb: 'End, and keep going as a card', action: 'end-as-card', title: 'write the bundle, hand this checkout to a background card that continues the task, and end this terminal' } : null))
|
|
932
1912
|
actions.appendChild(e)
|
|
933
1913
|
} else {
|
|
934
1914
|
const r = el('button', { type: 'button', class: 'btn btn-danger', 'data-focus-key': `remove:${s.session_id}` }, ['Remove'])
|
|
@@ -985,9 +1965,15 @@
|
|
|
985
1965
|
// The messages, the diffs and the timeline are one extra fetch per open
|
|
986
1966
|
// terminal, so nothing here is requested until the region is open, and the
|
|
987
1967
|
// poll stops when it is paused or the tab is in the background.
|
|
988
|
-
const drawer = { id: null, paused: false, timer: null, detail: null, error: '', expanded: new Set(), diffs: new Map(), turnCap: 8, openedBy: 'prompt' }
|
|
1968
|
+
const drawer = { id: null, paused: false, timer: null, detail: null, error: '', expanded: new Set(), diffs: new Map(), turnCap: 8, openedBy: 'prompt', pick: null }
|
|
989
1969
|
|
|
990
1970
|
function drawerSession() { return view && view.sessions ? view.sessions.find((s) => s.session_id === drawer.id) : null }
|
|
1971
|
+
// the one control on this page whose value is a decision in flight
|
|
1972
|
+
function pickHasFocus() {
|
|
1973
|
+
const node = document.activeElement
|
|
1974
|
+
const id = node && typeof node.id === 'string' ? node.id : ''
|
|
1975
|
+
return id.startsWith('handoff-to-')
|
|
1976
|
+
}
|
|
991
1977
|
// The region is MOVED under the panel it expands, so the reference is cached:
|
|
992
1978
|
// once it has been moved into the sessions list, getElementById would stop
|
|
993
1979
|
// finding it the moment that list is rebuilt.
|
|
@@ -1004,6 +1990,8 @@
|
|
|
1004
1990
|
drawer.error = ''
|
|
1005
1991
|
drawer.paused = false
|
|
1006
1992
|
drawer.turnCap = 8
|
|
1993
|
+
// a destination chosen on one terminal is not a destination on the next
|
|
1994
|
+
drawer.pick = null
|
|
1007
1995
|
drawer.expanded.clear()
|
|
1008
1996
|
drawer.diffs.clear()
|
|
1009
1997
|
const region = detailRegion()
|
|
@@ -1016,7 +2004,11 @@
|
|
|
1016
2004
|
renderDrawer()
|
|
1017
2005
|
loadDrawer()
|
|
1018
2006
|
if (drawer.timer) clearInterval(drawer.timer)
|
|
1019
|
-
|
|
2007
|
+
// and it stands down while the reader is inside the destination select:
|
|
2008
|
+
// rebuilding a <select> under an open option list closes it, so a list that
|
|
2009
|
+
// takes longer than three seconds to read could not be read at all. The
|
|
2010
|
+
// pick itself survives the rebuild by rung (pickIndex above).
|
|
2011
|
+
drawer.timer = setInterval(() => { if (!drawer.paused && !document.hidden && !pickHasFocus()) loadDrawer() }, 3000)
|
|
1020
2012
|
document.getElementById('session-drawer-close')?.focus()
|
|
1021
2013
|
}
|
|
1022
2014
|
|
|
@@ -1356,7 +2348,22 @@
|
|
|
1356
2348
|
const verdict = !list.length ? 'nothing is running'
|
|
1357
2349
|
: waiting ? `${running} running, ${waiting} waiting on you`
|
|
1358
2350
|
: `${running} running, nothing is waiting on you`
|
|
1359
|
-
meta.textContent = `${verdict}${landed ? `, last landed ${clockAt(landed)}` : ''}`
|
|
2351
|
+
meta.textContent = `${verdict}${shareClause(list)}${landed ? `, last landed ${clockAt(landed)}` : ''}`
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
// A.7: a fact true of every row is a property of the region and is said once,
|
|
2355
|
+
// here. Per-row usage is per MODEL, which is real; the reader who adds three
|
|
2356
|
+
// rows' figures together is stopped by this clause and by nothing else.
|
|
2357
|
+
function shareClause(list) {
|
|
2358
|
+
const groups = new Map()
|
|
2359
|
+
for (const s of list.filter((x) => x.active)) {
|
|
2360
|
+
const key = `${s.agent}/${s.account || 'default'}`
|
|
2361
|
+
groups.set(key, (groups.get(key) || 0) + 1)
|
|
2362
|
+
}
|
|
2363
|
+
const [key, n] = [...groups.entries()].sort((x, y) => y[1] - x[1])[0] || []
|
|
2364
|
+
if (!n || n < 2) return ''
|
|
2365
|
+
const label = key.endsWith('/default') ? key.slice(0, -'/default'.length) : key
|
|
2366
|
+
return `, ${n} share the ${label} login`
|
|
1360
2367
|
}
|
|
1361
2368
|
|
|
1362
2369
|
// A fact that is true of every terminal on the board is a property of the
|
|
@@ -1405,6 +2412,203 @@
|
|
|
1405
2412
|
for (const p of document.querySelectorAll('#session-grid .blocker')) p.classList.toggle('is-hoisted', Boolean(shared))
|
|
1406
2413
|
}
|
|
1407
2414
|
|
|
2415
|
+
// ---- A.4 row 18 and E: where a waiting terminal says so off-screen ------
|
|
2416
|
+
// The tab badge is always on and needs no permission: the title and the
|
|
2417
|
+
// favicon are the only surface a browser gives a tab nobody is looking at.
|
|
2418
|
+
// favicon.svg itself is never touched; the dot is a variant drawn inline.
|
|
2419
|
+
const FAVICON = '/favicon.svg'
|
|
2420
|
+
const FAVICON_DOT = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F9BF}</text><circle cx="78" cy="22" r="20" fill="#E64343"/></svg>')}`
|
|
2421
|
+
function titleBadge(n) {
|
|
2422
|
+
const title = n > 0 ? `(${n}) Leg` : 'Leg'
|
|
2423
|
+
if (document.title !== title) document.title = title
|
|
2424
|
+
const link = document.querySelector('link[rel~="icon"]')
|
|
2425
|
+
if (!link) return
|
|
2426
|
+
const href = n > 0 ? FAVICON_DOT : FAVICON
|
|
2427
|
+
if (link.getAttribute('href') !== href) link.setAttribute('href', href)
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
// A browser toast fires on the TRANSITION into needing a human, never on
|
|
2431
|
+
// every render and never on first paint: the same rule the status mark's
|
|
2432
|
+
// annunciation keeps, for the same reason. A reload is not a new event.
|
|
2433
|
+
let announced = null
|
|
2434
|
+
// An OS toast is for a transition the reader was not there for. Firing one
|
|
2435
|
+
// over the window they are watching announces a state they just caused and
|
|
2436
|
+
// have already read on the row (pressing Land and getting a bounce is the
|
|
2437
|
+
// common one). The tab badge still updates either way: it costs nothing and
|
|
2438
|
+
// asks for nothing. Both halves are needed, because a visible tab in an
|
|
2439
|
+
// unfocused window is a reader who is somewhere else.
|
|
2440
|
+
function readerIsWatching() {
|
|
2441
|
+
return document.visibilityState === 'visible' && typeof document.hasFocus === 'function' && document.hasFocus()
|
|
2442
|
+
}
|
|
2443
|
+
function announceWaiting(rows, prefs) {
|
|
2444
|
+
const ids = new Set(rows.map((s) => s.session_id))
|
|
2445
|
+
if (announced && !readerIsWatching() && prefs && prefs.notify_board && typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
|
2446
|
+
for (const s of rows) {
|
|
2447
|
+
if (announced.has(s.session_id)) continue
|
|
2448
|
+
const note = rankedNotes(s)[0]
|
|
2449
|
+
try { new Notification(`${rowName(s)} is waiting on you`, { body: note ? note.text : '', tag: s.session_id }) } catch { /* a browser that refuses the constructor is not a reason to stop rendering */ }
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
announced = ids
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
const NOT_SECURE = 'This page is not a secure context. Open the board at http://localhost:<port> to turn toasts on.'
|
|
2456
|
+
function secureSentence() {
|
|
2457
|
+
const port = (window.location && window.location.port) || ''
|
|
2458
|
+
return NOT_SECURE.replace('<port>', port || '4747')
|
|
2459
|
+
}
|
|
2460
|
+
// E, the notifications table. Two toggles and no third: the tab badge above
|
|
2461
|
+
// is always on, so there is nothing to decide about it. The board toggle
|
|
2462
|
+
// reads `window.isSecureContext` at RUNTIME, because whether 127.0.0.1
|
|
2463
|
+
// counts is the browser's answer and not one this file may assume.
|
|
2464
|
+
function renderNotifySettings(v) {
|
|
2465
|
+
const box = document.getElementById('notify-settings')
|
|
2466
|
+
if (!box) return
|
|
2467
|
+
const prefs = v && v.preferences
|
|
2468
|
+
box.hidden = !prefs
|
|
2469
|
+
if (!prefs) return
|
|
2470
|
+
const term = document.getElementById('notify-terminal')
|
|
2471
|
+
const brd = document.getElementById('notify-board')
|
|
2472
|
+
const help = document.getElementById('notify-board-help')
|
|
2473
|
+
if (term) term.checked = prefs.notify_terminal !== false
|
|
2474
|
+
const secure = Boolean(window.isSecureContext)
|
|
2475
|
+
if (brd) {
|
|
2476
|
+
brd.disabled = !secure
|
|
2477
|
+
brd.checked = secure && prefs.notify_board === true
|
|
2478
|
+
}
|
|
2479
|
+
if (help) {
|
|
2480
|
+
const state = typeof Notification === 'undefined' ? 'this browser has no notifications' : `permission: ${Notification.permission}`
|
|
2481
|
+
help.textContent = secure ? `The browser asks the first time you turn this on (${state}).` : secureSentence()
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
async function saveNotify(patch) {
|
|
2485
|
+
const status = document.getElementById('notify-status')
|
|
2486
|
+
if (status) status.textContent = 'Saving…'
|
|
2487
|
+
try {
|
|
2488
|
+
const data = await api('/api/settings', { method: 'PATCH', body: patch })
|
|
2489
|
+
if (view && data && data.preferences) view.preferences = data.preferences
|
|
2490
|
+
if (status) status.textContent = 'Saved.'
|
|
2491
|
+
renderNotifySettings(view)
|
|
2492
|
+
} catch (err) {
|
|
2493
|
+
if (status) status.textContent = err.message
|
|
2494
|
+
renderNotifySettings(view)
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
// ---- D14: the keyboard map ----------------------------------------------
|
|
2499
|
+
// Every binding CLICKS a button that is already on the row, so no key is a
|
|
2500
|
+
// second way to do anything and nothing here can drift from the buttons.
|
|
2501
|
+
// `data-focus-key` is the same handle the focus-restore pass uses.
|
|
2502
|
+
const KEY_BUTTONS = [
|
|
2503
|
+
{ key: 'h', focus: 'handoff', button: 'Hand off now' },
|
|
2504
|
+
{ key: 'l', focus: 'land', button: 'Land' },
|
|
2505
|
+
{ key: 'd', focus: 'details', button: 'Details' },
|
|
2506
|
+
{ key: 'e', focus: 'end', button: 'End' },
|
|
2507
|
+
]
|
|
2508
|
+
const KEY_MOVES = [
|
|
2509
|
+
{ key: 'j', what: 'move the ring to the next terminal' },
|
|
2510
|
+
{ key: 'k', what: 'move the ring to the previous terminal' },
|
|
2511
|
+
{ key: '1 to 9', what: 'move the ring to that terminal' },
|
|
2512
|
+
{ key: '?', what: 'open and close this map' },
|
|
2513
|
+
{ key: 'Escape', what: 'close this map, cancel a confirm row, or close an expansion' },
|
|
2514
|
+
]
|
|
2515
|
+
// The ring is a TERMINAL, not a position. The grid re-sorts on every SSE push
|
|
2516
|
+
// and on the 15 second timer (needs-you rows first), so an index left the
|
|
2517
|
+
// ring painted on whichever row slid into that slot while the reader was
|
|
2518
|
+
// looking at their terminal, and the next key ended a session they never
|
|
2519
|
+
// chose. `data-session-id` is on every .term, and DOM focus is already
|
|
2520
|
+
// carried by identity through data-focus-key, so this makes the two agree.
|
|
2521
|
+
let ringId = null
|
|
2522
|
+
let keymapOpen = false
|
|
2523
|
+
function termRows() { return [...document.querySelectorAll('#session-grid .term')] }
|
|
2524
|
+
function ringIndex(list) {
|
|
2525
|
+
if (!ringId) return -1
|
|
2526
|
+
return (list || termRows()).findIndex((r) => r.getAttribute('data-session-id') === ringId)
|
|
2527
|
+
}
|
|
2528
|
+
function paintRing(list) {
|
|
2529
|
+
const rows = list || termRows()
|
|
2530
|
+
const at = ringIndex(rows)
|
|
2531
|
+
// a terminal that ended and moved to the ledger takes its ring with it,
|
|
2532
|
+
// rather than leaving it to be inherited by the row that took its place
|
|
2533
|
+
if (ringId && at < 0) ringId = null
|
|
2534
|
+
rows.forEach((r, i) => r.classList.toggle('is-focused', i === at))
|
|
2535
|
+
}
|
|
2536
|
+
// the ring moves focus to the row's first button, so a screen reader
|
|
2537
|
+
// announces the row it landed on rather than leaving the reader nowhere
|
|
2538
|
+
function moveRing(to) {
|
|
2539
|
+
const rows = termRows()
|
|
2540
|
+
if (!rows.length) return
|
|
2541
|
+
const ringAt = Math.max(0, Math.min(rows.length - 1, to))
|
|
2542
|
+
ringId = rows[ringAt].getAttribute('data-session-id')
|
|
2543
|
+
paintRing(rows)
|
|
2544
|
+
// the prompt button first: it is the row's first control and its label is
|
|
2545
|
+
// the prompt, so a screen reader announces WHICH terminal the ring landed
|
|
2546
|
+
// on rather than a bare `Land`. A row with no prompt (someone else's) falls
|
|
2547
|
+
// through to whatever control it does have.
|
|
2548
|
+
const btn = rows[ringAt].querySelector('.panel-prompt, .term-actions .btn, button')
|
|
2549
|
+
if (btn && btn.focus) btn.focus()
|
|
2550
|
+
}
|
|
2551
|
+
function pressOnRing(focusKey) {
|
|
2552
|
+
const rows = termRows()
|
|
2553
|
+
if (!rows.length) return
|
|
2554
|
+
const at = ringIndex(rows)
|
|
2555
|
+
// With no ring there is nothing painted, so a key that acted would act on
|
|
2556
|
+
// whichever row the needs-you sort put first, with no way for the reader to
|
|
2557
|
+
// see which one that was before the POST went out. The first press only
|
|
2558
|
+
// moves and paints the ring; the second acts.
|
|
2559
|
+
if (at < 0) { moveRing(0); return }
|
|
2560
|
+
const btn = rows[at].querySelector(`[data-focus-key^="${focusKey}:"]`)
|
|
2561
|
+
if (btn && !btn.disabled) btn.click()
|
|
2562
|
+
}
|
|
2563
|
+
function renderKeymap() {
|
|
2564
|
+
const box = document.getElementById('keymap')
|
|
2565
|
+
const list = document.getElementById('keymap-list')
|
|
2566
|
+
if (!box || !list) return
|
|
2567
|
+
box.hidden = !keymapOpen
|
|
2568
|
+
if (!keymapOpen) return
|
|
2569
|
+
list.textContent = ''
|
|
2570
|
+
for (const k of KEY_MOVES) {
|
|
2571
|
+
list.appendChild(el('dt', { class: 'keymap-key' }, [k.key]))
|
|
2572
|
+
list.appendChild(el('dd', { class: 'keymap-what' }, [k.what]))
|
|
2573
|
+
}
|
|
2574
|
+
for (const k of KEY_BUTTONS) {
|
|
2575
|
+
list.appendChild(el('dt', { class: 'keymap-key' }, [k.key]))
|
|
2576
|
+
list.appendChild(el('dd', { class: 'keymap-what' }, [`press ${k.button} on the terminal the ring is on`]))
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
function toggleKeymap(open) {
|
|
2580
|
+
keymapOpen = open === undefined ? !keymapOpen : open
|
|
2581
|
+
renderKeymap()
|
|
2582
|
+
if (keymapOpen) { const c = document.getElementById('keymap-close'); if (c && c.focus) c.focus() }
|
|
2583
|
+
}
|
|
2584
|
+
// a key pressed into a field is text, never a command
|
|
2585
|
+
function isTyping(e) {
|
|
2586
|
+
const t = e.target
|
|
2587
|
+
const tag = t && t.tagName ? String(t.tagName).toLowerCase() : ''
|
|
2588
|
+
return tag === 'input' || tag === 'select' || tag === 'textarea' || Boolean(t && t.isContentEditable)
|
|
2589
|
+
}
|
|
2590
|
+
// A <dialog> opened with showModal() still sends its keydowns to document, so
|
|
2591
|
+
// `h` typed at the New card dialog reached a row the reader cannot see, and a
|
|
2592
|
+
// confirm row is a question that has not been answered yet. Both own the page
|
|
2593
|
+
// while they are up. Escape has its own branch above this handler, so every
|
|
2594
|
+
// one of them can still be dismissed.
|
|
2595
|
+
function boardBusy() {
|
|
2596
|
+
if (pendingConfirm) return true
|
|
2597
|
+
try { return Boolean(document.querySelector('dialog[open]')) } catch { return false }
|
|
2598
|
+
}
|
|
2599
|
+
function boardKey(e) {
|
|
2600
|
+
if (e.ctrlKey || e.metaKey || e.altKey || isTyping(e)) return
|
|
2601
|
+
if (boardBusy()) return
|
|
2602
|
+
if (e.key === '?') { toggleKeymap(); e.preventDefault(); return }
|
|
2603
|
+
// the map is a panel explaining these keys; reading it must not fire them
|
|
2604
|
+
if (keymapOpen) return
|
|
2605
|
+
if (e.key === 'j') { moveRing(ringIndex() + 1); e.preventDefault(); return }
|
|
2606
|
+
if (e.key === 'k') { moveRing(ringIndex() - 1); e.preventDefault(); return }
|
|
2607
|
+
if (/^[1-9]$/.test(e.key)) { moveRing(Number(e.key) - 1); e.preventDefault(); return }
|
|
2608
|
+
const hit = KEY_BUTTONS.find((k) => k.key === e.key)
|
|
2609
|
+
if (hit) { pressOnRing(hit.focus); e.preventDefault() }
|
|
2610
|
+
}
|
|
2611
|
+
|
|
1408
2612
|
// Finished terminals are history. After a day of work they are most of the
|
|
1409
2613
|
// list, and drawn as full rows they bury the one or two that are live, so
|
|
1410
2614
|
// they leave the panel entirely and become a ledger cell with a drawer.
|
|
@@ -1475,6 +2679,12 @@
|
|
|
1475
2679
|
const done = (s) => !s.active && !urgent(s) && drawer.id !== s.session_id
|
|
1476
2680
|
const live = list.filter((s) => !done(s))
|
|
1477
2681
|
const finished = list.filter(done)
|
|
2682
|
+
// A.4 row 18 and E: the tab badge and the browser toast read the same
|
|
2683
|
+
// predicate the rows sort on and the region head counts, so the three
|
|
2684
|
+
// cannot disagree about who is waiting.
|
|
2685
|
+
const waiting = list.filter(urgent)
|
|
2686
|
+
titleBadge(waiting.length)
|
|
2687
|
+
announceWaiting(waiting, (view && view.preferences) || {})
|
|
1478
2688
|
terminalsMeta(live, notesOf)
|
|
1479
2689
|
// computed over the rows that are actually drawn: a sentence shared only by
|
|
1480
2690
|
// terminals collapsed into the ledger is not on screen to be deduped
|
|
@@ -1497,6 +2707,10 @@
|
|
|
1497
2707
|
// the control they were on, at the offset they had scrolled to
|
|
1498
2708
|
if (region && parked) putScroll(region, parked)
|
|
1499
2709
|
putFocus(document, focus)
|
|
2710
|
+
// the rows are new elements: the ring is a class, so it is repainted onto
|
|
2711
|
+
// the terminal the reader left it on rather than stealing focus again. This
|
|
2712
|
+
// list was just re-sorted, so painting by position would move it.
|
|
2713
|
+
paintRing()
|
|
1500
2714
|
}
|
|
1501
2715
|
|
|
1502
2716
|
// What landed is history too. The full list was eighteen rows of git log at
|
|
@@ -1569,6 +2783,7 @@
|
|
|
1569
2783
|
}
|
|
1570
2784
|
renderAccounts(v.accounts || [])
|
|
1571
2785
|
renderDefaultOrder(v)
|
|
2786
|
+
renderNotifySettings(v)
|
|
1572
2787
|
// A rebuild replaces every button in the grid. A confirm row is a question
|
|
1573
2788
|
// the reader is answering right now, and a push landing between their
|
|
1574
2789
|
// mousedown and their mouseup dropped the click: the browser fires `click`
|
|
@@ -1590,14 +2805,57 @@
|
|
|
1590
2805
|
// `baton:sessions` alias for every push; this file had been registered on
|
|
1591
2806
|
// `leg:sessions` twice and on the alias once, so one push rebuilt the entire
|
|
1592
2807
|
// terminals grid three times over.
|
|
2808
|
+
// The verdict is a pure function of the payload, and its character budget is
|
|
2809
|
+
// a measurement, so test/board-verdict.test.mjs drives the branches directly
|
|
2810
|
+
// through this seam. In a browser there is no `module`, and nothing here
|
|
2811
|
+
// depends on it. board-updates.test.mjs uses the same pattern in board.js.
|
|
2812
|
+
if (typeof module !== 'undefined') {
|
|
2813
|
+
module.exports = {
|
|
2814
|
+
verdictLines, VERDICT_CH, SUB_CH, WARN_PCT, bindingOf, capFigure, capToken, shareClause, headline,
|
|
2815
|
+
rankedNotes, needsYou, registerTokens, capacityPhrase, capacityNote, waitingNote, notifyWait, resetWait,
|
|
2816
|
+
KEY_BUTTONS, KEY_MOVES,
|
|
2817
|
+
// D14: the ring is state, not a pure function, so test/board-keyboard
|
|
2818
|
+
// drives it through the same keydown listener a reader presses and reads
|
|
2819
|
+
// the paint back through these two. getToken is here because a board in a
|
|
2820
|
+
// browser with storage blocked has to render, and that cannot be asserted
|
|
2821
|
+
// from the source text.
|
|
2822
|
+
paintRing, ringSession: () => ringId, getToken, announceWaiting, readerIsWatching,
|
|
2823
|
+
// B.6: the pick that survives the poll, the rule box and the refusal the
|
|
2824
|
+
// Add button prints are all pure and are asserted without a DOM
|
|
2825
|
+
pickKey, pickIndex, whenFromBox, whenFlag, duplicateRung,
|
|
2826
|
+
// B.6: the picker's option text, the ladder's round trip and the climb
|
|
2827
|
+
// predicate are pure, so they are asserted without a DOM
|
|
2828
|
+
handoffOptionText, rungLabel, costWord, whenKind, whenPct, whenString, moveRung, rungsAfter,
|
|
2829
|
+
climbTarget, topRungFor, MODEL_ALIASES, LADDER_AGENTS,
|
|
2830
|
+
MAY_SPEND_SENTENCE, NO_CREDITS_SENTENCE, CLIMB_RULE, CLIMB_WORDS,
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
|
|
1593
2834
|
window.addEventListener('leg:sessions', (e) => render(e.detail))
|
|
1594
2835
|
document.addEventListener('keydown', (e) => {
|
|
1595
|
-
if (e.key
|
|
1596
|
-
|
|
1597
|
-
|
|
2836
|
+
if (e.key === 'Escape') {
|
|
2837
|
+
if (keymapOpen) { toggleKeymap(false); return }
|
|
2838
|
+
if (pendingConfirm) { pendingConfirm = null; if (view) renderSessions(view); return }
|
|
2839
|
+
if (drawer.id) closeSessionDrawer()
|
|
2840
|
+
return
|
|
2841
|
+
}
|
|
2842
|
+
boardKey(e)
|
|
1598
2843
|
})
|
|
1599
2844
|
document.addEventListener('DOMContentLoaded', () => {
|
|
1600
2845
|
document.getElementById('default-order-save')?.addEventListener('click', saveDefaultOrder)
|
|
2846
|
+
document.getElementById('capacity-toggle')?.addEventListener('click', toggleCapacity)
|
|
2847
|
+
document.getElementById('keymap-close')?.addEventListener('click', () => toggleKeymap(false))
|
|
2848
|
+
document.getElementById('notify-terminal')?.addEventListener('change', (e) => saveNotify({ notify_terminal: Boolean(e.target.checked) }))
|
|
2849
|
+
// the permission is asked for on the toggle, never on load: a page that
|
|
2850
|
+
// asks for a permission nobody wanted is a page people close
|
|
2851
|
+
document.getElementById('notify-board')?.addEventListener('change', async (e) => {
|
|
2852
|
+
const on = Boolean(e.target.checked)
|
|
2853
|
+
if (on && typeof Notification !== 'undefined' && Notification.permission === 'default') {
|
|
2854
|
+
try { await Notification.requestPermission() } catch { /* a refusal is an answer; the toggle still saves */ }
|
|
2855
|
+
}
|
|
2856
|
+
saveNotify({ notify_board: on })
|
|
2857
|
+
})
|
|
2858
|
+
renderCapacityToggle()
|
|
1601
2859
|
renderLoadingHead()
|
|
1602
2860
|
refresh()
|
|
1603
2861
|
setInterval(tickElapsed, 1000)
|