@ucsandman/legcli 0.11.0 → 0.13.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 (65) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/README.md +95 -65
  3. package/bin/leg.mjs +123 -14
  4. package/docs/DECISIONS.md +18 -0
  5. package/docs/DEMO.md +20 -14
  6. package/docs/DEVIATIONS.md +1 -0
  7. package/docs/ERRORS.md +68 -0
  8. package/docs/ROADMAP-v2.md +50 -5
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/board-guide.md +529 -96
  11. package/docs/cli-contracts.md +241 -5
  12. package/docs/concepts.md +167 -19
  13. package/docs/configuration.md +65 -1
  14. package/docs/faq.md +21 -5
  15. package/docs/getting-started.md +15 -11
  16. package/docs/redesign-2026-09-17.md +477 -0
  17. package/docs/screenshots/background-1280.png +0 -0
  18. package/docs/screenshots/board-400px.png +0 -0
  19. package/docs/screenshots/board-details-open.png +0 -0
  20. package/docs/screenshots/board-drawer.png +0 -0
  21. package/docs/screenshots/board-handoff.png +0 -0
  22. package/docs/screenshots/board-running.png +0 -0
  23. package/docs/screenshots/capacity-drawer-1280.png +0 -0
  24. package/docs/screenshots/floor.png +0 -0
  25. package/docs/screenshots/new-card-dialog.png +0 -0
  26. package/docs/screenshots/settings-ladder-1280.png +0 -0
  27. package/docs/screenshots/terminals-1280.png +0 -0
  28. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  29. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  30. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  31. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  32. package/fixtures/live/claude/resume-model-probe.json +20 -0
  33. package/fixtures/live/claude/usage-oauth.json +87 -0
  34. package/fixtures/verified.json +1 -1
  35. package/package.json +3 -2
  36. package/scripts/board-jump-probe.mjs +335 -0
  37. package/scripts/seed-fake-cards.mjs +59 -6
  38. package/scripts/seed-wes-board.mjs +81 -12
  39. package/src/accounts.mjs +6 -1
  40. package/src/attach.mjs +378 -93
  41. package/src/audit.mjs +1 -1
  42. package/src/board/board.css +203 -11
  43. package/src/board/board.js +664 -200
  44. package/src/board/entry.js +343 -0
  45. package/src/board/floor.html +51 -39
  46. package/src/board/floor.js +585 -73
  47. package/src/board/index.html +122 -45
  48. package/src/board/sessions.js +1569 -141
  49. package/src/board/strip.js +163 -0
  50. package/src/buckets.mjs +101 -0
  51. package/src/cards.mjs +9 -1
  52. package/src/chain.mjs +13 -0
  53. package/src/hook.mjs +7 -1
  54. package/src/ledger.mjs +10 -2
  55. package/src/models.mjs +265 -0
  56. package/src/orchestrator.mjs +13 -4
  57. package/src/preferences.mjs +278 -5
  58. package/src/scheduler.mjs +24 -1
  59. package/src/server.mjs +625 -78
  60. package/src/sessions.mjs +17 -1
  61. package/src/taps/claude-usage.mjs +107 -3
  62. package/src/taps/claude.mjs +144 -5
  63. package/src/taps/codex.mjs +23 -3
  64. package/src/usage-poll.mjs +260 -0
  65. package/src/usage.mjs +439 -12
@@ -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'
@@ -30,13 +38,28 @@
30
38
  let trunkOpen = false
31
39
  let finishedOpen = false
32
40
  let pendingConfirm = null
41
+ // The order the terminals were last drawn in, and when that order first
42
+ // stopped matching the sort. A reader with a terminal expanded is reading a
43
+ // region whose position on the page is decided by the rows above it: a row
44
+ // crossing the needs-you partition moved the whole expansion 205px down the
45
+ // screen mid-sentence, with scrollY unchanged, so no scroll-hold probe could
46
+ // see it. See listOrder/holdsOrder below and scripts/board-jump-probe.mjs.
47
+ let heldOrder = []
48
+ let orderDivergedAt = 0
33
49
  const sessionEditors = new Map()
34
50
  const alsoOpen = new Set()
35
51
  const actionNotes = new Map()
36
52
  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') || '' }
53
+ const defaultEditor = { ladder: null, climb_back: 'next-handoff', may_spend: false, reserve: {}, dirty: false, saving: false, status: '', statusClass: '' }
54
+
55
+ // localStorage THROWS rather than answering null in a browser with site data
56
+ // blocked, in a partitioned webview and on a board opened from a file. This
57
+ // read runs inside api(), so an unguarded one takes every fetch on the page
58
+ // down before it is issued and the board looks exactly like a dead server.
59
+ // An empty token is already a valid state: the header is only added if (token).
60
+ function getToken() {
61
+ try { return localStorage.getItem('legToken') || localStorage.getItem('batonToken') || '' } catch { return '' }
62
+ }
40
63
  async function api(path, opts = {}) {
41
64
  const headers = { 'Content-Type': 'application/json' }
42
65
  const token = getToken()
@@ -139,8 +162,15 @@
139
162
  function optionLabel(a) { return a ? (a.account && a.account !== 'default' ? `${a.agent}/${a.account}` : a.agent) : 'none' }
140
163
  function idOf(agent) { return IDS.includes(agent) ? agent : 'fake' }
141
164
  const tail = (id) => String(id).split('-').slice(-2).join('-')
165
+ // `leg#7f3a`: the repo this terminal is in and the short id the row prints,
166
+ // which is how a human refers to it out loud and in the verdict.
167
+ const shortId = (s) => tail(s.session_id).replace(new RegExp(`^${s.agent || ''}-`), '')
168
+ const rowName = (s) => `${s.repo_name || s.agent || 'terminal'}#${shortId(s)}`
142
169
  const shared = () => Boolean(view && view.share && view.share.on)
143
170
  const isMine = (s) => Boolean(view && view.you && s.owner && view.you.name === s.owner)
171
+ // the pipeline board belongs to the owner and the operators of the machine;
172
+ // a guest's End row offers no card (the route answers 403)
173
+ const canCards = () => !(view && view.you && view.you.role && view.you.role !== 'owner' && view.you.role !== 'operator')
144
174
 
145
175
  // ---- 6.1 the window rail ----------------------------------------------
146
176
  // One state value per account drives the .acct modifier, every rail cell, the
@@ -252,6 +282,251 @@
252
282
  ])
253
283
  }
254
284
 
285
+ // ---- the binding bucket -------------------------------------------------
286
+ // The bucket that will actually stop the work: the one the endpoint marked
287
+ // active, else the highest percentage it reported, else the legacy hottest of
288
+ // the two windows, which is all an older record or a guest payload carries.
289
+ // Mirrors binding() in src/usage.mjs; the board cannot import from it.
290
+ //
291
+ // src/board/strip.js OWNS this grammar and the capacity strip that prints it,
292
+ // because /floor prints the same tokens from the same payload: two pages
293
+ // computing a binding bucket their own way is the defect the strip was built
294
+ // to end. The names here are this file's callers and its test seam; the
295
+ // answers come from that one file, which is loaded before this one and is
296
+ // handed this file's primitives (use(), at the bottom).
297
+ const strip = () => window.legStrip
298
+ // strip.js is a plain script like this one, so it is handed this file's
299
+ // primitives instead of growing a second copy of the time grammar or the
300
+ // login labels. Every name below is a function declaration above, so the
301
+ // reference is live however late strip.js calls it.
302
+ if (typeof window !== 'undefined' && window.legStrip) {
303
+ window.legStrip.use({ el, accountLabel, idOf, acctState, worstWindow, until, clockAt, spoken })
304
+ }
305
+ const bindingOf = (a) => strip().bindingOf(a)
306
+ // the same bucket inside a sentence: "63% of its week"
307
+ const windowPhrase = (b) => strip().windowPhrase(b)
308
+ // the account's own window, ignoring any model bucket: what a same-login
309
+ // model rung still has to spend, and what an account wall would take away
310
+ function accountBucket(a) {
311
+ const flat = (Array.isArray(a && a.buckets) ? a.buckets : []).filter((b) => b && !b.model && Number.isFinite(b.percent))
312
+ const b = [...flat].sort((x, y) => y.percent - x.percent)[0]
313
+ if (b) return { kind: b.kind, model: null, percent: b.percent, resets_at: Number.isFinite(b.resets_at) ? b.resets_at : null, scope: 'account' }
314
+ const legacy = bindingOf(a)
315
+ return legacy && !legacy.model ? legacy : null
316
+ }
317
+ const Model = (m) => (m ? String(m).charAt(0).toUpperCase() + String(m).slice(1) : '')
318
+ // every model this login has published anything about, and which of them are
319
+ // out. Owned by strip.js with the rest of the bucket grammar; named here for
320
+ // the model rail and the verdict that read them.
321
+ const knownModels = (a) => strip().knownModels(a)
322
+ const wallFor = (a, model) => strip().wallFor(a, model)
323
+ const walledModels = (a) => strip().walledModels(a)
324
+ function openModels(a) { return knownModels(a).filter((m) => !wallFor(a, m)) }
325
+ function modelBucket(a, model) {
326
+ return (Array.isArray(a && a.buckets) ? a.buckets : []).find((b) => b && b.model === model && Number.isFinite(b.percent)) || null
327
+ }
328
+
329
+ // ---- the forecast (spec A.5 row 4, E rule 6) -----------------------------
330
+ // `forecast` rides the binding bucket, computed by `burn()` in src/usage.mjs:
331
+ // a rate measured inside ONE window, never across a reset, and only past a
332
+ // gate of three samples spanning ten minutes. Under the gate there is no time
333
+ // at all, and every time that is printed carries the volume it came from: a
334
+ // figure with no sample count beside it is a guess wearing an instrument's
335
+ // clothes, and this one is printed in the largest type on the page.
336
+ function forecastOf(c) {
337
+ const f = c && c.forecast
338
+ return f && Number.isFinite(f.seconds_left) && Number.isFinite(f.samples) && Number.isFinite(f.span_s) ? f : null
339
+ }
340
+ // `2h 40m` under a day, `3d 5h` beyond, and spelled-out minutes under ten:
341
+ // `0h 8m` reads as an instrument, and eight minutes is a sentence.
342
+ function burnPhrase(secondsLeft) {
343
+ const m = Math.max(0, Math.round(secondsLeft / 60))
344
+ if (m < 10) return `${m} minute${m === 1 ? '' : 's'}`
345
+ if (m < 60) return `${m}m`
346
+ const h = Math.floor(m / 60)
347
+ if (h < 24) return `${h}h${m % 60 ? ` ${m % 60}m` : ''}`
348
+ const d = Math.floor(h / 24)
349
+ return `${d}d${h % 24 ? ` ${h % 24}h` : ''}`
350
+ }
351
+ function burnVolume(f, lead = 'from') { return `${lead} ${f.samples} sample${f.samples === 1 ? '' : 's'} over ${ago(f.span_s * 1000)}` }
352
+ function andList(xs) { return xs.length < 2 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}` }
353
+
354
+ // ---- what one terminal is waiting for ------------------------------------
355
+ // `waiting` carries TWO shapes under one key and they mean opposite things.
356
+ // `{ type: 'reset', agent, account, resets_at, since }` is the all-out
357
+ // countdown the runner writes: nobody is being waited on, the child is
358
+ // already dead. The Notification shapes below are a HUMAN being waited on.
359
+ // Both carry `type` and `since`, so the shape is told apart by the type and
360
+ // never by the presence of a field (src/sessions.mjs createSession).
361
+ const NOTIFY_TYPES = ['permission_prompt', 'idle_prompt', 'agent_needs_input', 'quota_auto_resume']
362
+ // src/taps/claude.mjs QUOTA_STAND_DOWN, character for character: two waiters
363
+ // on one terminal is the failure to avoid, so the row says who is waiting.
364
+ const QUOTA_STAND_DOWN = 'Claude Code is waiting at the limit itself; Leg is not handing this one off.'
365
+ // Nothing clears `waiting` on the way out: the ended transition in
366
+ // src/attach.mjs and the lost one in src/sessions.mjs both leave the last
367
+ // Notification shape on the record. A dead terminal is not waiting on
368
+ // anybody, so the guard is the one quietPhrase below already keeps: without
369
+ // it a terminal that exited at a permission prompt said "waiting on you"
370
+ // forever, held the tab badge and never fell into the Finished ledger.
371
+ function notifyWait(s) {
372
+ const w = s && s.waiting
373
+ return w && s.active && NOTIFY_TYPES.includes(w.type) ? w : null
374
+ }
375
+ function resetWait(s) {
376
+ const w = s && s.waiting
377
+ return w && !NOTIFY_TYPES.includes(w.type) ? w : null
378
+ }
379
+ // A.6 rank 3. The question is printed verbatim, capped at the 160 characters
380
+ // the hook itself stores: a paraphrase of what an agent asked for is the one
381
+ // thing a human cannot check against the terminal in front of them.
382
+ function waitingNote(w) {
383
+ if (!w) return null
384
+ const since = Date.parse(w.since || '')
385
+ const msg = String(w.message || '').slice(0, 160)
386
+ const asked = Number.isFinite(since) ? `, asked ${ago(Date.now() - since)} ago` : ''
387
+ if (w.type === 'quota_auto_resume') return { rank: 3, cat: 'standing down', tone: 'warn', text: msg || QUOTA_STAND_DOWN }
388
+ 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}` }
389
+ 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)}` : ''}` }
390
+ return { rank: 3, cat: 'waiting on you', tone: 'warn', text: `waiting on you: ${msg || 'it asked for something'}` }
391
+ }
392
+
393
+ // ---- A.4 rows 7 to 9 and 12: the register's data tokens ------------------
394
+ // Pure data in reading order, so the words can be asserted without a DOM and
395
+ // the row builder below stays a list of appends.
396
+ const QUIET_MS = 2 * 60 * 1000
397
+ // `claude/fable`. The agent alone when the model is unknown: a printed model
398
+ // nobody chose is a wrong number in disguise, so there is never a default.
399
+ function modelToken(s) { return s && s.agent ? (s.model ? `${s.agent}/${s.model}` : s.agent) : null }
400
+ // an observation, not a demand, and printed in the muted tone for every
401
+ // agent. A row already waiting on a human says that instead.
402
+ function quietPhrase(s) {
403
+ if (!s || !s.active || notifyWait(s)) return null
404
+ const t = Date.parse(s.last_activity || '')
405
+ if (!Number.isFinite(t)) return null
406
+ const idle = Date.now() - t
407
+ return idle >= QUIET_MS ? `quiet ${ago(idle)}` : null
408
+ }
409
+ // A.4 row 12: the bucket that will actually stop THIS terminal, which depends
410
+ // on the model it is running. `capacity` is binding(usage, session.model),
411
+ // computed per request in src/server.mjs and never persisted.
412
+ function capacityPhrase(s) {
413
+ const c = s && s.capacity
414
+ if (!c || !Number.isFinite(c.percent)) return null
415
+ const label = s.account && s.account !== 'default' ? `${s.agent}/${s.account}` : s.agent
416
+ // With a rate the row says the same fact in the unit the reader is actually
417
+ // deciding in, and carries the volume it was drawn from. Under the gate it
418
+ // is the percentage again (E rule 6): no time is printed from two readings.
419
+ const f = forecastOf(c)
420
+ if (f) return `about ${burnPhrase(f.seconds_left)} of ${c.scope === 'model' && c.model ? c.model : label} left, ${burnVolume(f)}`
421
+ const pct = Math.round(c.percent)
422
+ if (c.scope === 'model' && c.model) return `${pct}% of the ${c.model} week`
423
+ const win = c.kind === 'session' || c.kind === 'five_hour' ? '5-hour window' : 'week'
424
+ return `${pct}% of the ${label} ${win}`
425
+ }
426
+ function registerTokens(s) {
427
+ const out = []
428
+ const dirty = Array.isArray(s && s.files_dirty) ? s.files_dirty.length : 0
429
+ if (dirty) out.push({ kind: 'dirty', text: `dirty ${dirty}` })
430
+ // `ahead` is a git count the runner polls; an older record does not carry
431
+ // it, and a count nobody measured is never printed as zero
432
+ if (Number.isFinite(s && s.ahead) && s.ahead > 0) out.push({ kind: 'ahead', text: `ahead ${s.ahead}` })
433
+ const model = modelToken(s)
434
+ if (model) out.push({ kind: 'model', text: model })
435
+ const quiet = quietPhrase(s)
436
+ if (quiet) out.push({ kind: 'quiet', text: quiet })
437
+ return out
438
+ }
439
+ // A.6 rank 8.5: said only when the bucket that binds this terminal is at or
440
+ // past the warning line. Model-scoped, a same-login rung is the answer;
441
+ // account-scoped, it buys nothing and the sentence says which login is next.
442
+ function capacityNote(s, account) {
443
+ const c = s && s.capacity
444
+ if (!c || !Number.isFinite(c.percent) || c.percent < WARN_PCT) return null
445
+ const pct = Math.round(c.percent)
446
+ const label = s.account && s.account !== 'default' ? `${s.agent}/${s.account}` : s.agent
447
+ if (c.scope === 'model' && c.model) {
448
+ const alt = account ? openModels(account).filter((m) => m !== c.model)[0] : null
449
+ 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` : ''}` }
450
+ }
451
+ const next = (s.chain || []).find((x) => x && x.agent !== s.agent)
452
+ 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)}` : ''}` }
453
+ }
454
+ function accountOf(s) {
455
+ const list = (view && view.accounts) || []
456
+ return list.find((a) => a.agent === s.agent && (a.account || 'default') === (s.account || 'default')) || null
457
+ }
458
+
459
+ // ---- the capacity strip -------------------------------------------------
460
+ // One 44px band under the verdict, one token per login, and nothing else:
461
+ // usage is a property of the work now, not a region of its own. The panels
462
+ // are not rewritten, they move behind the disclosure at the end of the strip.
463
+ // The token prints the BINDING bucket, because the board printing 47% for a
464
+ // login whose active bucket is at 63% is the defect this strip exists for.
465
+ //
466
+ // src/board/strip.js DRAWS IT, for this page and for /floor, from the same
467
+ // /api/sessions accounts payload. These three names are what this file's
468
+ // callers and test/board-verdict.test.mjs reach for; there is one token
469
+ // builder behind them.
470
+ const capFigure = (a, b) => strip().capFigure(a, b)
471
+ const capToken = (a) => strip().capToken(a)
472
+ const capacityStrip = (list) => strip().capacityStrip(list)
473
+
474
+ // The model rail, on the panel head inside the drawer: one chip per model
475
+ // this login has published a bucket or a wall for. A walled model says when
476
+ // it is back, in words, because a wall is attributed from wording and a
477
+ // percentage is measured, and one must never be printed as the other.
478
+ function modelRail(a) {
479
+ const models = knownModels(a)
480
+ if (!models.length) return null
481
+ const rail = el('span', { class: 'model-rail', 'aria-label': `${accountLabel(a)} models` })
482
+ // B.6: an open chip is the one control that turns a figure into a decision,
483
+ // so it becomes a button that puts that rung at the top of ONE terminal's
484
+ // ladder: the one open in the detail region, else the first live row on
485
+ // this login. With no live terminal on the login there is nothing to
486
+ // re-point and the chip stays what it was, a reading.
487
+ const target = railTerminal(a)
488
+ for (const m of models) {
489
+ const wall = wallFor(a, m)
490
+ const b = modelBucket(a, m)
491
+ const text = wall ? `${m} out until ${until(wall.limited_until)}` : b ? `${m} ${Math.round(b.percent)}%` : m
492
+ const pickable = !wall && target && target.model !== m
493
+ if (!pickable) {
494
+ rail.appendChild(el('span', { class: `model-chip${wall ? ' is-out' : ''}${target && target.model === m ? ' is-current' : ''}`, title: wall && wall.evidence ? wall.evidence : null }, [text]))
495
+ continue
496
+ }
497
+ const chip = el('button', {
498
+ type: 'button', class: 'btn btn-text model-chip model-chip--pick',
499
+ 'data-focus-key': `rail:${a.agent}:${a.account || 'default'}:${m}`,
500
+ title: `Put ${a.agent} / ${m} at the top of ${rowName(target)}'s ladder`,
501
+ }, [text])
502
+ chip.addEventListener('click', () => pickRung(target, { agent: a.agent, account: a.account || 'default', model: m }, chip))
503
+ rail.appendChild(chip)
504
+ }
505
+ return rail
506
+ }
507
+ // the terminal a rail chip re-points: the open one if it is on this login,
508
+ // else the first live row on it
509
+ function railTerminal(a) {
510
+ 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)
511
+ return live.find((s) => s.session_id === drawer.id) || live[0] || null
512
+ }
513
+ // Moving a rung to the top of one terminal's ladder is the same POST the
514
+ // ladder editor makes, so the two cannot drift: one route, one shape.
515
+ async function pickRung(s, rung, btn) {
516
+ btn.disabled = true
517
+ actionNotes.delete(s.session_id)
518
+ const rest = (s.handoff_ladder || []).filter((r) => rungKey(r) !== rungKey(rung))
519
+ const kept = (s.handoff_ladder || []).find((r) => rungKey(r) === rungKey(rung))
520
+ const ladder = [kept || { ...rung, when: 'always', cost: rung.agent === 'agy' ? 'free' : rung.agent === 'grok' ? 'metered' : 'plan' }, ...rest]
521
+ try {
522
+ await api(`/api/sessions/${encodeURIComponent(s.session_id)}/handoff-order`, { method: 'POST', body: { handoff_ladder: ladder } })
523
+ actionNotes.set(s.session_id, { at: Date.now(), tone: 'ok', text: `${rungLabel(rung)} is the next rung for this terminal` })
524
+ } catch (err) {
525
+ actionNotes.set(s.session_id, { at: Date.now(), tone: 'danger', text: err.message })
526
+ }
527
+ refresh()
528
+ }
529
+
255
530
  // A login is a raised object, and how much surface it gets is the design
