@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +158 -67
  3. package/bin/leg.mjs +168 -18
  4. package/docs/DECISIONS.md +10 -0
  5. package/docs/DEMO.md +20 -14
  6. package/docs/DEVIATIONS.md +1 -0
  7. package/docs/ERRORS.md +94 -0
  8. package/docs/ROADMAP-v2.md +69 -11
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/adapters.md +93 -11
  11. package/docs/board-guide.md +401 -66
  12. package/docs/cli-contracts.md +235 -22
  13. package/docs/concepts.md +167 -19
  14. package/docs/configuration.md +113 -5
  15. package/docs/faq.md +21 -5
  16. package/docs/getting-started.md +15 -11
  17. package/docs/redesign-2026-09-17.md +477 -0
  18. package/docs/screenshots/background-1280.png +0 -0
  19. package/docs/screenshots/board-400px.png +0 -0
  20. package/docs/screenshots/board-details-open.png +0 -0
  21. package/docs/screenshots/board-drawer.png +0 -0
  22. package/docs/screenshots/board-handoff.png +0 -0
  23. package/docs/screenshots/board-running.png +0 -0
  24. package/docs/screenshots/capacity-drawer-1280.png +0 -0
  25. package/docs/screenshots/settings-ladder-1280.png +0 -0
  26. package/docs/screenshots/terminals-1280.png +0 -0
  27. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  28. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  29. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  30. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  31. package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
  32. package/fixtures/live/claude/resume-model-probe.json +20 -0
  33. package/fixtures/live/claude/usage-oauth.json +87 -0
  34. package/fixtures/live/grok/cmd.txt +1 -1
  35. package/fixtures/live/grok/parsed.json +6 -3
  36. package/fixtures/live/grok/run.json +22 -10
  37. package/fixtures/verified.json +8 -1
  38. package/package.json +3 -2
  39. package/scripts/build-docs-site.mjs +4 -4
  40. package/scripts/probe.mjs +2 -1
  41. package/scripts/seed-fake-cards.mjs +59 -6
  42. package/scripts/seed-wes-board.mjs +81 -12
  43. package/src/accounts.mjs +6 -1
  44. package/src/adapters/cli.mjs +130 -0
  45. package/src/adapters/custom.mjs +271 -0
  46. package/src/adapters/grok.mjs +51 -10
  47. package/src/adapters/index.mjs +34 -7
  48. package/src/attach.mjs +350 -42
  49. package/src/audit.mjs +118 -0
  50. package/src/board/audit.js +123 -0
  51. package/src/board/board.css +134 -9
  52. package/src/board/board.js +482 -106
  53. package/src/board/index.html +89 -7
  54. package/src/board/sessions.js +1371 -113
  55. package/src/buckets.mjs +101 -0
  56. package/src/cards.mjs +9 -1
  57. package/src/chain.mjs +13 -0
  58. package/src/hook.mjs +7 -1
  59. package/src/ledger.mjs +10 -2
  60. package/src/orchestrator.mjs +13 -4
  61. package/src/preferences.mjs +214 -5
  62. package/src/scheduler.mjs +24 -1
  63. package/src/server.mjs +615 -50
  64. package/src/sessions.mjs +17 -1
  65. package/src/share.mjs +66 -6
  66. package/src/taps/claude-usage.mjs +91 -2
  67. package/src/taps/claude.mjs +144 -5
  68. package/src/taps/codex.mjs +23 -3
  69. package/src/taps/grok.mjs +4 -0
  70. package/src/usage.mjs +424 -13