256
531
  // saying how much it matters. The login carrying the terminals gets the wide
257
532
  // lit panel with both of its windows drawn; a login with one fact to report
@@ -265,6 +540,8 @@
265
540
  panel.appendChild(el('div', { class: 'panel-head' }, [
266
541
  el('span', { class: 'who' }, [el('span', { class: `dot id-${id}` }), el('span', { class: `acct-name id-${id}` }, [accountLabel(a)])]),
267
542
  el('span', { class: 'who-note' }, [live]),
543
+ // one chip per model this login has published a bucket or a wall for
544
+ modelRail(a),
268
545
  ]))
269
546
 
270
547
  if (state === 'notshared' || state === 'loading') {
@@ -310,62 +587,288 @@
310
587
  }
311
588
 
312
589
  // 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:
590
+ // sentence. It is never the number that the strip under it already prints:
314
591
  // the same figure in the two largest slots on the page is one fact taking up
315
592
  // 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.`
593
+ //
594
+ // VERDICT_CH is a MEASUREMENT, not a taste: at 1280 the verdict column is
595
+ // 26ch (891px) and 300 random sentences per length, drawn from this table's
596
+ // own vocabulary, still fit two 56.16px lines at 60 characters. 56 is that
597
+ // ceiling with four characters of slack for a longer login name, and
598
+ // test/board-verdict.test.mjs holds every branch under it.
599
+ const VERDICT_CH = 56
600
+ // the sub is two lines of 17px inside `max-width: 54ch`, which is about 120
601
+ // characters; clauses are added while they fit and dropped whole after that.
602
+ const SUB_CH = 120
603
+ // mirrors WARN_PCT in src/usage.mjs, which the board cannot import from. A
604
+ // bucket at or past it is close enough to the wall that the row says so.
605
+ const WARN_PCT = 85
606
+ // Each headline is written as a preferred form and shorter fallbacks, so a
607
+ // login called `claude/very-long-account` costs a clause, never a third line.
608
+ function headline(...forms) {
609
+ const real = forms.filter(Boolean)
610
+ for (const f of real) if (f.length <= VERDICT_CH) return f
611
+ const last = String(real[real.length - 1] || '')
612
+ const cut = last.slice(0, VERDICT_CH - 1)
613
+ const space = cut.lastIndexOf(' ')
614
+ return `${(space > 20 ? cut.slice(0, space) : cut).replace(/[,.;:]$/, '')}.`
615
+ }
616
+ function subLine(...parts) {
617
+ const out = []
618
+ for (const p of parts.filter(Boolean)) {
619
+ const next = out.concat(p).join(' ')
620
+ if (next.length <= SUB_CH) out.push(p)
347
621
  }
622
+ return out.join(' ')
623
+ }
348
624
 
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.`)
625
+ // A.5, top to bottom. A bucket whose state is unknown is never named, and
626
+ // "Measured Ns ago" appears only when the reading is actually stale.
627
+ // C.5: the verdict never mentions cards unless one is waiting on a human.
628
+ // `cards_waiting` on the payload is {count, first:{id,title,station,since}};
629
+ // an older server sends a bare number and a guest is sent nothing at all, and
630
+ // neither of those can name a card, so neither prints a sentence about one.
631
+ function waitingCard(cards) {
632
+ const first = cards && typeof cards === 'object' ? cards.first : null
633
+ return first && first.id ? first : null
634
+ }
635
+ // `card 3e1c`: the tail of the card id, which is how a card is named on its
636
+ // own row and out loud.
637
+ function cardName(id) { return `card ${String(id).split('-').filter(Boolean).slice(-1)[0] || id}` }
638
+ function verdictLines(list, sessions, cards) {
639
+ const accounts = (list || []).filter((a) => a && a.agent && !a.loading)
640
+ if (!accounts.length) return { line: 'Reading the logins.', sub: '' }
641
+ const live = (sessions || []).filter((s) => s.active)
642
+ const acctOf = (s) => accounts.find((a) => a.agent === s.agent && (a.account || 'default') === (s.account || 'default'))
643
+ const liveOn = (a) => live.filter((s) => acctOf(s) === a).length
644
+ const walled = accounts.filter((a) => acctState(a) === 'walled')
645
+ const carrying = accounts.filter((a) => liveOn(a) > 0)
646
+ const subject = (carrying.length ? closestToWall(carrying) : closestToWall(accounts)) || accounts[0]
647
+ const b = bindingOf(subject)
648
+ const other = (a) => accounts.filter((x) => x !== a)
649
+ const openElsewhere = other(subject).filter((a) => acctState(a) !== 'walled')
650
+ const leftOf = (bb) => Math.max(0, 100 - Math.round(bb.percent))
651
+ 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 }
652
+ 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.`)
653
+ const otherWalls = () => walled.filter((a) => a !== subject).map(wallClause).join(' ') || null
654
+ // a reading taken two hours ago with terminals running since is a floor,
655
+ // not a measurement, and the direction it is wrong in is the whole point
656
+ const staleClause = (a, bb) => {
657
+ const observed = Date.parse((a && (a.observed_at || a.updated_at)) || '')
658
+ if (!a || !a.stale || a.agent === 'agy' || !Number.isFinite(observed) || !bb) return null
659
+ const n = liveOn(a)
660
+ if (!n) return `Measured ${ago(Date.now() - observed)} ago.`
661
+ return `Measured ${ago(Date.now() - observed)} ago. ${n} terminal${n === 1 ? ' runs' : 's run'} on it, so the real figure is higher, never lower.`
662
+ }
663
+
664
+ // 1. a human is blocked. Attention is the scarcer thing, so it outranks
665
+ // usage. `waiting` carries {type, message, since} from the Notification
666
+ // hook; the all-out countdown on the same key carries {type: 'reset',
667
+ // agent, account, resets_at, since} and is not a human being waited on, so
668
+ // the TYPE is checked and never the presence of `since`. `quota_auto_resume`
669
+ // is left out here too: nobody asked the human anything, Claude Code is
670
+ // holding its own turn, and the row says so in its own sentence.
671
+ const blocked = live
672
+ .filter((s) => notifyWait(s) && s.waiting.type !== 'quota_auto_resume' && Number.isFinite(Date.parse(s.waiting.since)))
673
+ .sort((x, y) => Date.parse(x.waiting.since) - Date.parse(y.waiting.since))[0]
674
+ if (blocked) {
675
+ const who = rowName(blocked)
676
+ const waited = spoken(Date.now() - Date.parse(blocked.waiting.since))
677
+ const others = live.length - 1
678
+ return {
679
+ 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.`),
680
+ sub: subLine(
681
+ // the question in the words of the thing that asked it, and each
682
+ // type asks a different question: a permission prompt names a tool,
683
+ // an idle prompt names a clock, and the third carries its own text
684
+ blocked.waiting.type === 'permission_prompt' && blocked.waiting.message ? `It asked to run ${String(blocked.waiting.message).slice(0, 80)}.`
685
+ : blocked.waiting.type === 'idle_prompt' ? `It has had no input since ${clockAt(Date.parse(blocked.waiting.since))}.`
686
+ : blocked.waiting.message ? `It asked: ${String(blocked.waiting.message).slice(0, 80)}` : null,
687
+ others > 0 ? `The other ${others === 1 ? 'terminal is' : `${others} terminals are`} still running.` : null,
688
+ ),
689
+ }
690
+ }
691
+
692
+ // 1b. the same rule one level down: no terminal is blocked, but a card is,
693
+ // and a card waiting at a review station is a human being waited on too. It
694
+ // outranks every usage branch for the reason branch 1 does, and it is the
695
+ // ONLY thing that puts a card in the verdict (C.5).
696
+ const card = waitingCard(cards)
697
+ const cardSince = card ? Date.parse(card.since || '') : NaN
698
+ if (card) {
699
+ const who = cardName(card.id)
700
+ return {
701
+ line: Number.isFinite(cardSince)
702
+ ? 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.`)
703
+ : headline(`${who} is waiting on you.`),
704
+ sub: subLine(card.station ? `It is at the ${card.station} station.` : null, 'Approve or Reassign on its row.'),
705
+ }
706
+ }
707
+
708
+ // 2. every login at its limit: nothing anywhere can be started, whatever
709
+ // is running. Said before the per-login branches because it is the whole
710
+ // board's state, not this login's.
711
+ if (walled.length === accounts.length && walled.length > 1) {
712
+ const first = [...walled].sort((x, y) => (x.limited_until || Infinity) - (y.limited_until || Infinity))[0]
713
+ return {
714
+ line: headline(`Every login is at its limit; ${accountLabel(first)} is back first.`, `Every login is at its limit.`),
715
+ sub: subLine(Number.isFinite(first.limited_until) ? `${accountLabel(first)} returns ${until(first.limited_until)}.` : null),
716
+ }
358
717
  }
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.`)
718
+
719
+ if (live.length) {
720
+ // 3. an account-scoped bucket binds, and this login has model rungs that
721
+ // therefore buy nothing. Only said where models are KNOWN: on a login
722
+ // with no model buckets there is no switch to warn anyone off.
723
+ if (b && b.scope === 'account' && knownModels(subject).length) {
724
+ const alt = openModels(subject)[0]
725
+ // the rung a hand-off off this login would ACTUALLY take, from the
726
+ // chooser's own answer for a terminal running here, and only the
727
+ // nearest open login when no live row has one. A login that is open is
728
+ // not the same fact as a rung that is eligible: a reserve, a cost gate
729
+ // or a `below N%` rung can rule one out, and the sentence that names it
730
+ // is the sentence the reader acts on.
731
+ const eligible = live.filter((s) => acctOf(s) === subject).map((s) => s.eligible_next).find(Boolean)
732
+ const next = eligible || openElsewhere[0]
733
+ return {
734
+ line: headline(`${accountLabel(subject)} has ${leftOf(b)}% left, shared by every model.`, `${accountLabel(subject)} has ${leftOf(b)}% left, for every model.`),
735
+ sub: subLine(
736
+ alt ? `Switching to ${alt} buys nothing.` : null,
737
+ next ? `Next off ${accountLabel(subject)}: ${eligible ? rungLabel(next) : accountLabel(next)}.` : 'Nothing else is open.',
738
+ staleClause(subject, b),
739
+ ),
740
+ }
741
+ }
742
+
743
+ // 4. a model bucket is walled while the account window is open: the one
744
+ // case where a same-login switch is the answer.
745
+ const out = acctState(subject) === 'walled' ? [] : walledModels(subject)
746
+ if (out.length) {
747
+ const wall = wallFor(subject, out[0])
748
+ const open = openModels(subject)[0]
749
+ const acct = accountBucket(subject)
750
+ return {
751
+ line: headline(
752
+ `${Model(out[0])} is out until ${until(wall.limited_until)}; ${open || 'no other model'} is open.`,
753
+ `${Model(out[0])} is out until ${until(wall.limited_until)}.`,
754
+ ),
755
+ sub: subLine(
756
+ acct ? `${accountLabel(subject)} still has ${leftOf(acct)}% of ${windowPhrase(acct)}.` : null,
757
+ open ? `Hand off > ${subject.agent}/${open} keeps this terminal.` : null,
758
+ ),
759
+ }
760
+ }
761
+
762
+ // 5. a model bucket came back and a terminal is still on the rung Leg
763
+ // dropped it to. A wall Leg recorded whose clock has run out is the only
764
+ // evidence that a model returned, and a live row on this login running a
765
+ // DIFFERENT model is the only evidence anyone is still downshifted; with
766
+ // neither the sentence is never guessed at.
767
+ //
768
+ // Spec A.5 lists this row under "several logins carry work", where it can
769
+ // never fire: the two branches below return for any login that has a
770
+ // figure at all. It is a state CHANGE, and it outranks the two branches
771
+ // that restate a standing percentage (DEVIATIONS.md, step 3).
772
+ const back = knownModels(subject)
773
+ .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) })
774
+ .find((m) => live.some((s) => acctOf(s) === subject && s.model && s.model !== m))
775
+ if (back) {
776
+ const down = live.find((s) => acctOf(s) === subject && s.model && s.model !== back)
777
+ return {
778
+ line: headline(`${Model(back)} is back; ${rowName(down)} is still on ${down.model}.`, `${Model(back)} is back.`),
779
+ sub: subLine(`Leg climbs back at the next hand-off.`, `Back to ${back} on the row does it now.`),
780
+ }
781
+ }
782
+
783
+ // 5.5. the burn rate (A.5 row 4): a time beats a percentage, because the
784
+ // decision is about the afternoon and not about the number. The rate
785
+ // rides each session's own `capacity` (binding(usage, model) in
786
+ // src/server.mjs carries burn()); the accounts payload is buckets, not
787
+ // rates, so a live row bound to the SAME bucket as the strip's figure is
788
+ // where the board reads it. A guest payload has no capacity at all, so a
789
+ // guest board never prints a time, which is the rule the strip follows
790
+ // already. Placed under the model-came-back branch and above the two
791
+ // standing-percentage branches: a state CHANGE still outranks a figure.
792
+ const rate = b
793
+ ? 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
794
+ : null
795
+ if (rate) {
796
+ const who = b.model ? Model(b.model) : accountLabel(subject)
797
+ // which other models this login has published a bucket or a wall for:
798
+ // said because a Fable figure is NOT the login's figure, and the reader
799
+ // who takes it for one plans the wrong afternoon. A model nobody has
800
+ // seen is never named, so a login with one bucket says nothing here.
801
+ const others = b.model ? knownModels(subject).filter((m) => m !== b.model).map(Model) : []
802
+ return {
803
+ line: headline(`About ${burnPhrase(rate.seconds_left)} of ${who} left.`, `About ${burnPhrase(rate.seconds_left)} left.`),
804
+ sub: subLine(
805
+ `${burnVolume(rate, 'From')}.`,
806
+ b.model
807
+ ? (others.length ? `${andList(others)} ${others.length === 1 ? 'has its own bucket' : 'have their own buckets'}.` : null)
808
+ : 'Shared by every model.',
809
+ staleClause(subject, b),
810
+ ),
811
+ }
812
+ }
813
+
814
+ // 6. one login carries every live terminal: one login, one point of
815
+ // failure, and that is what the sentence says.
816
+ if (carrying.length === 1 && b) {
817
+ const who = b.model ? Model(b.model) : accountLabel(subject)
818
+ return {
819
+ line: headline(
820
+ `${who} is at ${Math.round(b.percent)}% of ${windowPhrase(b)}, the only login open.`,
821
+ `${who} is at ${Math.round(b.percent)}% of ${windowPhrase(b)}.`,
822
+ ),
823
+ sub: subLine(staleClause(subject, b), otherWalls()),
824
+ }
825
+ }
826
+
827
+ // 7. several logins carry work: name the one closest to a wall, and the
828
+ // volume, then put the next login's figure in the sub.
829
+ if (b) {
830
+ const second = other(subject).map(figure).filter(Boolean)[0]
831
+ return {
832
+ line: headline(
833
+ `${accountLabel(subject)} has ${leftOf(b)}% left, and ${live.length} terminal${live.length === 1 ? ' is' : 's are'} working.`,
834
+ `${accountLabel(subject)} has ${leftOf(b)}% left.`,
835
+ ),
836
+ sub: subLine(second, staleClause(subject, b), otherWalls()),
837
+ }
838
+ }
839
+
840
+ // live, and not one login has published a figure. Never a guess.
841
+ return {
842
+ 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.`),
843
+ sub: subLine(otherWalls(), 'No login has reported a usage figure yet.'),
844
+ }
845
+ }
846
+
847
+ // 8 to 11. nothing is running.
848
+ 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]
849
+ 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
850
+ if (walled.length) {
851
+ const first = [...walled].sort((x, y) => (x.limited_until || Infinity) - (y.limited_until || Infinity))[0]
852
+ return {
853
+ line: headline(
854
+ `Nothing is running. ${accountLabel(first)} is back ${until(first.limited_until)}.`,
855
+ `Nothing is running. ${accountLabel(first)} is at its limit.`,
856
+ ),
857
+ sub: subLine(openLine),
858
+ }
364
859
  }
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(' ') }
860
+ if (bestOpen) return { line: headline(`Nothing is running. ${openLine}`, 'Nothing is running.'), sub: '' }
861
+ return { line: 'Nothing is running, and no login has a figure.', sub: '' }
367
862
  }
368
863
 
864
+ // The login panels are behind one disclosure now, and whether it is open is
865
+ // the reader's decision, kept across reloads and shared with /floor, which
866
+ // puts the same panels behind the same button. strip.js owns the state and
867
+ // the key; these two names are what this file's init and its click binding
868
+ // reach for.
869
+ const renderCapacityToggle = () => strip().renderCapacityToggle()
870
+ const toggleCapacity = () => strip().toggleCapacity()
871
+
369
872
  // 6.1.5, all eight states. The walled state is an ADDITIONAL state of the
370
873
  // rail, never a replacement for it: both percentages, both reset times and
371
874
  // both rails stay on screen while the account is at its wall, and R4 grows.
@@ -375,11 +878,14 @@
375
878
  box.textContent = ''
376
879
  const list = accounts || []
377
880
 
378
- const { line, sub } = verdictLines(list, view ? view.sessions : [])
881
+ const { line, sub } = verdictLines(list, view ? view.sessions : [], view ? view.cards_waiting : null)
379
882
  const h1 = document.getElementById('verdict-line')
380
883
  const p = document.getElementById('verdict-sub')
381
884
  if (h1) h1.textContent = line
382
885
  if (p) p.textContent = sub
886
+ // the strip is the only usage on screen until the reader opens the drawer
887
+ capacityStrip(list)
888
+ renderCapacityToggle()
383
889
 
384
890
  if (!list.length) return
385
891
  // Size encodes importance. The login the terminals are on gets the wide lit
@@ -454,12 +960,24 @@
454
960
  ? `${o.agent} (${tail(o.session_id)}) is changing ${files} in another checkout; whoever lands second rebases`
455
961
  : `${o.agent} (${tail(o.session_id)}) is editing ${files} too` })
456
962
  }
963
+ // A.6: a terminal parked at a permission prompt is waiting on a human, and
964
+ // at rank 3 it raises the row, sorts it and counts it in the region head
965
+ // through `needsYou` without a second predicate.
966
+ const waits = waitingNote(notifyWait(s))
967
+ if (waits) out.push(waits)
457
968
  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)}` })
969
+ // the OTHER shape on `waiting`: the all-out countdown, where the child is
970
+ // already dead and nobody is waiting on a human
971
+ 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
972
  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
973
  // rank 8 does not restate the percentage: the head prints it in 30px type a
461
974
  // few inches above. It names what the head does not carry, the fallback.
462
975
  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'}` })
976
+ // rank 8.5: the bucket that binds THIS terminal is near its wall. It sorts
977
+ // under the account's own warning and above the activity fallback, because
978
+ // it is the more specific of the two: per model, not per login.
979
+ const cap = capacityNote(s, accountOf(s))
980
+ if (cap) out.push(cap)
463
981
  // rank 10 is a genuine fallback, so a state nobody wrote a fixture for still
464
982
  // gets a correct sentence rather than an empty slot.
465
983
  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 +1035,195 @@
517
1035
  return el('div', { class: 'kv' }, rows)
518
1036
  }
519
1037
 
520
- function moveOrder(order, index, delta) {
1038
+ // ---- B.3 the fallback ladder, as an editable list -----------------------
1039
+ // A rung is a destination, not an agent: {agent, account, model, when, cost}.
1040
+ // Everything below is pure, so the round trip from a saved ladder to the
1041
+ // controls and back is testable without a DOM.
1042
+ function moveRung(ladder, index, delta) {
521
1043
  const target = index + delta
522
- if (target < 0 || target >= order.length) return [...order]
523
- const next = [...order]
1044
+ if (target < 0 || target >= ladder.length) return [...ladder]
1045
+ const next = [...ladder]
524
1046
  ;[next[index], next[target]] = [next[target], next[index]]
525
1047
  return next
526
1048
  }
1049
+ // `claude / opus`, `codex`, `claude/work / sonnet`: the login first, then the
1050
+ // model, and a model is never invented for an agent that published none.
1051
+ function rungLabel(r) {
1052
+ if (!r || !r.agent) return 'none'
1053
+ const login = r.account && r.account !== 'default' ? `${r.agent}/${r.account}` : r.agent
1054
+ return r.model ? `${login} / ${r.model}` : login
1055
+ }
1056
+ const rungKey = (r) => `${r.agent}--${r.account || 'default'}--${r.model || ''}`
1057
+ // What a rung spends, in words. `plan` is the subscription already paid for,
1058
+ // which is why it is the only cost that reads as nothing extra.
1059
+ const COST_WORDS = { free: 'free', plan: 'on the plan', credits: 'spends usage credits', metered: 'spends metered credits' }
1060
+ function costWord(cost) { return COST_WORDS[cost] || COST_WORDS.plan }
1061
+
1062
+ // B.6, one option in the Hand off picker. Three fields, always in this order:
1063
+ // the rung, what taking it does to the conversation, and what it costs you
1064
+ // right now. A rung that cannot be taken carries the SERVER'S reason
1065
+ // verbatim, never a board paraphrase: that sentence is the one the chooser
1066
+ // itself would print in the ledger, and two wordings for one refusal is how a
1067
+ // greyed row starts lying. The separator is a middot because a browser
1068
+ // collapses runs of spaces inside an <option>.
1069
+ function handoffOptionText(t) {
1070
+ const mode = t.keeps_conversation ? 'same terminal, keeps the conversation' : 'new agent, from the bundle'
1071
+ let state
1072
+ if (!t.available) state = `${t.reason || 'not available right now'}${Number.isFinite(t.resets_at) ? ` until ${until(t.resets_at)}` : ''}`
1073
+ else if (t.reason) state = t.reason
1074
+ else state = ['credits', 'metered'].includes(t.cost) ? `ready, ${costWord(t.cost)}` : 'ready'
1075
+ return [rungLabel(t), mode, state].join(' · ')
1076
+ }
1077
+ // A destination is a rung, so it is remembered as one. The index is still the
1078
+ // option's value (an account name is not ours to parse), but an index is a
1079
+ // position in a list the poll rewrites, and the pick has to outlive that.
1080
+ function pickKey(t) { return t && t.agent ? `${t.agent}/${t.account || 'default'}/${t.model || ''}` : null }
1081
+ function pickIndex(targets, key) {
1082
+ if (!key) return ''
1083
+ const at = (targets || []).findIndex((t) => pickKey(t) === key)
1084
+ return at < 0 ? '' : String(at)
1085
+ }
1086
+ // `when` is one string on the record and two controls on screen.
1087
+ function whenKind(when) { return String(when || 'always').startsWith('below:') ? 'below' : (when === 'walled-only' ? 'walled-only' : 'always') }
1088
+ // The server's own range is 0 to 100 (WHEN_RE in src/preferences.mjs), and
1089
+ // what is stored is what is shown: a zero is a reading, so a rung saved as
1090
+ // `below:0` renders as 0 and carries its consequence (whenFlag) rather than
1091
+ // being redrawn as a 50 nobody chose. Only a `below:` with no number at all
1092
+ // falls back.
1093
+ function whenPct(when) {
1094
+ const n = Number(String(when || '').slice('below:'.length))
1095
+ return Number.isFinite(n) && n >= 0 && n <= 100 && String(when || '').slice('below:'.length).trim() !== '' ? n : 50
1096
+ }
1097
+ function whenString(kind, pct) {
1098
+ if (kind === 'walled-only') return 'walled-only'
1099
+ if (kind !== 'below') return 'always'
1100
+ const n = Math.max(0, Math.min(100, Math.round(Number(pct) || 0)))
1101
+ return `below:${n}`
1102
+ }
1103
+ // An empty box is not a percentage. Clearing the number to type a new one and
1104
+ // tabbing away used to store `below:1`, a rung that is never taken; the rung
1105
+ // goes back to `always`, which is the rule with no number in it.
1106
+ function whenFromBox(value) {
1107
+ const raw = String(value === null || value === undefined ? '' : value).trim()
1108
+ return raw === '' ? 'always' : whenString('below', raw)
1109
+ }
1110
+ // A stored number the editor would never produce is shown with what it does,
1111
+ // never rewritten: both ends of the server's range are legal and neither is
1112
+ // a percentage anybody means.
1113
+ function whenFlag(when) {
1114
+ if (whenKind(when) !== 'below') return null
1115
+ const n = whenPct(when)
1116
+ if (n === 0) return 'below 0% is never true: this rung is never taken'
1117
+ if (n === 100) return 'below 100% is always true: this rung is taken like always'
1118
+ return null
1119
+ }
1120
+ const WHEN_WORDS = [['always', 'always'], ['below', 'below N%'], ['walled-only', 'walled only']]
527
1121
 
528
- function orderRows(order, onMove, scope) {
1122
+ // The editor. Numbered rows, one select and one optional number per rung, and
1123
+ // three buttons that survive a rebuild by data-focus-key the way the order
1124
+ // editor's did. No drag: a list this short is faster with two buttons, and a
1125
+ // drag has no keyboard.
1126
+ function ladderRows(ladder, onChange, scope) {
529
1127
  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]))