@@ -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 = { order: null, dirty: false, saving: false, status: '', statusClass: '' }
38
-
39
- function getToken() { return localStorage.getItem('legToken') || localStorage.getItem('batonToken') || '' }
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 panel under it already prints:
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
- function verdictLines(list, sessions) {
317
- const liveSessions = (sessions || []).filter((s) => s.active)
318
- const withReading = list.filter((a) => worstWindow(a) && Number.isFinite(worstWindow(a).pct))
319
- if (!list.length) return { line: 'Reading the logins.', sub: '' }
320
-
321
- const walled = list.filter((a) => acctState(a) === 'walled')
322
- const busiest = [...list].sort((x, y) => (y.live || 0) - (x.live || 0))[0]
323
- const onOneLogin = liveSessions.length > 0 && new Set(liveSessions.map((s) => s.agent)).size === 1
324
- const subject = onOneLogin ? list.find((a) => a.agent === liveSessions[0].agent) || busiest : closestToWall(list)
325
- const w = subject ? worstWindow(subject) : null
326
- const known = w && Number.isFinite(w.pct)
327
- const left = known ? Math.max(0, 100 - Math.round(w.pct)) : null
328
-
329
- let line
330
- if (!liveSessions.length) {
331
- line = walled.length
332
- ? `Nothing is running, and ${walled.map(accountLabel).join(' and ')} ${walled.length === 1 ? 'is' : 'are'} at the wall.`
333
- : 'Nothing is running.'
334
- } else if (onOneLogin && known) {
335
- // C's framing: one login, one point of failure. The number is what is
336
- // LEFT, because that is the quantity the reader is deciding against.
337
- // One terminal is not "all the terminal": the plural sentence is about
338
- // everything riding on one login, and with a single terminal there is no
339
- // "all" to make, so it says the plainer thing.
340
- line = liveSessions.length === 1
341
- ? `Your terminal is on ${accountLabel(subject)}, and ${accountLabel(subject)} has ${left}% left.`
342
- : `All ${liveSessions.length} terminals are on ${accountLabel(subject)}, and ${accountLabel(subject)} has ${left}% left.`
343
- } else if (known) {
344
- line = `${accountLabel(subject)} has ${left}% left, and ${liveSessions.length} terminal${liveSessions.length === 1 ? ' is' : 's are'} working.`
345
- } else {
346
- line = `${liveSessions.length} terminal${liveSessions.length === 1 ? ' is' : 's are'} working.`
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
- // A's staleness sentence. A reading taken two hours ago with terminals
350
- // running since is a floor, not a measurement, and saying which direction
351
- // it is wrong in is the whole point of printing the age.
352
- const parts = []
353
- const observed = subject ? Date.parse(subject.observed_at || subject.updated_at || '') : NaN
354
- if (subject && subject.stale && subject.agent !== 'agy' && Number.isFinite(observed) && known) {
355
- parts.push(liveSessions.length
356
- ? `Measured ${ago(Date.now() - observed)} ago. ${liveSessions.length} terminal${liveSessions.length === 1 ? ' has' : 's have'} been running since, so the real figure is higher than ${Math.round(w.pct)} percent, never lower.`
357
- : `Measured ${ago(Date.now() - observed)} ago.`)
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
- for (const a of walled) {
360
- if (a === subject) continue
361
- parts.push(Number.isFinite(a.limited_until)
362
- ? `${accountLabel(a)} is at the wall until ${until(a.limited_until)}.`
363
- : `${accountLabel(a)} is at the wall.`)
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
- if (!withReading.length && liveSessions.length) parts.push('No login has reported a usage figure yet.')
366
- return { line, sub: parts.slice(0, 2).join(' ') }
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
- if (s.status === 'waiting' && s.waiting) out.push({ rank: 5, cat: 'waiting', tone: 'warn', text: `waiting for ${optionLabel(s.waiting)} at ${until(s.waiting.resets_at)}` })
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
- function moveOrder(order, index, delta) {
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 >= order.length) return [...order]
523
- const next = [...order]
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
- function orderRows(order, onMove, scope) {
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
- order.forEach((agent, index) => {
531
- const attrs = (direction) => ({
532
- 'data-order-scope': scope,
533
- 'data-order-agent': agent,
534
- 'data-order-direction': direction,
535
- 'data-focus-key': `order:${scope}:${agent}:${direction}`,
536
- })
537
- const up = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-label': `Move ${agent} earlier`, disabled: index === 0 ? '' : null, ...attrs('up') }, ['Up'])
538
- const down = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-label': `Move ${agent} later`, disabled: index === order.length - 1 ? '' : null, ...attrs('down') }, ['Down'])
539
- up.addEventListener('click', () => onMove(index, -1))
540
- down.addEventListener('click', () => onMove(index, 1))
541
- box.appendChild(el('div', { class: 'order-row' }, [el('span', { class: `order-row-name chip-id-${idOf(agent)}` }, [`${index + 1}. ${agent}`]), up, down]))
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
- function agentsAfter(current, order) {
596
- return order.filter((agent) => agent !== current)
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
- if (!defaultEditor.order || (!defaultEditor.dirty && !defaultEditor.saving)) defaultEditor.order = [...v.preferences.handoff_order]
605
- const list = document.getElementById('default-order-list')
606
- const focus = takeFocus(document)
607
- list.textContent = ''
608
- list.appendChild(orderRows(defaultEditor.order, (index, delta) => {
609
- defaultEditor.order = moveOrder(defaultEditor.order, index, delta)
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
- }, 'default'))
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 default'
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', { method: 'PATCH', body: { handoff_order: defaultEditor.order } })
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.order = [...data.preferences.handoff_order]
1496
+ defaultEditor.ladder = (data.preferences.handoff_ladder || []).map((r) => ({ ...r }))
631
1497
  defaultEditor.dirty = false
632
- defaultEditor.status = 'Saved for new terminals.'
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)}` }, [optionLabel(s)])])
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)}` }, [optionLabel(next)]))
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
- const preferred = optionLabel(s.preferred_next)
689
- const eligible = optionLabel(s.eligible_next)
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
- const sourceOrder = s.can_edit_handoff_order ? s.handoff_order : (view?.preferences?.handoff_order ?? s.handoff_order)
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, order: [...sourceOrder], dirty: false, saving: false, status: '', statusClass: '' }
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.order = [...sourceOrder]
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 order editor' : 'Change order'])
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
- ? 'Move agents to set the priority for this terminal. All three stay available; the agent running now is skipped, and the rest keep this order.'
711
- : 'This terminal started before order changes were available. Save this order for the next terminal, then restart when ready.']))
712
- editor.appendChild(orderRows(state.order, (index, delta) => {
713
- state.order = moveOrder(state.order, index, delta)
714
- state.dirty = true
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
- }, s.session_id))
718
- editor.appendChild(el('p', { class: 'blocker' }, [`Draft priority after ${s.agent}: ${agentsAfter(s.agent, state.order).join(', then ')}`]))
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: { handoff_order: state.order } })
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: { handoff_order: state.order } })
1653
+ const data = await api('/api/settings', { method: 'PATCH', body: { handoff_ladder: state.ladder } })
729
1654
  if (view) view.preferences = data.preferences
730
- defaultEditor.order = [...data.preferences.handoff_order]
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
- body.appendChild(line)
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
- term.appendChild(confirmRow(pending.question, pending.verb, (btn) => act(s.session_id, pending.action, btn)))
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
- h.addEventListener('click', () => act(s.session_id, 'handoff', h))
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
- e.addEventListener('click', ask('End this terminal? The agent stops and the bundle is kept.', 'End', 'end'))
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
- drawer.timer = setInterval(() => { if (!drawer.paused && !document.hidden) loadDrawer() }, 3000)
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 !== 'Escape') return
1596
- if (pendingConfirm) { pendingConfirm = null; if (view) renderSessions(view); return }
1597
- if (drawer.id) closeSessionDrawer()
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)