1128
+ ladder.forEach((r, index) => {
1129
+ const key = rungKey(r)
1130
+ const label = rungLabel(r)
1131
+ const row = el('div', { class: 'ladder-row' })
1132
+ row.appendChild(el('span', { class: 'ladder-num' }, [`${index + 1}.`]))
1133
+ row.appendChild(el('span', { class: `dot id-${idOf(r.agent)}`, 'aria-hidden': 'true' }))
1134
+ row.appendChild(el('span', { class: `ladder-name chip-id-${idOf(r.agent)}` }, [label]))
1135
+
1136
+ const kind = whenKind(r.when)
1137
+ const whenId = `ladder-when-${scope}-${key}`
1138
+ const when = el('select', { id: whenId, class: 'ladder-when', 'aria-label': `When to take ${label}`, 'data-focus-key': `ladder:${scope}:${key}:when` })
1139
+ for (const [value, text] of WHEN_WORDS) {
1140
+ const opt = el('option', { value }, [text])
1141
+ if (value === kind) opt.setAttribute('selected', 'selected')
1142
+ when.appendChild(opt)
1143
+ }
1144
+ when.value = kind
1145
+ when.addEventListener('change', () => onChange(ladder.map((x, i) => (i === index ? { ...x, when: whenString(when.value, whenPct(r.when)) } : x))))
1146
+ // one cell for the rule and its number, so the selects line up in a
1147
+ // column whether or not a rung carries a percentage
1148
+ const rule = el('span', { class: 'ladder-rule' }, [when])
1149
+ row.appendChild(rule)
1150
+ if (kind === 'below') {
1151
+ const pct = el('input', {
1152
+ type: 'number', min: '0', max: '100', class: 'ladder-pct', value: String(whenPct(r.when)),
1153
+ 'aria-label': `Take ${label} only under this percent`, 'data-focus-key': `ladder:${scope}:${key}:pct`,
1154
+ })
1155
+ pct.addEventListener('change', () => onChange(ladder.map((x, i) => (i === index ? { ...x, when: whenFromBox(pct.value) } : x))))
1156
+ rule.appendChild(pct)
1157
+ rule.appendChild(el('span', { class: 'ladder-cost' }, ['%']))
1158
+ const flag = whenFlag(r.when)
1159
+ if (flag) rule.appendChild(el('span', { class: 'ladder-flag tone-warn' }, [flag]))
1160
+ }
1161
+ row.appendChild(el('span', { class: 'ladder-cost ladder-costcol' }, [costWord(r.cost)]))
1162
+
1163
+ 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'])
1164
+ 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'])
1165
+ 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'])
1166
+ up.addEventListener('click', () => onChange(moveRung(ladder, index, -1)))
1167
+ down.addEventListener('click', () => onChange(moveRung(ladder, index, 1)))
1168
+ drop.addEventListener('click', () => onChange(ladder.filter((_, i) => i !== index)))
1169
+ row.append(up, down, drop)
1170
+ box.appendChild(row)
542
1171
  })
543
1172
  return box
544
1173
  }
545
1174
 
1175
+ // `[+ Add a rung]`: an agent and one of its models, or `default` for the
1176
+ // model the CLI picks itself. An agent Leg knows no model names for offers
1177
+ // `default` alone rather than a guess.
1178
+ // A dead control is worse than none: pressing Add on a rung the ladder
1179
+ // already carries used to do nothing at all, with no row, no sentence and no
1180
+ // hover reason, so the reader pressed it again. It names the rung it found
1181
+ // and where it already is.
1182
+ function duplicateRung(ladder, rung) {
1183
+ const at = (ladder || []).findIndex((r) => rungKey(r) === rungKey(rung))
1184
+ return at < 0 ? null : `${rungLabel(rung)} is already rung ${at + 1}.`
1185
+ }
1186
+
1187
+ // The models this MACHINE publishes for an agent, from /api/models
1188
+ // (src/models.mjs), which board.js fetches once and parks on `state.models`.
1189
+ // MODEL_ALIASES is the fallback and stays the truth for claude, whose four
1190
+ // words are aliases Claude Code resolves rather than service-side ids; codex,
1191
+ // agy and grok have no entry there at all, so the rung editor could offer
1192
+ // them `default` and nothing else and a reader could not put gpt-5.6-luna on
1193
+ // a rung from this page.
1194
+ function catalogModels(agent) {
1195
+ const board = typeof window !== 'undefined' && window.legBoard ? window.legBoard : null
1196
+ const live = board && board.models ? board.models[agent] : null
1197
+ if (Array.isArray(live) && live.length) return live
1198
+ return (MODEL_ALIASES[agent] || []).map((id) => ({ id, label: id }))
1199
+ }
1200
+ function addRungRow(ladder, onChange, scope, onRefuse) {
1201
+ const row = el('div', { class: 'ladder-add' })
1202
+ const agentId = `ladder-add-agent-${scope}`
1203
+ const modelId = `ladder-add-model-${scope}`
1204
+ const agent = el('select', { id: agentId, class: 'ladder-when', 'aria-label': 'Agent for the new rung', 'data-focus-key': `ladder:${scope}:add:agent` })
1205
+ for (const a of LADDER_AGENTS) agent.appendChild(el('option', { value: a }, [a]))
1206
+ agent.value = LADDER_AGENTS[0]
1207
+ const model = el('select', { id: modelId, class: 'ladder-when', 'aria-label': 'Model for the new rung', 'data-focus-key': `ladder:${scope}:add:model` })
1208
+ const fillModels = () => {
1209
+ model.textContent = ''
1210
+ model.appendChild(el('option', { value: '' }, ['default']))
1211
+ for (const m of catalogModels(agent.value)) model.appendChild(el('option', { value: m.id }, [m.label || m.id]))
1212
+ model.value = ''
1213
+ }
1214
+ fillModels()
1215
+ agent.addEventListener('change', fillModels)
1216
+ const add = el('button', { type: 'button', class: 'btn btn-secondary', 'data-focus-key': `ladder:${scope}:add:go` }, ['+ Add a rung'])
1217
+ add.addEventListener('click', () => {
1218
+ const rung = { agent: agent.value, account: 'default', model: model.value || null, when: 'always', cost: agent.value === 'agy' ? 'free' : agent.value === 'grok' ? 'metered' : 'plan' }
1219
+ const already = duplicateRung(ladder, rung)
1220
+ if (already) { if (onRefuse) onRefuse(already); return }
1221
+ onChange([...ladder, rung])
1222
+ })
1223
+ row.append(el('label', { for: agentId }, ['Add']), agent, el('label', { for: modelId }, ['model']), model, add)
1224
+ return row
1225
+ }
1226
+
546
1227
  // Every region on this page is wiped and rebuilt on a timer, so a control the
547
1228
  // reader had tabbed to is a different element three seconds later and focus
548
1229
  // lands back on <body>. Controls that survive a rebuild by identity carry a
@@ -583,17 +1264,152 @@
583
1264
  // A rebuild clears any selection that spans it, so the timed re-sort stands
584
1265
  // down while the reader is selecting a path or a sentence out of a panel. A
585
1266
  // real data push still redraws: the words on screen win over the drag.
586
- function selectionInsideGrid() {
1267
+ function selectionInside(box) {
587
1268
  const sel = typeof document.getSelection === 'function' ? document.getSelection() : null
588
1269
  if (!sel || sel.isCollapsed || !String(sel).trim()) return false
1270
+ return Boolean(box && sel.anchorNode && typeof box.contains === 'function' && box.contains(sel.anchorNode))
1271
+ }
1272
+ function selectionInsideGrid() { return selectionInside(document.getElementById('session-grid')) }
1273
+
1274
+ // How long the expanded region may refuse to redraw itself. The grid's order
1275
+ // hold and this one answer different questions -- one is about rows moving
1276
+ // under the reader, the other about the region going stale -- so they have
1277
+ // their own bounds: a held selection may freeze the ORDER for as long as the
1278
+ // drag lasts, but it may not hide finished turns for more than twenty
1279
+ // seconds. See the paragraph in renderDrawer.
1280
+ const DRAWER_HOLD_MS = 20000
1281
+ let drawerHeldAt = 0
1282
+ function drawerStandsDown({ timed, selecting, confirming, heldForMs }) {
1283
+ // a redraw the reader asked for (show 40 more, Resume updates, a rung
1284
+ // moved) is never a surprise; only the poll's own redraw stands down
1285
+ if (!timed) return false
1286
+ if (confirming) return true
1287
+ if (!selecting) return false
1288
+ return heldForMs < DRAWER_HOLD_MS
1289
+ }
1290
+
1291
+ // ---- the list holds still while the reader is inside it ------------------
1292
+ // Reported 2026-09-18: "the page is hard to interact with while a terminal is
1293
+ // running and the details are expanded, it keeps jumping around and knocking
1294
+ // me out of what I'm doing." Every push re-sorts (needs-you first), so a row
1295
+ // crossing that partition moves every row after it, and the expansion hanging
1296
+ // under one of them goes with it. The sort is right; running it under the
1297
+ // reader's cursor is not. While they are demonstrably inside the list the
1298
+ // previous order is held, and the new one is applied on the first render
1299
+ // after they come out.
1300
+ //
1301
+ // Two of the four reasons are unbounded and two are not. An expansion and a
1302
+ // live selection are the reader in the middle of something, and both end with
1303
+ // one click of theirs. A pointer resting on a row and a button still holding
1304
+ // focus after a click are states that last until the machine is touched
1305
+ // again, and an unbounded hold on either would freeze the needs-you sort for
1306
+ // the rest of the day, which is a worse bug than the one this fixes.
1307
+ //
1308
+ // 2026-09-18, second pass: an expansion is not one of the bounded two. A
1309
+ // reader leaves one open and walks away -- that IS the resting state of a
1310
+ // dashboard -- and while it was open the needs-you sort never ran again. A
1311
+ // terminal that hit a permission prompt went urgent, the tab badge counted
1312
+ // it, and the row it was on stayed at the bottom of the list for as long as
1313
+ // the expansion lived. Waiting hours to be shown a terminal that is waiting
1314
+ // on you is a worse bug than a row moving. So `expanded` takes the same
1315
+ // ORDER_HOLD_MS release hovering and focus have: the reader gets 30 seconds
1316
+ // of stillness from each push, and then the sort is allowed to run.
1317
+ //
1318
+ // The expanded row itself is not pinned to its slot, and deliberately so:
1319
+ // the row the reader is waiting on is usually BELOW the expansion (that is
1320
+ // the reported case), so a pin would leave it exactly where it was buried.
1321
+ // The expansion moving down the LIST does not move it on the SCREEN --
1322
+ // holdAnchor puts its viewport offset back after the rebuild, which is the
1323
+ // thing the reader actually perceives, and the probe asserts both halves.
1324
+ //
1325
+ // A live selection keeps its unbounded hold. A drag really does die on a
1326
+ // rebuild, and it ends the moment the reader lets go.
1327
+ const ORDER_HOLD_MS = 30000
1328
+ function holdsOrder({ expanded, selecting, hovering, focusInside, divergedForMs }) {
1329
+ if (selecting) return true
1330
+ if (!expanded && !hovering && !focusInside) return false
1331
+ return !(divergedForMs >= ORDER_HOLD_MS)
1332
+ }
1333
+
1334
+ // The ids to draw, in order: the sort, or the order they had when the reader
1335
+ // went in. A terminal that started while the order was held joins at the end,
1336
+ // where it cannot move anything on screen; one that left simply drops out.
1337
+ function listOrder(natural, held, hold) {
1338
+ if (!hold || !held || !held.length) return natural
1339
+ const at = new Map(held.map((id, i) => [id, i]))
1340
+ const known = natural.filter((id) => at.has(id)).sort((a, b) => at.get(a) - at.get(b))
1341
+ return known.concat(natural.filter((id) => !at.has(id)))
1342
+ }
1343
+
1344
+ function readerIsInTheList() {
589
1345
  const grid = document.getElementById('session-grid')
590
- return Boolean(grid && sel.anchorNode && grid.contains(sel.anchorNode))
1346
+ const region = detailRegion()
1347
+ // a region that is hidden is one the reader has just closed: the focus
1348
+ // still sitting on its Close button is not a reason to hold the order
1349
+ const open = region && !region.hidden ? region : null
1350
+ const node = document.activeElement
1351
+ const has = (box) => Boolean(box && node && node !== document.body && typeof box.contains === 'function' && box.contains(node))
1352
+ let hovering = false
1353
+ // :hover is the pointer's own position, which no event listener has to be
1354
+ // kept in sync with. A DOM that cannot answer it holds nothing.
1355
+ try { hovering = Boolean(document.querySelector('#session-grid .term:hover, #session-drawer:hover')) } catch { hovering = false }
1356
+ return holdsOrder({
1357
+ expanded: Boolean(drawer.id),
1358
+ selecting: selectionInside(grid) || selectionInside(open),
1359
+ hovering,
1360
+ focusInside: has(grid) || has(open),
1361
+ divergedForMs: orderDivergedAt ? Date.now() - orderDivergedAt : 0,
1362
+ })
591
1363
  }
592
1364
 
593
1365
  // Absolute priority, not a rotation: the saved list decides, minus the agent
594
1366
  // already running here, so an agent placed last stays last.
595
- function agentsAfter(current, order) {
596
- return order.filter((agent) => agent !== current)
1367
+ // The rungs below the one a terminal is on: what it would try next, in order.
1368
+ function rungsAfter(current, ladder) {
1369
+ return ladder.filter((r) => !(r.agent === current.agent && (r.account || 'default') === (current.account || 'default') && (r.model || null) === (current.model || null)))
1370
+ }
1371
+
1372
+ // B.6 `Back to fable`. Two conditions, both of which must be KNOWN true from
1373
+ // data this page already has: the row is running a model below the top rung
1374
+ // of its own login's ladder, and that top rung is open. "Open" means no
1375
+ // active wall on the model, no wall on the login, and either no bucket for it
1376
+ // at all or a bucket under 100. A login this board has no record for is not
1377
+ // "open", it is unknown, and an unknown destination gets no button: the whole
1378
+ // point of the control is that it will work when pressed.
1379
+ function topRungFor(s) {
1380
+ const ladder = Array.isArray(s && s.handoff_ladder) ? s.handoff_ladder : []
1381
+ return ladder.find((r) => r.agent === s.agent && (r.account || 'default') === (s.account || 'default')) || null
1382
+ }
1383
+ function climbTarget(s, accounts) {
1384
+ if (!s || !s.active || !s.model || s.hidden) return null
1385
+ const top = topRungFor(s)
1386
+ if (!top || !top.model || top.model === s.model) return null
1387
+ const names = MODEL_ALIASES[s.agent] || []
1388
+ const best = names.indexOf(top.model)
1389
+ const here = names.indexOf(s.model)
1390
+ if (best < 0 || here < 0 || here <= best) return null
1391
+ const acct = (accounts || []).find((x) => x.agent === s.agent && (x.account || 'default') === (s.account || 'default'))
1392
+ if (!acct || acctState(acct) === 'walled' || wallFor(acct, top.model)) return null
1393
+ const b = modelBucket(acct, top.model)
1394
+ if (b && b.percent >= 100) return null
1395
+ return top
1396
+ }
1397
+
1398
+ // B.5: the one sentence beside the checkbox, and the one that replaces it on
1399
+ // a login whose credits are off, where there is nothing to spend and so no
1400
+ // decision to offer. A dead control is worse than none.
1401
+ const MAY_SPEND_SENTENCE = 'A rung that spends usage credits or metered balance may be taken by an automatic hand-off.'
1402
+ const NO_CREDITS_SENTENCE = 'Usage credits are off, so there is nothing to spend through the wall.'
1403
+ // B.7, printed under the choice because it is not one: killing a working
1404
+ // agent to save budget loses the turn.
1405
+ const CLIMB_RULE = 'Leg never interrupts a running turn to climb.'
1406
+ const CLIMB_WORDS = {
1407
+ 'next-handoff': 'Leg climbs back to the top rung at the next hand-off.',
1408
+ never: 'Stay on the lower rung until you press Back to fable.',
1409
+ }
1410
+ function creditsOff(v) {
1411
+ const a = ((v && v.accounts) || []).find((x) => x.agent === 'claude' && (x.account || 'default') === 'default')
1412
+ return Boolean(a && a.extra_usage && a.extra_usage.enabled === false)
597
1413
  }
598
1414
 
599
1415
  function renderDefaultOrder(v) {
@@ -601,19 +1417,93 @@
601
1417
  if (!field) return
602
1418
  field.hidden = !v.preferences
603
1419
  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)
1420
+ const fresh = !defaultEditor.ladder || (!defaultEditor.dirty && !defaultEditor.saving)
1421
+ if (fresh) {
1422
+ defaultEditor.ladder = (v.preferences.handoff_ladder || []).map((r) => ({ ...r }))
1423
+ defaultEditor.climb_back = v.preferences.climb_back || 'next-handoff'
1424
+ defaultEditor.may_spend = Boolean(v.preferences.may_spend)
1425
+ defaultEditor.reserve = { ...(v.preferences.reserve || {}) }
1426
+ }
1427
+ const touch = (next) => {
1428
+ if (next) defaultEditor.ladder = next
610
1429
  defaultEditor.dirty = true
611
1430
  defaultEditor.status = ''
612
1431
  renderDefaultOrder(view)
613
- }, 'default'))
1432
+ }
1433
+ const focus = takeFocus(document)
1434
+
1435
+ const list = document.getElementById('default-order-list')
1436
+ list.textContent = ''
1437
+ list.appendChild(ladderRows(defaultEditor.ladder, touch, 'default'))
1438
+ list.appendChild(addRungRow(defaultEditor.ladder, touch, 'default', (msg) => {
1439
+ defaultEditor.status = msg
1440
+ defaultEditor.statusClass = 'bad'
1441
+ renderDefaultOrder(view)
1442
+ }))
1443
+
1444
+ // may_spend
1445
+ const spend = document.getElementById('ladder-spend')
1446
+ if (spend) {
1447
+ spend.textContent = ''
1448
+ 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' })
1449
+ if (defaultEditor.may_spend) box.setAttribute('checked', 'checked')
1450
+ box.checked = defaultEditor.may_spend
1451
+ box.addEventListener('change', () => { defaultEditor.may_spend = Boolean(box.checked); touch(null) })
1452
+ spend.appendChild(el('label', { class: 'notify-toggle', for: 'ladder-may-spend' }, [box, ' Let an automatic hand-off spend']))
1453
+ spend.appendChild(el('p', { class: 'field-help' }, [MAY_SPEND_SENTENCE]))
1454
+ // B.5: with credits off and `can_toggle` false there is nothing to turn
1455
+ // on from here, so this states the fact and offers no button. A control
1456
+ // that cannot do anything is worse than none.
1457
+ if (creditsOff(v)) spend.appendChild(el('p', { class: 'field-help' }, [NO_CREDITS_SENTENCE]))
1458
+ }
1459
+
1460
+ // climb back
1461
+ const climb = document.getElementById('ladder-climb')
1462
+ if (climb) {
1463
+ climb.textContent = ''
1464
+ climb.appendChild(el('legend', {}, ['Climbing back']))
1465
+ for (const [value, sentence] of Object.entries(CLIMB_WORDS)) {
1466
+ const id = `ladder-climb-${value}`
1467
+ const radio = el('input', { type: 'radio', name: 'ladder-climb-back', id, value, 'aria-label': sentence, 'data-focus-key': `ladder:default:climb:${value}` })
1468
+ if (defaultEditor.climb_back === value) radio.setAttribute('checked', 'checked')
1469
+ radio.checked = defaultEditor.climb_back === value
1470
+ radio.addEventListener('change', () => { defaultEditor.climb_back = value; touch(null) })
1471
+ climb.appendChild(el('label', { class: 'notify-toggle', for: id }, [radio, ` ${sentence}`]))
1472
+ }
1473
+ climb.appendChild(el('p', { class: 'field-help' }, [CLIMB_RULE]))
1474
+ }
1475
+
1476
+ // reserve, one number per login
1477
+ const reserve = document.getElementById('ladder-reserve')
1478
+ if (reserve) {
1479
+ reserve.textContent = ''
1480
+ const logins = []
1481
+ for (const r of defaultEditor.ladder) if (!logins.includes(r.agent)) logins.push(r.agent)
1482
+ for (const agent of logins) {
1483
+ const id = `ladder-reserve-${agent}`
1484
+ const n = el('input', {
1485
+ type: 'number', min: '0', max: '100', class: 'ladder-pct', id,
1486
+ value: Number.isFinite(Number(defaultEditor.reserve[agent])) ? String(defaultEditor.reserve[agent]) : '0',
1487
+ 'aria-label': `Keep this percent of ${agent} for your own terminals`,
1488
+ 'data-focus-key': `ladder:default:reserve:${agent}`,
1489
+ })
1490
+ n.addEventListener('change', () => {
1491
+ const pct = Math.max(0, Math.min(100, Math.round(Number(n.value) || 0)))
1492
+ if (pct > 0) defaultEditor.reserve[agent] = pct
1493
+ else delete defaultEditor.reserve[agent]
1494
+ touch(null)
1495
+ })
1496
+ reserve.appendChild(el('div', { class: 'ladder-row' }, [
1497
+ el('label', { for: id }, [`Keep`]), n,
1498
+ el('span', { class: 'ladder-cost' }, [`% of ${agent} for your own terminals`]),
1499
+ ]))
1500
+ }
1501
+ 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.']))
1502
+ }
1503
+
614
1504
  const save = document.getElementById('default-order-save')
615
1505
  save.disabled = defaultEditor.saving || !defaultEditor.dirty
616
- save.textContent = defaultEditor.saving ? 'Saving…' : 'Save default'
1506
+ save.textContent = defaultEditor.saving ? 'Saving…' : 'Save ladder'
617
1507
  const status = document.getElementById('default-order-status')
618
1508
  status.textContent = defaultEditor.status
619
1509
  status.className = `field-status ${defaultEditor.statusClass}`
@@ -625,13 +1515,22 @@
625
1515
  defaultEditor.status = ''
626
1516
  renderDefaultOrder(view)
627
1517
  try {
628
- const data = await api('/api/settings', { method: 'PATCH', body: { handoff_order: defaultEditor.order } })
1518
+ const data = await api('/api/settings', {
1519
+ method: 'PATCH',
1520
+ body: {
1521
+ handoff_ladder: defaultEditor.ladder,
1522
+ climb_back: defaultEditor.climb_back,
1523
+ may_spend: defaultEditor.may_spend,
1524
+ reserve: defaultEditor.reserve,
1525
+ },
1526
+ })
629
1527
  if (view) view.preferences = data.preferences
630
- defaultEditor.order = [...data.preferences.handoff_order]
1528
+ defaultEditor.ladder = (data.preferences.handoff_ladder || []).map((r) => ({ ...r }))
631
1529
  defaultEditor.dirty = false
632
- defaultEditor.status = 'Saved for new terminals.'
1530
+ defaultEditor.status = `Saved for new terminals. Order off the ladder: ${(data.preferences.handoff_order || []).join(', ')}.`
633
1531
  defaultEditor.statusClass = 'ok'
634
1532
  } catch (err) {
1533
+ // the server's own sentence, verbatim: it is the one that names the rung
635
1534
  defaultEditor.status = err.message
636
1535
  defaultEditor.statusClass = 'bad'
637
1536
  } finally {
@@ -661,8 +1560,9 @@
661
1560
  sysMessage('removed the Leg record; the worktree and the branch are kept', 'ok')
662
1561
  } else {
663
1562
  await api(`/api/sessions/${encodeURIComponent(id)}/${action}`, { method: 'POST', body })
664
- if (action === 'handoff') actionNotes.set(id, { at: Date.now(), tone: 'warn', text: body && body.agent ? `hand-off to ${optionLabel(body)} requested; this terminal switches agents in a few seconds` : 'hand-off requested; this terminal switches agents in a few seconds' })
1563
+ 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
1564
  else if (action === 'end') actionNotes.set(id, { at: Date.now(), tone: 'warn', text: 'end requested; the agent stops after its current turn' })
1565
+ 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
1566
  else if (action === 'land/fix') actionNotes.set(id, { at: Date.now(), tone: 'ok', text: 'applied fix' })
667
1567
  }
668
1568
  refresh()
@@ -680,13 +1580,15 @@
680
1580
  // `now: codex · then · claude`, a five-item list with two items called
681
1581
  // "then". The agent names keep their identity colour inside it.
682
1582
  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)])])
1583
+ const chain = el('span', { class: 'chip' }, ['now: ', el('span', { class: `chip-id-${idOf(s.agent)}` }, [rungLabel(s)])])
684
1584
  // 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)]))
1585
+ for (const next of s.chain || []) chain.append(document.createTextNode(', then '), el('span', { class: `chip-id-${idOf(next.agent)}` }, [rungLabel(next)]))
686
1586
  sequence.appendChild(chain)
687
1587
  wrap.appendChild(sequence)
688
- const preferred = optionLabel(s.preferred_next)
689
- const eligible = optionLabel(s.eligible_next)
1588
+ // the rung, not just the login: `claude / opus` and `claude / sonnet` are
1589
+ // two destinations and the sequence above already names both
1590
+ const preferred = s.preferred_next ? rungLabel(s.preferred_next) : 'none'
1591
+ const eligible = s.eligible_next ? rungLabel(s.eligible_next) : 'none'
690
1592
  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
1593
  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
1594
  else if (eligible !== preferred) wrap.appendChild(el('p', { class: 'sentence tone-muted' }, [`preferred: ${preferred}, first eligible now: ${eligible}`]))
@@ -702,17 +1604,39 @@
702
1604
  const pick = el('div', { class: 'form-row' })
703
1605
  const selectId = `handoff-to-${s.session_id}`
704
1606
  pick.appendChild(el('label', { for: selectId }, ['Hand off now to']))
705
- const select = el('select', { id: selectId })
1607
+ const select = el('select', { id: selectId, 'aria-label': 'Hand off now to' })
706
1608
  select.appendChild(el('option', { value: '' }, ['the next option in the order']))
707
1609
  targets.forEach((t, i) => {
708
- const note = t.available ? '' : ` — ${t.reason}${Number.isFinite(t.resets_at) ? `, back ${until(t.resets_at)}` : ''}`
709
1610
  // the index is the value: an account name is not ours to parse
710
- select.appendChild(el('option', { value: String(i), disabled: t.available ? null : 'disabled' }, [optionLabel(t) + note]))
1611
+ select.appendChild(el('option', { value: String(i), disabled: t.available ? null : 'disabled' }, [handoffOptionText(t)]))
1612
+ })
1613
+ // The drawer rebuilds itself every 3 seconds, and a destination chosen
1614
+ // four seconds ago was silently back to "the next option in the order"
1615
+ // while the confirm row still said the rung's name. The pick is held on
1616
+ // `drawer`, beside turnCap and expanded, and it is keyed by the RUNG, not
1617
+ // by the index: the poll can reorder the rows under it.
1618
+ select.value = pickIndex(targets, drawer.pick)
1619
+ if (drawer.pick && select.value === '') drawer.pick = null
1620
+ select.addEventListener('change', () => {
1621
+ const at = select.value === '' ? null : targets[Number(select.value)]
1622
+ drawer.pick = at ? pickKey(at) : null
711
1623
  })
712
1624
  const go = el('button', { type: 'button', class: 'btn btn-secondary' }, ['Hand off'])
713
1625
  go.addEventListener('click', () => {
714
1626
  const t = select.value === '' ? null : targets[Number(select.value)]
715
- act(s.session_id, 'handoff', go, t ? { agent: t.agent, account: t.account } : null)
1627
+ // the confirm row names the destination and says whether the
1628
+ // conversation survives, because those are the two things that differ
1629
+ // between one rung and the next and neither is guessable from the row
1630
+ pendingConfirm = {
1631
+ id: s.session_id,
1632
+ question: t
1633
+ ? `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.'}`
1634
+ : 'Hands off to the first open rung of this terminal\'s ladder. The current turn stops.',
1635
+ verb: 'Hand off',
1636
+ action: 'handoff',
1637
+ body: t ? { agent: t.agent, account: t.account, ...(t.model ? { model: t.model } : {}) } : null,
1638
+ }
1639
+ renderSessions(view)
716
1640
  })
717
1641
  pick.appendChild(el('div', { class: 'chain-rail' }, [select, go]))
718
1642
  if (!targets.some((t) => t.available)) {
@@ -724,38 +1648,43 @@
724
1648
  const editableNow = ['starting', 'running', 'warning', 'limit', 'waiting'].includes(s.status)
725
1649
  if (!s.hidden && editableNow) {
726
1650
  let state = sessionEditors.get(s.session_id)
727
- const sourceOrder = s.can_edit_handoff_order ? s.handoff_order : (view?.preferences?.handoff_order ?? s.handoff_order)
1651
+ // this terminal's own ladder, else the machine default it would take on
1652
+ // its next launch. A terminal that cannot be edited in place still shows
1653
+ // the list it is about to inherit, so the save below means something.
1654
+ const source = (s.can_edit_handoff_order ? s.handoff_ladder : (view?.preferences?.handoff_ladder ?? s.handoff_ladder)) || []
1655
+ const copy = () => source.map((r) => ({ ...r }))
728
1656
  if (!state) {
729
- state = { open: false, order: [...sourceOrder], dirty: false, saving: false, status: '', statusClass: '' }
1657
+ state = { open: false, ladder: copy(), dirty: false, saving: false, status: '', statusClass: '' }
730
1658
  sessionEditors.set(s.session_id, state)
731
- } else if (!state.dirty && !state.saving) state.order = [...sourceOrder]
732
- 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'])
1659
+ } else if (!state.dirty && !state.saving) state.ladder = copy()
1660
+ 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'])
733
1661
  change.addEventListener('click', () => { state.open = !state.open; renderDrawer() })
734
1662
  wrap.appendChild(change)
735
1663
  if (state.open) {
736
1664
  const editor = el('div', { class: 'detail-section' })
737
1665
  editor.appendChild(el('p', { class: 'blocker' }, [s.can_edit_handoff_order
738
- ? '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.'
739
- : 'This terminal started before order changes were available. Save this order for the next terminal, then restart when ready.']))
740
- editor.appendChild(orderRows(state.order, (index, delta) => {
741
- state.order = moveOrder(state.order, index, delta)
742
- state.dirty = true
743
- state.status = ''
1666
+ ? '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.'
1667
+ : 'This terminal started before ladder changes were available. Save this ladder as the default, then restart when ready.']))
1668
+ const touch = (next) => { state.ladder = next; state.dirty = true; state.status = ''; renderDrawer() }
1669
+ editor.appendChild(ladderRows(state.ladder, touch, s.session_id))
1670
+ editor.appendChild(addRungRow(state.ladder, touch, s.session_id, (msg) => {
1671
+ state.status = msg
1672
+ state.statusClass = 'bad'
744
1673
  renderDrawer()
745
- }, s.session_id))
746
- editor.appendChild(el('p', { class: 'blocker' }, [`Draft priority after ${s.agent}: ${agentsAfter(s.agent, state.order).join(', then ')}`]))
1674
+ }))
1675
+ editor.appendChild(el('p', { class: 'blocker' }, [`Draft ladder after ${rungLabel(s)}: ${rungsAfter(s, state.ladder).map(rungLabel).join(', then ') || 'nothing left'}`]))
747
1676
  const status = el('span', { class: `field-status ${state.statusClass}`, 'aria-live': 'polite' }, [state.status])
748
1677
  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'])
749
1678
  save.addEventListener('click', async () => {
750
1679
  state.saving = true; state.status = ''; renderDrawer()
751
1680
  try {
752
1681
  if (s.can_edit_handoff_order) {
753
- await api(`/api/sessions/${encodeURIComponent(s.session_id)}/handoff-order`, { method: 'POST', body: { handoff_order: state.order } })
1682
+ await api(`/api/sessions/${encodeURIComponent(s.session_id)}/handoff-order`, { method: 'POST', body: { handoff_ladder: state.ladder } })
754
1683
  state.status = 'Saved for this terminal.'
755
1684
  } else {
756
- const data = await api('/api/settings', { method: 'PATCH', body: { handoff_order: state.order } })
1685
+ const data = await api('/api/settings', { method: 'PATCH', body: { handoff_ladder: state.ladder } })
757
1686
  if (view) view.preferences = data.preferences
758
- defaultEditor.order = [...data.preferences.handoff_order]
1687
+ defaultEditor.ladder = (data.preferences.handoff_ladder || []).map((r) => ({ ...r }))
759
1688
  defaultEditor.dirty = false
760
1689
  state.status = 'Saved as the default. Restart this terminal when ready.'
761
1690
  }
@@ -763,6 +1692,7 @@
763
1692
  state.statusClass = 'ok'
764
1693
  await refresh()
765
1694
  } catch (err) {
1695
+ // the 409 or the 400 in the server's own words
766
1696
  state.status = err.message
767
1697
  state.statusClass = 'bad'
768
1698
  } finally {
@@ -799,6 +1729,13 @@
799
1729
  statusMark(s.status, s.session_id, urgent ? 'waiting on you' : null),
800
1730
  el('span', { class: 'term-where', title: s.cwd || null }, [`${s.repo_name || s.cwd || 'unknown repo'}${branch ? ` on ${branch}` : ''}`]),
801
1731
  ])
1732
+ // A.4 rows 7 to 9, in reading order and on the SAME line: what changed,
1733
+ // which model actually answered, and whether it has gone quiet. The model
1734
+ // token is the agent and the model it resolved to, never a default.
1735
+ for (const t of registerTokens(s)) {
1736
+ if (t.kind === 'model') register.appendChild(el('span', { class: `term-model chip-id-${idOf(s.agent)}` }, [t.text]))
1737
+ else register.appendChild(el('span', { class: `term-${t.kind}` }, [t.text]))
1738
+ }
802
1739
  if (s.account !== 'default') register.appendChild(el('span', { class: 'chip' }, [s.account]))
803
1740
  if (shared() && s.owner) register.appendChild(el('span', { class: 'chip' }, [isMine(s) ? `${s.owner}, you` : s.owner]))
804
1741
  if (s.lineage && s.lineage.from) register.appendChild(el('span', { class: 'chip' }, [`from ${s.lineage.from}`]))
@@ -839,6 +1776,7 @@
839
1776
  if (open) for (const n of rest) body.appendChild(el('p', { class: `sentence tone-${n.tone}` }, [n.text]))
840
1777
  }
841
1778
  const touched = s.files || []
1779
+ let files = null
842
1780
  if (!s.hidden && touched.length) {
843
1781
  // comma-separated text, not chips: six file names are a sentence, and a
844
1782
  // file that is also in an overlap is named in that sentence anyway
@@ -849,7 +1787,37 @@
849
1787
  line.appendChild(el('span', { class: `file${overlapFiles.has(f) ? ' is-overlap' : ''}`, title: f }, [fileLabel(f)]))
850
1788
  })
851
1789
  if (touched.length > 6) line.appendChild(document.createTextNode(`, and ${touched.length - 6} more`))
852
- body.appendChild(line)
1790
+ files = line
1791
+ }
1792
+ // A.4 row 12, at the right of the files line: the bucket that will stop
1793
+ // THIS terminal, which is per model and so is a fact about the row. The
1794
+ // region head carries the share clause that stops anyone adding three
1795
+ // rows' figures together (A.7).
1796
+ const phrase = s.hidden ? null : capacityPhrase(s)
1797
+ // B.6: the climb is a link on the capacity line, not a fifth button in the
1798
+ // 2x2 grid. It belongs beside the figure that explains why the row was
1799
+ // dropped a rung in the first place, and the grid is the shipped shape.
1800
+ const top = climbTarget(s, view && view.accounts)
1801
+ let climb = null
1802
+ if (top) {
1803
+ const back = (s.handoff_targets || []).find((t) => t.agent === top.agent && t.account === top.account && t.model === top.model)
1804
+ climb = el('button', { type: 'button', class: 'btn btn-text term-climb', 'data-focus-key': `climb:${s.session_id}` }, [`Back to ${top.model}`])
1805
+ climb.addEventListener('click', () => {
1806
+ pendingConfirm = {
1807
+ id: s.session_id,
1808
+ question: `Hands off now. The current turn stops and ${top.model} continues from ${back && back.keeps_conversation ? 'the conversation' : 'the bundle'}.`,
1809
+ verb: `Back to ${top.model}`,
1810
+ action: 'handoff',
1811
+ body: { agent: top.agent, account: top.account, model: top.model },
1812
+ }
1813
+ renderSessions(view)
1814
+ })
1815
+ }
1816
+ if (files || phrase || climb) {
1817
+ body.appendChild(el('div', { class: 'term-meta' }, [
1818
+ files,
1819
+ phrase || climb ? el('span', { class: 'term-capacity' }, [phrase, climb]) : null,
1820
+ ]))
853
1821
  }
854
1822
  row.appendChild(body)
855
1823
 
@@ -871,11 +1839,20 @@
871
1839
  // TypeError going only to the console.
872
1840
  const pending = pendingConfirm
873
1841
  term.appendChild(row)
874
- term.appendChild(confirmRow(pending.question, pending.verb, (btn) => act(s.session_id, pending.action, btn)))
1842
+ const confirm = confirmRow(pending.question, pending.verb, (btn) => act(s.session_id, pending.action, btn, pending.body ?? null))
1843
+ // a second verb on the same row, never a fifth grid button: End grows
1844
+ // 'End, and keep going as a card' (spec C.4), the moment being 'I have
1845
+ // to leave, keep going'. It sits before Cancel and takes the snapshot too.
1846
+ if (pending.alt) {
1847
+ const more = el('button', { type: 'button', class: 'btn btn-secondary', title: pending.alt.title || null }, [pending.alt.verb])
1848
+ more.addEventListener('click', () => { pendingConfirm = null; act(s.session_id, pending.alt.action, more, null) })
1849
+ confirm.insertBefore(more, confirm.lastChild)
1850
+ }
1851
+ term.appendChild(confirm)
875
1852
  return term
876
1853
  }
877
1854
  const actions = el('div', { class: 'term-actions' })
878
- const ask = (question, verb, action) => () => { pendingConfirm = { id: s.session_id, question, verb, action }; renderSessions(view) }
1855
+ const ask = (question, verb, action, alt = null) => () => { pendingConfirm = { id: s.session_id, question, verb, action, alt }; renderSessions(view) }
879
1856
  if (s.hidden) {
880
1857
  if (s.active) {
881
1858
  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'])
@@ -948,7 +1925,10 @@
948
1925
  }
949
1926
  if (s.active) {
950
1927
  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'])
951
- h.addEventListener('click', () => act(s.session_id, 'handoff', h))
1928
+ // the same confirm row End and the picker already use. This stops the
1929
+ // current turn of a working agent, and the `h` key presses this button:
1930
+ // an action that costs a turn asks first, whichever hand pressed it.
1931
+ h.addEventListener('click', ask('Hands off to the first open rung of this terminal\'s ladder. The current turn stops.', 'Hand off', 'handoff'))
952
1932
  actions.appendChild(h)
953
1933
  }
954
1934
  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'])
@@ -956,7 +1936,11 @@
956
1936
  actions.appendChild(details)
957
1937
  if (s.active) {
958
1938
  const e = el('button', { type: 'button', class: 'btn btn-danger', 'data-focus-key': `end:${s.session_id}` }, ['End'])
959
- e.addEventListener('click', ask('End this terminal? The agent stops and the bundle is kept.', 'End', 'end'))
1939
+ // the second verb only where a card can continue: a terminal outside a
1940
+ // git repository has no branch for a card to work on (the route says so
1941
+ // with a 409, and a control that can only fail is not offered)
1942
+ e.addEventListener('click', ask('End this terminal? The agent stops and the bundle is kept.', 'End', 'end',
1943
+ 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))
960
1944
  actions.appendChild(e)
961
1945
  } else {
962
1946
  const r = el('button', { type: 'button', class: 'btn btn-danger', 'data-focus-key': `remove:${s.session_id}` }, ['Remove'])
@@ -1013,9 +1997,15 @@
1013
1997
  // The messages, the diffs and the timeline are one extra fetch per open
1014
1998
  // terminal, so nothing here is requested until the region is open, and the
1015
1999
  // poll stops when it is paused or the tab is in the background.
1016
- const drawer = { id: null, paused: false, timer: null, detail: null, error: '', expanded: new Set(), diffs: new Map(), turnCap: 8, openedBy: 'prompt' }
2000
+ const drawer = { id: null, paused: false, timer: null, detail: null, error: '', expanded: new Set(), diffs: new Map(), turnCap: 8, openedBy: 'prompt', pick: null }
1017
2001
 
1018
2002
  function drawerSession() { return view && view.sessions ? view.sessions.find((s) => s.session_id === drawer.id) : null }
2003
+ // the one control on this page whose value is a decision in flight
2004
+ function pickHasFocus() {
2005
+ const node = document.activeElement
2006
+ const id = node && typeof node.id === 'string' ? node.id : ''
2007
+ return id.startsWith('handoff-to-')
2008
+ }
1019
2009
  // The region is MOVED under the panel it expands, so the reference is cached:
1020
2010
  // once it has been moved into the sessions list, getElementById would stop
1021
2011
  // finding it the moment that list is rebuilt.
@@ -1032,6 +2022,8 @@
1032
2022
  drawer.error = ''
1033
2023
  drawer.paused = false
1034
2024
  drawer.turnCap = 8
2025
+ // a destination chosen on one terminal is not a destination on the next
2026
+ drawer.pick = null
1035
2027
  drawer.expanded.clear()
1036
2028
  drawer.diffs.clear()
1037
2029
  const region = detailRegion()
@@ -1044,7 +2036,11 @@
1044
2036
  renderDrawer()
1045
2037
  loadDrawer()
1046
2038
  if (drawer.timer) clearInterval(drawer.timer)
1047
- drawer.timer = setInterval(() => { if (!drawer.paused && !document.hidden) loadDrawer() }, 3000)
2039
+ // and it stands down while the reader is inside the destination select:
2040
+ // rebuilding a <select> under an open option list closes it, so a list that
2041
+ // takes longer than three seconds to read could not be read at all. The
2042
+ // pick itself survives the rebuild by rung (pickIndex above).
2043
+ drawer.timer = setInterval(() => { if (!drawer.paused && !document.hidden && !pickHasFocus()) loadDrawer({ timed: true }) }, 3000)
1048
2044
  document.getElementById('session-drawer-close')?.focus()
1049
2045
  }
1050
2046
 
@@ -1062,7 +2058,7 @@
1062
2058
  if (id) putFocus(document, { key: `${from === 'details' ? 'details' : 'prompt'}:${id}` })
1063
2059
  }
1064
2060
 
1065
- async function loadDrawer() {
2061
+ async function loadDrawer({ timed = false } = {}) {
1066
2062
  if (!drawer.id) return
1067
2063
  const id = drawer.id
1068
2064
  try {
@@ -1074,7 +2070,7 @@
1074
2070
  if (drawer.id !== id) return
1075
2071
  drawer.error = err.message
1076
2072
  }
1077
- renderDrawer()
2073
+ renderDrawer({ timed })
1078
2074
  }
1079
2075
 
1080
2076
  function paintDiff(pre, d) {
@@ -1171,6 +2167,41 @@
1171
2167
  }
1172
2168
  }
1173
2169
 
2170
+ // The page's own scroll anchor. A box the reader is reading keeps its offset
2171
+ // in the VIEWPORT, not its offset in the document: everything above it is
2172
+ // rebuilt on a timer and may change height or order, and the browser's native
2173
+ // scroll anchoring does not survive a subtree being wiped and refilled. These
2174
+ // two are the page-level twin of takeScroll/putScroll, and they only ever run
2175
+ // for a region that is open and on screen.
2176
+ function anchorTop(box) {
2177
+ if (!box || box.hidden || typeof box.getBoundingClientRect !== 'function') return null
2178
+ if (typeof window.scrollBy !== 'function') return null
2179
+ return box.getBoundingClientRect().top
2180
+ }
2181
+ function holdAnchor(box, was) {
2182
+ if (was === null || was === undefined) return
2183
+ const now = anchorTop(box)
2184
+ if (now === null) return
2185
+ const moved = now - was
2186
+ // sub-pixel reflow is not a jump; a row that crossed the needs-you
2187
+ // partition above the reader is 200px of one
2188
+ if (Math.abs(moved) < 1) return
2189
+ window.scrollBy(0, moved)
2190
+ }
2191
+ // ...and only for a region that already HAD a position the reader gave it.
2192
+ // #session-drawer lives at the end of <body> until renderSessions moves it
2193
+ // under the panel it expands, and it is parked back there every time it
2194
+ // closes. On the render that opens an expansion, anchorTop therefore reads a
2195
+ // viewport top several hundred pixels below where the region is about to
2196
+ // land, and holdAnchor "restores" it by yanking the whole window up: a reader
2197
+ // scrolled to 400 was thrown to 0 and then dragged back to 803 by the focus
2198
+ // on Close, 403px of movement on the most common click on the board. A region
2199
+ // that is not in the list yet has nothing to put back.
2200
+ function anchorFor(region, grid) {
2201
+ if (!region || !grid || region.parentNode !== grid) return null
2202
+ return anchorTop(region)
2203
+ }
2204
+
1174
2205
  // Whether .baton/RESUME.md still describes the repository a reader would find.
1175
2206
  // The server recomputes this from git on every poll, so the line is a verdict
1176
2207
  // about right now, not a timestamp the file remembered about itself.
@@ -1251,6 +2282,53 @@
1251
2282
  return box
1252
2283
  }
1253
2284
 
2285
+ // ---- the timeline ---------------------------------------------------------
2286
+ // A usage poll that says the same sentence every ten seconds is one fact, not
2287
+ // forty lines, and forty lines of it push everything that actually happened
2288
+ // off the top of a region the reader is trying to read. A run of events of
2289
+ // the same type whose summary repeats the previous one word for word, each
2290
+ // within a minute of the one before it, becomes one line carrying ×N. Only a
2291
+ // RUN collapses: two identical lines with something else between them are two
2292
+ // things that happened and are printed as two.
2293
+ const REPEAT_MS = 60000
2294
+ function collapseEvents(events) {
2295
+ const out = []
2296
+ for (const e of events || []) {
2297
+ const last = out[out.length - 1]
2298
+ const gap = last ? Date.parse(e.ts) - Date.parse(last.last_ts) : NaN
2299
+ if (last && last.type === e.type && (last.summary || '') === (e.summary || '') && gap >= 0 && gap < REPEAT_MS) {
2300
+ last.count += 1
2301
+ last.last_ts = e.ts
2302
+ continue
2303
+ }
2304
+ out.push({ ...e, count: 1, last_ts: e.ts })
2305
+ }
2306
+ return out
2307
+ }
2308
+
2309
+ // The timeline is newest first, so a new event arrives ABOVE everything the
2310
+ // reader is looking at and carrying its scrollTop across the rebuild moves
2311
+ // them down a line every time one lands. The line at the top edge of the box
2312
+ // is the anchor instead: it stays where it was and the new one appears above.
2313
+ function takeTimelineAnchor(box) {
2314
+ const node = box && typeof box.querySelector === 'function' ? box.querySelector('[data-scroll-key="timeline"]') : null
2315
+ if (!node || !node.scrollTop) return null
2316
+ for (const item of node.querySelectorAll('[data-event-key]')) {
2317
+ if (item.offsetTop >= node.scrollTop) return { key: item.getAttribute('data-event-key'), from: item.offsetTop - node.scrollTop }
2318
+ }
2319
+ return null
2320
+ }
2321
+ function putTimelineAnchor(box, at) {
2322
+ if (!at) return
2323
+ const node = box && typeof box.querySelector === 'function' ? box.querySelector('[data-scroll-key="timeline"]') : null
2324
+ if (!node) return
2325
+ const key = at.key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
2326
+ let item = null
2327
+ try { item = node.querySelector(`[data-event-key="${key}"]`) } catch { return }
2328
+ if (!item) return
2329
+ node.scrollTop = Math.max(0, item.offsetTop - at.from)
2330
+ }
2331
+
1254
2332
  function section(title, note, body) {
1255
2333
  const s = el('section', { class: 'detail-section' }, [
1256
2334
  el('h3', { class: 'detail-heading' }, [title, note ? el('span', { class: 'detail-sub' }, [note]) : null]),
@@ -1273,10 +2351,42 @@
1273
2351
  return line
1274
2352
  }
1275
2353
 
1276
- function renderDrawer() {
2354
+ function renderDrawer({ timed = false } = {}) {
1277
2355
  const box = document.getElementById('session-drawer-content')
1278
2356
  if (!box) return
2357
+ // A rebuild destroys any selection that spans it, and this one runs every
2358
+ // three seconds: a reader dragging across a path, a session id or a
2359
+ // timeline sentence lost it before they reached the end of the word, every
2360
+ // time. The region stands down until they let go, the same contract the
2361
+ // grid already keeps for its own timed re-sort, and one click anywhere
2362
+ // resumes it. A confirm row is a question the reader is answering right
2363
+ // now: rebuilding the picker under it drops the click that answers it.
2364
+ // `timed` is the poll's redraw and nobody else's: a redraw the reader asked
2365
+ // for (show 40 more, Resume updates, a rung moved) is never a surprise and
2366
+ // is the default, so a new caller cannot silently inherit the stand-down.
2367
+ //
2368
+ // 2026-09-18, second pass: "until they let go" was not a bound. A
2369
+ // double-click leaves an uncollapsed selection behind, which is exactly the
2370
+ // gesture the paragraph above is written for, and a reader who picks a path
2371
+ // out of the region and keeps reading never lets go of anything. Twelve
2372
+ // turns finished and not one of them was drawn in sixty seconds, with
2373
+ // nothing on screen saying the region was stale. The stand-down is capped:
2374
+ // twenty seconds is longer than any drag and shorter than a reader's
2375
+ // patience for "what is it doing now". That redraw does cost the selection,
2376
+ // which is the price of being told the truth about the terminal.
2377
+ //
2378
+ // The confirm row half is narrowed to THIS terminal's question. A confirm
2379
+ // row on an unrelated row used to freeze this region too.
2380
+ const selecting = selectionInside(box)
2381
+ drawerHeldAt = selecting ? (drawerHeldAt || Date.now()) : 0
2382
+ if (drawerStandsDown({
2383
+ timed,
2384
+ selecting,
2385
+ confirming: Boolean(pendingConfirm && pendingConfirm.id === drawer.id),
2386
+ heldForMs: drawerHeldAt ? Date.now() - drawerHeldAt : 0,
2387
+ })) return
1279
2388
  const inner = takeScroll(box)
2389
+ const onLine = takeTimelineAnchor(box)
1280
2390
  const focus = takeFocus(box)
1281
2391
  const s = drawerSession()
1282
2392
  const d = drawer.detail
@@ -1338,20 +2448,27 @@
1338
2448
 
1339
2449
  const timeline = el('div', { class: 'drawer-timeline', 'data-scroll-key': 'timeline' })
1340
2450
  const events = (d && d.events) || []
1341
- for (const e of events.slice(-40).reverse()) {
2451
+ const lines = collapseEvents(events)
2452
+ const shownLines = lines.slice(-40).reverse()
2453
+ for (const e of shownLines) {
1342
2454
  // clockAt, not a slice of the ISO string: that printed UTC, in 24-hour
1343
2455
  // with seconds, under a page that says `Times are local.` and beside a
1344
2456
  // header on the same panel printing the same instant as 11:04 PM. The
1345
2457
  // summary is a block, as board.js:754 builds the same row, or the kind
1346
2458
  // word and the sentence render glued: `lostrunner pid 999002 is gone`.
1347
- timeline.appendChild(el('div', { class: 'turn timeline-item' }, [
1348
- el('span', { class: 'mono turn-when' }, [clockAt(Date.parse(e.ts))]),
2459
+ // data-event-key is the anchor the timeline's own scroll is held by when
2460
+ // a new line arrives above the one the reader is reading.
2461
+ timeline.appendChild(el('div', { class: 'turn timeline-item', 'data-event-key': `${e.ts}|${e.type}` }, [
2462
+ el('span', { class: 'mono turn-when' }, [clockAt(Date.parse(e.last_ts || e.ts))]),
1349
2463
  el('span', { class: 'turn-role' }, [e.type]),
1350
- el('p', { class: 'timeline-summary' }, [e.summary || '']),
2464
+ el('p', { class: 'timeline-summary' }, [e.summary || '', e.count > 1 ? el('span', { class: 'timeline-count', title: `this line was recorded ${e.count} times in a row` }, [`×${e.count}`]) : null]),
1351
2465
  ]))
1352
2466
  }
1353
2467
  if (!events.length) timeline.appendChild(el('p', { class: 'sentence tone-muted' }, [d ? 'nothing recorded yet' : 'reading the timeline']))
1354
- const shownEvents = Math.min(events.length, 40)
2468
+ // G14: the cap names the volume, and the volume is EVENTS, not lines: a
2469
+ // collapsed line stands for every repeat it swallowed, so the two numbers
2470
+ // still add up against `events.length`.
2471
+ const shownEvents = shownLines.reduce((n, e) => n + (e.count || 1), 0)
1355
2472
  const timelineBody = el('div', { class: 'detail-section' }, [timeline])
1356
2473
  if (events.length > shownEvents) timelineBody.appendChild(capLine(shownEvents, events.length, 'events', null))
1357
2474
  box.appendChild(section('Timeline', 'this terminal, newest first', timelineBody))
@@ -1364,6 +2481,9 @@
1364
2481
  const harness = harnessSection(s, d)
1365
2482
  if (harness) box.appendChild(section('Harness', 'the working environment this leg was given', harness))
1366
2483
  putScroll(box, inner)
2484
+ // after putScroll, never instead of it: putScroll puts the box back where
2485
+ // it was and this corrects for the lines that arrived above the reader
2486
+ putTimelineAnchor(box, onLine)
1367
2487
  putFocus(box, focus)
1368
2488
  }
1369
2489
 
@@ -1384,7 +2504,22 @@
1384
2504
  const verdict = !list.length ? 'nothing is running'
1385
2505
  : waiting ? `${running} running, ${waiting} waiting on you`
1386
2506
  : `${running} running, nothing is waiting on you`
1387
- meta.textContent = `${verdict}${landed ? `, last landed ${clockAt(landed)}` : ''}`
2507
+ meta.textContent = `${verdict}${shareClause(list)}${landed ? `, last landed ${clockAt(landed)}` : ''}`
2508
+ }
2509
+
2510
+ // A.7: a fact true of every row is a property of the region and is said once,
2511
+ // here. Per-row usage is per MODEL, which is real; the reader who adds three
2512
+ // rows' figures together is stopped by this clause and by nothing else.
2513
+ function shareClause(list) {
2514
+ const groups = new Map()
2515
+ for (const s of list.filter((x) => x.active)) {
2516
+ const key = `${s.agent}/${s.account || 'default'}`
2517
+ groups.set(key, (groups.get(key) || 0) + 1)
2518
+ }
2519
+ const [key, n] = [...groups.entries()].sort((x, y) => y[1] - x[1])[0] || []
2520
+ if (!n || n < 2) return ''
2521
+ const label = key.endsWith('/default') ? key.slice(0, -'/default'.length) : key
2522
+ return `, ${n} share the ${label} login`
1388
2523
  }
1389
2524
 
1390
2525
  // A fact that is true of every terminal on the board is a property of the
@@ -1433,6 +2568,203 @@
1433
2568
  for (const p of document.querySelectorAll('#session-grid .blocker')) p.classList.toggle('is-hoisted', Boolean(shared))
1434
2569
  }
1435
2570
 
2571
+ // ---- A.4 row 18 and E: where a waiting terminal says so off-screen ------
2572
+ // The tab badge is always on and needs no permission: the title and the
2573
+ // favicon are the only surface a browser gives a tab nobody is looking at.
2574
+ // favicon.svg itself is never touched; the dot is a variant drawn inline.
2575
+ const FAVICON = '/favicon.svg'
2576
+ 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>')}`
2577
+ function titleBadge(n) {
2578
+ const title = n > 0 ? `(${n}) Leg` : 'Leg'
2579
+ if (document.title !== title) document.title = title
2580
+ const link = document.querySelector('link[rel~="icon"]')
2581
+ if (!link) return
2582
+ const href = n > 0 ? FAVICON_DOT : FAVICON
2583
+ if (link.getAttribute('href') !== href) link.setAttribute('href', href)
2584
+ }
2585
+
2586
+ // A browser toast fires on the TRANSITION into needing a human, never on
2587
+ // every render and never on first paint: the same rule the status mark's
2588
+ // annunciation keeps, for the same reason. A reload is not a new event.
2589
+ let announced = null
2590
+ // An OS toast is for a transition the reader was not there for. Firing one
2591
+ // over the window they are watching announces a state they just caused and
2592
+ // have already read on the row (pressing Land and getting a bounce is the
2593
+ // common one). The tab badge still updates either way: it costs nothing and
2594
+ // asks for nothing. Both halves are needed, because a visible tab in an
2595
+ // unfocused window is a reader who is somewhere else.
2596
+ function readerIsWatching() {
2597
+ return document.visibilityState === 'visible' && typeof document.hasFocus === 'function' && document.hasFocus()
2598
+ }
2599
+ function announceWaiting(rows, prefs) {
2600
+ const ids = new Set(rows.map((s) => s.session_id))
2601
+ if (announced && !readerIsWatching() && prefs && prefs.notify_board && typeof Notification !== 'undefined' && Notification.permission === 'granted') {
2602
+ for (const s of rows) {
2603
+ if (announced.has(s.session_id)) continue
2604
+ const note = rankedNotes(s)[0]
2605
+ 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 */ }
2606
+ }
2607
+ }
2608
+ announced = ids
2609
+ }
2610
+
2611
+ const NOT_SECURE = 'This page is not a secure context. Open the board at http://localhost:<port> to turn toasts on.'
2612
+ function secureSentence() {
2613
+ const port = (window.location && window.location.port) || ''
2614
+ return NOT_SECURE.replace('<port>', port || '4747')
2615
+ }
2616
+ // E, the notifications table. Two toggles and no third: the tab badge above
2617
+ // is always on, so there is nothing to decide about it. The board toggle
2618
+ // reads `window.isSecureContext` at RUNTIME, because whether 127.0.0.1
2619
+ // counts is the browser's answer and not one this file may assume.
2620
+ function renderNotifySettings(v) {
2621
+ const box = document.getElementById('notify-settings')
2622
+ if (!box) return
2623
+ const prefs = v && v.preferences
2624
+ box.hidden = !prefs
2625
+ if (!prefs) return
2626
+ const term = document.getElementById('notify-terminal')
2627
+ const brd = document.getElementById('notify-board')
2628
+ const help = document.getElementById('notify-board-help')
2629
+ if (term) term.checked = prefs.notify_terminal !== false
2630
+ const secure = Boolean(window.isSecureContext)
2631
+ if (brd) {
2632
+ brd.disabled = !secure
2633
+ brd.checked = secure && prefs.notify_board === true
2634
+ }
2635
+ if (help) {
2636
+ const state = typeof Notification === 'undefined' ? 'this browser has no notifications' : `permission: ${Notification.permission}`
2637
+ help.textContent = secure ? `The browser asks the first time you turn this on (${state}).` : secureSentence()
2638
+ }
2639
+ }
2640
+ async function saveNotify(patch) {
2641
+ const status = document.getElementById('notify-status')
2642
+ if (status) status.textContent = 'Saving…'
2643
+ try {
2644
+ const data = await api('/api/settings', { method: 'PATCH', body: patch })
2645
+ if (view && data && data.preferences) view.preferences = data.preferences
2646
+ if (status) status.textContent = 'Saved.'
2647
+ renderNotifySettings(view)
2648
+ } catch (err) {
2649
+ if (status) status.textContent = err.message
2650
+ renderNotifySettings(view)
2651
+ }
2652
+ }
2653
+
2654
+ // ---- D14: the keyboard map ----------------------------------------------
2655
+ // Every binding CLICKS a button that is already on the row, so no key is a
2656
+ // second way to do anything and nothing here can drift from the buttons.
2657
+ // `data-focus-key` is the same handle the focus-restore pass uses.
2658
+ const KEY_BUTTONS = [
2659
+ { key: 'h', focus: 'handoff', button: 'Hand off now' },
2660
+ { key: 'l', focus: 'land', button: 'Land' },
2661
+ { key: 'd', focus: 'details', button: 'Details' },
2662
+ { key: 'e', focus: 'end', button: 'End' },
2663
+ ]
2664
+ const KEY_MOVES = [
2665
+ { key: 'j', what: 'move the ring to the next terminal' },
2666
+ { key: 'k', what: 'move the ring to the previous terminal' },
2667
+ { key: '1 to 9', what: 'move the ring to that terminal' },
2668
+ { key: '?', what: 'open and close this map' },
2669
+ { key: 'Escape', what: 'close this map, cancel a confirm row, or close an expansion' },
2670
+ ]
2671
+ // The ring is a TERMINAL, not a position. The grid re-sorts on every SSE push
2672
+ // and on the 15 second timer (needs-you rows first), so an index left the
2673
+ // ring painted on whichever row slid into that slot while the reader was
2674
+ // looking at their terminal, and the next key ended a session they never
2675
+ // chose. `data-session-id` is on every .term, and DOM focus is already
2676
+ // carried by identity through data-focus-key, so this makes the two agree.
2677
+ let ringId = null
2678
+ let keymapOpen = false
2679
+ function termRows() { return [...document.querySelectorAll('#session-grid .term')] }
2680
+ function ringIndex(list) {
2681
+ if (!ringId) return -1
2682
+ return (list || termRows()).findIndex((r) => r.getAttribute('data-session-id') === ringId)
2683
+ }
2684
+ function paintRing(list) {
2685
+ const rows = list || termRows()
2686
+ const at = ringIndex(rows)
2687
+ // a terminal that ended and moved to the ledger takes its ring with it,
2688
+ // rather than leaving it to be inherited by the row that took its place
2689
+ if (ringId && at < 0) ringId = null
2690
+ rows.forEach((r, i) => r.classList.toggle('is-focused', i === at))
2691
+ }
2692
+ // the ring moves focus to the row's first button, so a screen reader
2693
+ // announces the row it landed on rather than leaving the reader nowhere
2694
+ function moveRing(to) {
2695
+ const rows = termRows()
2696
+ if (!rows.length) return
2697
+ const ringAt = Math.max(0, Math.min(rows.length - 1, to))
2698
+ ringId = rows[ringAt].getAttribute('data-session-id')
2699
+ paintRing(rows)
2700
+ // the prompt button first: it is the row's first control and its label is
2701
+ // the prompt, so a screen reader announces WHICH terminal the ring landed
2702
+ // on rather than a bare `Land`. A row with no prompt (someone else's) falls
2703
+ // through to whatever control it does have.
2704
+ const btn = rows[ringAt].querySelector('.panel-prompt, .term-actions .btn, button')
2705
+ if (btn && btn.focus) btn.focus()
2706
+ }
2707
+ function pressOnRing(focusKey) {
2708
+ const rows = termRows()
2709
+ if (!rows.length) return
2710
+ const at = ringIndex(rows)
2711
+ // With no ring there is nothing painted, so a key that acted would act on
2712
+ // whichever row the needs-you sort put first, with no way for the reader to
2713
+ // see which one that was before the POST went out. The first press only
2714
+ // moves and paints the ring; the second acts.
2715
+ if (at < 0) { moveRing(0); return }
2716
+ const btn = rows[at].querySelector(`[data-focus-key^="${focusKey}:"]`)
2717
+ if (btn && !btn.disabled) btn.click()
2718
+ }
2719
+ function renderKeymap() {
2720
+ const box = document.getElementById('keymap')
2721
+ const list = document.getElementById('keymap-list')
2722
+ if (!box || !list) return
2723
+ box.hidden = !keymapOpen
2724
+ if (!keymapOpen) return
2725
+ list.textContent = ''
2726
+ for (const k of KEY_MOVES) {
2727
+ list.appendChild(el('dt', { class: 'keymap-key' }, [k.key]))
2728
+ list.appendChild(el('dd', { class: 'keymap-what' }, [k.what]))
2729
+ }
2730
+ for (const k of KEY_BUTTONS) {
2731
+ list.appendChild(el('dt', { class: 'keymap-key' }, [k.key]))
2732
+ list.appendChild(el('dd', { class: 'keymap-what' }, [`press ${k.button} on the terminal the ring is on`]))
2733
+ }
2734
+ }
2735
+ function toggleKeymap(open) {
2736
+ keymapOpen = open === undefined ? !keymapOpen : open
2737
+ renderKeymap()
2738
+ if (keymapOpen) { const c = document.getElementById('keymap-close'); if (c && c.focus) c.focus() }
2739
+ }
2740
+ // a key pressed into a field is text, never a command
2741
+ function isTyping(e) {
2742
+ const t = e.target
2743
+ const tag = t && t.tagName ? String(t.tagName).toLowerCase() : ''
2744
+ return tag === 'input' || tag === 'select' || tag === 'textarea' || Boolean(t && t.isContentEditable)
2745
+ }
2746
+ // A <dialog> opened with showModal() still sends its keydowns to document, so
2747
+ // `h` typed at the New card dialog reached a row the reader cannot see, and a
2748
+ // confirm row is a question that has not been answered yet. Both own the page
2749
+ // while they are up. Escape has its own branch above this handler, so every
2750
+ // one of them can still be dismissed.
2751
+ function boardBusy() {
2752
+ if (pendingConfirm) return true
2753
+ try { return Boolean(document.querySelector('dialog[open]')) } catch { return false }
2754
+ }
2755
+ function boardKey(e) {
2756
+ if (e.ctrlKey || e.metaKey || e.altKey || isTyping(e)) return
2757
+ if (boardBusy()) return
2758
+ if (e.key === '?') { toggleKeymap(); e.preventDefault(); return }
2759
+ // the map is a panel explaining these keys; reading it must not fire them
2760
+ if (keymapOpen) return
2761
+ if (e.key === 'j') { moveRing(ringIndex() + 1); e.preventDefault(); return }
2762
+ if (e.key === 'k') { moveRing(ringIndex() - 1); e.preventDefault(); return }
2763
+ if (/^[1-9]$/.test(e.key)) { moveRing(Number(e.key) - 1); e.preventDefault(); return }
2764
+ const hit = KEY_BUTTONS.find((k) => k.key === e.key)
2765
+ if (hit) { pressOnRing(hit.focus); e.preventDefault() }
2766
+ }
2767
+
1436
2768
  // Finished terminals are history. After a day of work they are most of the
1437
2769
  // list, and drawn as full rows they bury the one or two that are live, so
1438
2770
  // they leave the panel entirely and become a ledger cell with a drawer.
@@ -1478,23 +2810,36 @@
1478
2810
  const focus = takeFocus(document)
1479
2811
  const grid = document.getElementById('session-grid')
1480
2812
  if (!grid) return
1481
- // park the expanded region back on the body before the list is wiped, so it
1482
- // is never orphaned by the rebuild and never loses its own content.
1483
- // Detaching a subtree resets scrollTop on every scrollable box inside it,
1484
- // so a reader half way down a 200-line diff was returned to the top by a
1485
- // rebuild of the list around them. The offsets are read before the move and
1486
- // written back after it, the same contract renderDrawer keeps.
2813
+ // The expanded region is a child of this grid, so a rebuild of the list can
2814
+ // orphan it, reset every scrollable box inside it and clear any selection
2815
+ // spanning it. It is left in place where it can be (see `keep` below), and
2816
+ // when it does have to move -- to the ledger, or into the list for the
2817
+ // first time -- its scroll offsets are read here and written back after,
2818
+ // the same contract renderDrawer keeps.
1487
2819
  const region = detailRegion()
1488
2820
  const parked = region ? takeScroll(region) : null
1489
- if (region && region.parentNode === grid) document.body.appendChild(region)
1490
- grid.textContent = ''
2821
+ // where the expansion sat in the viewport before any of this. Rows above it
2822
+ // are rebuilt from scratch and can change height as well as order, and the
2823
+ // browser has no anchor of its own across a wipe, so the offset is measured
2824
+ // here and restored at the end. A full in-place diff of every row is the
2825
+ // real answer to "rows that did not change keep their DOM nodes"; this is
2826
+ // the narrower one, and it holds the one thing the reader is looking at
2827
+ // still whatever the rows above it do.
2828
+ const wasAt = anchorFor(region, grid)
1491
2829
  const list = [...v.sessions]
1492
2830
  const notesOf = new Map(list.map((s) => [s.session_id, rankedNotes(s)]))
1493
2831
  const urgent = (s) => needsYou(s, notesOf.get(s.session_id) || [])
1494
- // needs-you first, then started_at ascending. The re-sort every 15 seconds
1495
- // only moves the needs-you partition, so a panel never slides under the
1496
- // cursor for a reason the reader cannot see.
2832
+ // needs-you first, then started_at ascending.
1497
2833
  list.sort((a, b) => (urgent(a) === urgent(b) ? Date.parse(a.started_at) - Date.parse(b.started_at) : urgent(a) ? -1 : 1))
2834
+ // and then held still if the reader is inside the list: the sort is what
2835
+ // the order WILL be, heldOrder is what it is on screen until they come out
2836
+ const natural = list.map((s) => s.session_id)
2837
+ const order = listOrder(natural, heldOrder, readerIsInTheList())
2838
+ const rank = new Map(order.map((id, i) => [id, i]))
2839
+ list.sort((a, b) => rank.get(a.session_id) - rank.get(b.session_id))
2840
+ heldOrder = order
2841
+ const same = order.length === natural.length && order.every((id, i) => id === natural[i])
2842
+ orderDivergedAt = same ? 0 : (orderDivergedAt || Date.now())
1498
2843
  const empty = document.querySelector('.region-terminals .empty-line')
1499
2844
  if (empty) empty.hidden = list.some((s) => s.active || needsYou(s, notesOf.get(s.session_id) || []))
1500
2845
 
@@ -1503,14 +2848,36 @@
1503
2848
  const done = (s) => !s.active && !urgent(s) && drawer.id !== s.session_id
1504
2849
  const live = list.filter((s) => !done(s))
1505
2850
  const finished = list.filter(done)
2851
+ // A.4 row 18 and E: the tab badge and the browser toast read the same
2852
+ // predicate the rows sort on and the region head counts, so the three
2853
+ // cannot disagree about who is waiting.
2854
+ const waiting = list.filter(urgent)
2855
+ titleBadge(waiting.length)
2856
+ announceWaiting(waiting, (view && view.preferences) || {})
1506
2857
  terminalsMeta(live, notesOf)
1507
2858
  // computed over the rows that are actually drawn: a sentence shared only by
1508
2859
  // terminals collapsed into the ledger is not on screen to be deduped
1509
2860
  hoistShared(live, notesOf)
2861
+ // The expansion stays exactly where it is whenever it can. Taking a subtree
2862
+ // out of the document and putting it back clears any text selection inside
2863
+ // it, and this runs on every push: a reader dragging across a path in the
2864
+ // expansion lost the drag every time a row anywhere on the board changed.
2865
+ // The rows around it are removed and rebuilt; it is not touched, so the
2866
+ // selection, and the scroll offsets inside it, are never disturbed.
2867
+ const keep = region && region.parentNode === grid && live.some((s) => s.session_id === drawer.id) ? region : null
2868
+ if (region && !keep && region.parentNode === grid) document.body.appendChild(region)
2869
+ for (const node of [...grid.childNodes]) if (node !== keep) grid.removeChild(node)
2870
+ let past = false
1510
2871
  for (const s of live) {
1511
2872
  const panel = renderSession(s)
1512
- grid.appendChild(panel)
1513
- if (drawer.id === s.session_id && region) panel.after(region)
2873
+ // rows up to and including the expanded one go in above it, the rest
2874
+ // after it, which keeps the region under the panel it belongs to
2875
+ if (keep && !past) grid.insertBefore(panel, keep)
2876
+ else grid.appendChild(panel)
2877
+ if (drawer.id === s.session_id) {
2878
+ past = true
2879
+ if (!keep && region) panel.after(region)
2880
+ }
1514
2881
  }
1515
2882
  // the per-row copies exist only now, so the pass that hides the ones the
1516
2883
  // region already says runs after the rows are in the document
@@ -1525,6 +2892,13 @@
1525
2892
  // the control they were on, at the offset they had scrolled to
1526
2893
  if (region && parked) putScroll(region, parked)
1527
2894
  putFocus(document, focus)
2895
+ // the rows above the expansion are new elements of their own height and in
2896
+ // their own order now: put the expansion back where the reader left it
2897
+ holdAnchor(region, wasAt)
2898
+ // the rows are new elements: the ring is a class, so it is repainted onto
2899
+ // the terminal the reader left it on rather than stealing focus again. This
2900
+ // list was just re-sorted, so painting by position would move it.
2901
+ paintRing()
1528
2902
  }
1529
2903
 
1530
2904
  // What landed is history too. The full list was eighteen rows of git log at
@@ -1597,6 +2971,7 @@
1597
2971
  }
1598
2972
  renderAccounts(v.accounts || [])
1599
2973
  renderDefaultOrder(v)
2974
+ renderNotifySettings(v)
1600
2975
  // A rebuild replaces every button in the grid. A confirm row is a question
1601
2976
  // the reader is answering right now, and a push landing between their
1602
2977
  // mousedown and their mouseup dropped the click: the browser fires `click`
@@ -1607,7 +2982,7 @@
1607
2982
  renderTrunk(v)
1608
2983
  // the panel behind the expansion just changed: status, turns and what is
1609
2984
  // next live in the session view, so redraw the region from it
1610
- if (drawer.id) { if (drawerSession()) renderDrawer(); else closeSessionDrawer() }
2985
+ if (drawer.id) { if (drawerSession()) renderDrawer({ timed: true }); else closeSessionDrawer() }
1611
2986
  }
1612
2987
 
1613
2988
  async function refresh() {
@@ -1618,14 +2993,67 @@
1618
2993
  // `baton:sessions` alias for every push; this file had been registered on
1619
2994
  // `leg:sessions` twice and on the alias once, so one push rebuilt the entire
1620
2995
  // terminals grid three times over.
2996
+ // The verdict is a pure function of the payload, and its character budget is
2997
+ // a measurement, so test/board-verdict.test.mjs drives the branches directly
2998
+ // through this seam. In a browser there is no `module`, and nothing here
2999
+ // depends on it. board-updates.test.mjs uses the same pattern in board.js.
3000
+ if (typeof module !== 'undefined') {
3001
+ module.exports = {
3002
+ verdictLines, VERDICT_CH, SUB_CH, WARN_PCT, bindingOf, capFigure, capToken, shareClause, headline,
3003
+ rankedNotes, needsYou, registerTokens, capacityPhrase, capacityNote, waitingNote, notifyWait, resetWait,
3004
+ KEY_BUTTONS, KEY_MOVES,
3005
+ // D14: the ring is state, not a pure function, so test/board-keyboard
3006
+ // drives it through the same keydown listener a reader presses and reads
3007
+ // the paint back through these two. getToken is here because a board in a
3008
+ // browser with storage blocked has to render, and that cannot be asserted
3009
+ // from the source text.
3010
+ paintRing, ringSession: () => ringId, getToken, announceWaiting, readerIsWatching,
3011
+ // 2026-09-18 "it keeps jumping around": the order the list holds while the
3012
+ // reader is inside it, and the run of identical status lines that used to
3013
+ // fill the timeline. Both are pure, so test/board-jump.test.mjs drives
3014
+ // them directly; the render that calls them is proved by
3015
+ // scripts/board-jump-probe.mjs in a real browser.
3016
+ listOrder, holdsOrder, collapseEvents, readerIsInTheList, ORDER_HOLD_MS,
3017
+ // ...and the two bounds the second pass put on that hold: which regions
3018
+ // have a scroll anchor worth restoring, and how long the region may
3019
+ // refuse to redraw itself under a selection the reader has forgotten.
3020
+ anchorFor, drawerStandsDown, DRAWER_HOLD_MS,
3021
+ // B.6: the pick that survives the poll, the rule box and the refusal the
3022
+ // Add button prints are all pure and are asserted without a DOM
3023
+ pickKey, pickIndex, whenFromBox, whenFlag, duplicateRung,
3024
+ // B.6: the picker's option text, the ladder's round trip and the climb
3025
+ // predicate are pure, so they are asserted without a DOM
3026
+ handoffOptionText, rungLabel, costWord, whenKind, whenPct, whenString, moveRung, rungsAfter,
3027
+ climbTarget, topRungFor, MODEL_ALIASES, LADDER_AGENTS,
3028
+ MAY_SPEND_SENTENCE, NO_CREDITS_SENTENCE, CLIMB_RULE, CLIMB_WORDS,
3029
+ }
3030
+ }
3031
+
1621
3032
  window.addEventListener('leg:sessions', (e) => render(e.detail))
1622
3033
  document.addEventListener('keydown', (e) => {
1623
- if (e.key !== 'Escape') return
1624
- if (pendingConfirm) { pendingConfirm = null; if (view) renderSessions(view); return }
1625
- if (drawer.id) closeSessionDrawer()
3034
+ if (e.key === 'Escape') {
3035
+ if (keymapOpen) { toggleKeymap(false); return }
3036
+ if (pendingConfirm) { pendingConfirm = null; if (view) renderSessions(view); return }
3037
+ if (drawer.id) closeSessionDrawer()
3038
+ return
3039
+ }
3040
+ boardKey(e)
1626
3041
  })
1627
3042
  document.addEventListener('DOMContentLoaded', () => {
1628
3043
  document.getElementById('default-order-save')?.addEventListener('click', saveDefaultOrder)
3044
+ document.getElementById('capacity-toggle')?.addEventListener('click', toggleCapacity)
3045
+ document.getElementById('keymap-close')?.addEventListener('click', () => toggleKeymap(false))
3046
+ document.getElementById('notify-terminal')?.addEventListener('change', (e) => saveNotify({ notify_terminal: Boolean(e.target.checked) }))
3047
+ // the permission is asked for on the toggle, never on load: a page that
3048
+ // asks for a permission nobody wanted is a page people close
3049
+ document.getElementById('notify-board')?.addEventListener('change', async (e) => {
3050
+ const on = Boolean(e.target.checked)
3051
+ if (on && typeof Notification !== 'undefined' && Notification.permission === 'default') {
3052
+ try { await Notification.requestPermission() } catch { /* a refusal is an answer; the toggle still saves */ }
3053
+ }
3054
+ saveNotify({ notify_board: on })
3055
+ })
3056
+ renderCapacityToggle()
1629
3057
  renderLoadingHead()
1630
3058
  refresh()
1631
3059
  setInterval(tickElapsed, 1000)