@ucsandman/legcli 0.12.0 → 0.13.1

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.
@@ -38,6 +38,14 @@
38
38
  let trunkOpen = false
39
39
  let finishedOpen = false
40
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
41
49
  const sessionEditors = new Map()
42
50
  const alsoOpen = new Set()
43
51
  const actionNotes = new Map()
@@ -279,20 +287,24 @@
279
287
  // active, else the highest percentage it reported, else the legacy hottest of
280
288
  // the two windows, which is all an older record or a guest payload carries.
281
289
  // Mirrors binding() in src/usage.mjs; the board cannot import from it.
282
- const BUCKET_WORD = { weekly_scoped: 'week', weekly_all: 'week', session: 'session', spend: 'spend', seven_day: '7d', five_hour: '5h' }
283
- function bindingOf(a) {
284
- const buckets = Array.isArray(a && a.buckets) ? a.buckets.filter((b) => b && Number.isFinite(b.percent)) : []
285
- const top = (l) => (l.length ? [...l].sort((x, y) => y.percent - x.percent)[0] : null)
286
- const b = top(buckets.filter((x) => x.is_active)) || top(buckets)
287
- if (b) return { kind: b.kind, model: b.model || null, percent: b.percent, resets_at: Number.isFinite(b.resets_at) ? b.resets_at : null, scope: b.model ? 'model' : 'account' }
288
- const w = worstWindow(a)
289
- if (!w || !Number.isFinite(w.pct)) return null
290
- return { kind: a && a.seven_day === w ? 'seven_day' : 'five_hour', model: null, percent: w.pct, resets_at: Number.isFinite(w.resets_at) ? w.resets_at : null, scope: 'account' }
291
- }
292
- // the token's two words: `fable week`, `week`, `session`, `5h`
293
- function bucketWord(b) { const word = BUCKET_WORD[b.kind] || b.kind; return b.model ? `${b.model} ${word}` : word }
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)
294
306
  // the same bucket inside a sentence: "63% of its week"
295
- function windowPhrase(b) { return b.kind === 'session' ? 'its session' : b.kind === 'five_hour' ? 'its 5 hours' : 'its week' }
307
+ const windowPhrase = (b) => strip().windowPhrase(b)
296
308
  // the account's own window, ignoring any model bucket: what a same-login
297
309
  // model rung still has to spend, and what an account wall would take away
298
310
  function accountBucket(a) {
@@ -303,20 +315,12 @@
303
315
  return legacy && !legacy.model ? legacy : null
304
316
  }
305
317
  const Model = (m) => (m ? String(m).charAt(0).toUpperCase() + String(m).slice(1) : '')
306
- // every model this login has published anything about. A model named by
307
- // neither a bucket nor a wall is one Leg has never seen, and it is never
308
- // guessed at.
309
- function knownModels(a) {
310
- const out = []
311
- for (const b of (a && a.buckets) || []) if (b && b.model && !out.includes(b.model)) out.push(b.model)
312
- for (const m of Object.keys((a && a.walls) || {})) if (!out.includes(m)) out.push(m)
313
- return out
314
- }
315
- function wallFor(a, model) {
316
- const w = a && a.walls ? a.walls[model] : null
317
- return w && Number.isFinite(w.limited_until) && w.limited_until * 1000 > Date.now() ? w : null
318
- }
319
- function walledModels(a) { return knownModels(a).filter((m) => wallFor(a, m)) }
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)
320
324
  function openModels(a) { return knownModels(a).filter((m) => !wallFor(a, m)) }
321
325
  function modelBucket(a, model) {
322
326
  return (Array.isArray(a && a.buckets) ? a.buckets : []).find((b) => b && b.model === model && Number.isFinite(b.percent)) || null
@@ -458,78 +462,14 @@
458
462
  // are not rewritten, they move behind the disclosure at the end of the strip.
459
463
  // The token prints the BINDING bucket, because the board printing 47% for a
460
464
  // login whose active bucket is at 63% is the defect this strip exists for.
461
- function capFigure(a, b) {
462
- // the same two refusals the gauge prints, in the strip's shorter grammar
463
- if (a.shared === false) return 'not shared'
464
- if (a.loading) return 'reading'
465
- if (acctState(a) === 'walled') return Number.isFinite(a.limited_until) ? `back ${until(a.limited_until)}` : 'back when it resets'
466
- // agy publishes no percentage, ever; a login that has one and has not
467
- // reported it yet is a different fact and says so.
468
- if (!b) return a.agent === 'agy' ? 'no figure' : 'no reading'
469
- const observed = Date.parse(a.observed_at || a.updated_at || '')
470
- // a reading older than the window it describes prints the clock it was
471
- // taken at instead of a bucket word: it is a measurement, not a reading now
472
- if (a.stale && a.agent !== 'agy' && Number.isFinite(observed)) return `${Math.round(b.percent)}% ${clockAt(observed)}`
473
- return `${Math.round(b.percent)}% ${bucketWord(b)}`
474
- }
475
- // the spoken sentence carries what the visible token cannot: the reset, the
476
- // source, the wall and the age of the reading, exactly as the gauges do.
477
- function capValueText(a, b) {
478
- const parts = []
479
- if (a.shared === false) parts.push(`Usage for ${accountLabel(a)} is not shared with guests.`)
480
- else if (!b) {
481
- parts.push(a.agent === 'agy'
482
- ? 'agy publishes no usage percentage, ever. Leg sees the wall when agy hits it.'
483
- : `No reading has come back from ${accountLabel(a)} yet.`)
484
- } else {
485
- parts.push(`${Math.round(b.percent)} percent of ${b.model ? `the ${b.model} ${BUCKET_WORD[b.kind] || b.kind}` : windowPhrase(b)} used.`)
486
- if (Number.isFinite(b.resets_at)) parts.push(`Resets at ${until(b.resets_at)}, in ${spoken(b.resets_at * 1000 - Date.now())}.`)
487
- }
488
- if (acctState(a) === 'walled') parts.push(`${accountLabel(a)} is at its wall until ${until(a.limited_until)}, in ${spoken(a.limited_until * 1000 - Date.now())}.`)
489
- for (const m of walledModels(a)) parts.push(`${m} is out until ${until(wallFor(a, m).limited_until)}.`)
490
- if (a.source) parts.push(`Source: ${a.source}.`)
491
- const observed = Date.parse(a.observed_at || a.updated_at || '')
492
- if (a.stale && a.agent !== 'agy' && Number.isFinite(observed)) parts.push(`Read at ${clockAt(observed)}, ${spoken(Date.now() - observed)} ago, stale.`)
493
- return parts.join(' ')
494
- }
495
- function capToken(a) {
496
- const id = idOf(a.agent)
497
- const b = bindingOf(a)
498
- const walled = acctState(a) === 'walled'
499
- const pct = b ? Math.max(0, Math.min(100, Math.round(b.percent))) : null
500
- const token = el('span', { class: 'cap-token' }, [
501
- el('span', { class: `dot id-${id}`, 'aria-hidden': 'true' }),
502
- el('span', { class: `cap-name id-${id}` }, [accountLabel(a)]),
503
- ])
504
- // no number, no instrument. A track with nothing in it is a reading of zero
505
- // to anyone glancing at it, which is exactly what agy does not have.
506
- if (pct !== null || walled) {
507
- const stop = pct !== null && pct > 85 ? `${((85 / pct) * 100).toFixed(2)}%` : null
508
- const fill = el('span', {
509
- class: 'cap-fill',
510
- style: walled ? 'width:100%;background:var(--danger)'
511
- : stop ? `width:${pct}%;background:linear-gradient(to right,var(--id-${id}) 0 ${stop},var(--danger) ${stop} 100%)`
512
- : `width:${pct}%;background:var(--id-${id})`,
513
- })
514
- // a walled login with no percentage is not a meter: 100 would be a number
515
- // nobody measured. It keeps the track and carries the sentence instead.
516
- const semantics = pct === null
517
- ? { role: 'img', 'aria-label': `${accountLabel(a)} capacity. ${capValueText(a, b)}` }
518
- : { role: 'meter', 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': String(pct), 'aria-label': `${accountLabel(a)} capacity`, 'aria-valuetext': capValueText(a, b) }
519
- token.appendChild(el('span', { class: 'cap-track', ...semantics }, [fill]))
520
- }
521
- // with no track the figure carries the whole sentence itself, the way the
522
- // gauge's readout does when a window has never been read
523
- const quiet = pct === null && !walled ? { role: 'img', 'aria-label': `${accountLabel(a)} capacity. ${capValueText(a, b)}` } : {}
524
- token.appendChild(el('span', { class: `cap-figure${walled ? ' is-out' : ''}${pct === null && !walled ? ' cap-figure--none' : ''}`, ...quiet }, [capFigure(a, b)]))
525
- return token
526
- }
527
- function capacityStrip(list) {
528
- const box = document.getElementById('capacity-tokens')
529
- if (!box) return
530
- box.textContent = ''
531
- for (const a of list) box.appendChild(capToken(a))
532
- }
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)
533
473
 
534
474
  // The model rail, on the panel head inside the drawer: one chip per model
535
475
  // this login has published a bucket or a wall for. A walled model says when
@@ -922,25 +862,12 @@
922
862
  }
923
863
 
924
864
  // The login panels are behind one disclosure now, and whether it is open is
925
- // the reader's decision, kept across reloads. localStorage throws in a
926
- // private window and on a board opened from a file, so it is never load
927
- // bearing: the strip and the panels both render either way.
928
- const CAP_KEY = 'legCapacityOpen'
929
- let capacityOpen = (() => { try { return localStorage.getItem(CAP_KEY) === '1' } catch { return false } })()
930
- function renderCapacityToggle() {
931
- const btn = document.getElementById('capacity-toggle')
932
- const drawer = document.getElementById('capacity-drawer')
933
- if (btn) {
934
- btn.setAttribute('aria-expanded', capacityOpen ? 'true' : 'false')
935
- btn.textContent = capacityOpen ? 'Hide capacity and models' : 'Capacity and models >'
936
- }
937
- if (drawer) drawer.hidden = !capacityOpen
938
- }
939
- function toggleCapacity() {
940
- capacityOpen = !capacityOpen
941
- try { localStorage.setItem(CAP_KEY, capacityOpen ? '1' : '0') } catch { /* private window: the drawer still opens, it just does not remember */ }
942
- renderCapacityToggle()
943
- }
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()
944
871
 
945
872
  // 6.1.5, all eight states. The walled state is an ADDITIONAL state of the
946
873
  // rail, never a replacement for it: both percentages, both reset times and
@@ -1256,6 +1183,20 @@
1256
1183
  const at = (ladder || []).findIndex((r) => rungKey(r) === rungKey(rung))
1257
1184
  return at < 0 ? null : `${rungLabel(rung)} is already rung ${at + 1}.`
1258
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
+ }
1259
1200
  function addRungRow(ladder, onChange, scope, onRefuse) {
1260
1201
  const row = el('div', { class: 'ladder-add' })
1261
1202
  const agentId = `ladder-add-agent-${scope}`
@@ -1267,7 +1208,7 @@
1267
1208
  const fillModels = () => {
1268
1209
  model.textContent = ''
1269
1210
  model.appendChild(el('option', { value: '' }, ['default']))
1270
- for (const m of MODEL_ALIASES[agent.value] || []) model.appendChild(el('option', { value: m }, [m]))
1211
+ for (const m of catalogModels(agent.value)) model.appendChild(el('option', { value: m.id }, [m.label || m.id]))
1271
1212
  model.value = ''
1272
1213
  }
1273
1214
  fillModels()
@@ -1323,11 +1264,102 @@
1323
1264
  // A rebuild clears any selection that spans it, so the timed re-sort stands
1324
1265
  // down while the reader is selecting a path or a sentence out of a panel. A
1325
1266
  // real data push still redraws: the words on screen win over the drag.
1326
- function selectionInsideGrid() {
1267
+ function selectionInside(box) {
1327
1268
  const sel = typeof document.getSelection === 'function' ? document.getSelection() : null
1328
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() {
1329
1345
  const grid = document.getElementById('session-grid')
1330
- 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
+ })
1331
1363
  }
1332
1364
 
1333
1365
  // Absolute priority, not a rotation: the saved list decides, minus the agent
@@ -2008,7 +2040,7 @@
2008
2040
  // rebuilding a <select> under an open option list closes it, so a list that
2009
2041
  // takes longer than three seconds to read could not be read at all. The
2010
2042
  // pick itself survives the rebuild by rung (pickIndex above).
2011
- drawer.timer = setInterval(() => { if (!drawer.paused && !document.hidden && !pickHasFocus()) loadDrawer() }, 3000)
2043
+ drawer.timer = setInterval(() => { if (!drawer.paused && !document.hidden && !pickHasFocus()) loadDrawer({ timed: true }) }, 3000)
2012
2044
  document.getElementById('session-drawer-close')?.focus()
2013
2045
  }
2014
2046
 
@@ -2026,7 +2058,7 @@
2026
2058
  if (id) putFocus(document, { key: `${from === 'details' ? 'details' : 'prompt'}:${id}` })
2027
2059
  }
2028
2060
 
2029
- async function loadDrawer() {
2061
+ async function loadDrawer({ timed = false } = {}) {
2030
2062
  if (!drawer.id) return
2031
2063
  const id = drawer.id
2032
2064
  try {
@@ -2038,7 +2070,7 @@
2038
2070
  if (drawer.id !== id) return
2039
2071
  drawer.error = err.message
2040
2072
  }
2041
- renderDrawer()
2073
+ renderDrawer({ timed })
2042
2074
  }
2043
2075
 
2044
2076
  function paintDiff(pre, d) {
@@ -2135,6 +2167,41 @@
2135
2167
  }
2136
2168
  }
2137
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
+
2138
2205
  // Whether .baton/RESUME.md still describes the repository a reader would find.
2139
2206
  // The server recomputes this from git on every poll, so the line is a verdict
2140
2207
  // about right now, not a timestamp the file remembered about itself.
@@ -2215,6 +2282,53 @@
2215
2282
  return box
2216
2283
  }
2217
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
+
2218
2332
  function section(title, note, body) {
2219
2333
  const s = el('section', { class: 'detail-section' }, [
2220
2334
  el('h3', { class: 'detail-heading' }, [title, note ? el('span', { class: 'detail-sub' }, [note]) : null]),
@@ -2237,10 +2351,42 @@
2237
2351
  return line
2238
2352
  }
2239
2353
 
2240
- function renderDrawer() {
2354
+ function renderDrawer({ timed = false } = {}) {
2241
2355
  const box = document.getElementById('session-drawer-content')
2242
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
2243
2388
  const inner = takeScroll(box)
2389
+ const onLine = takeTimelineAnchor(box)
2244
2390
  const focus = takeFocus(box)
2245
2391
  const s = drawerSession()
2246
2392
  const d = drawer.detail
@@ -2302,20 +2448,27 @@
2302
2448
 
2303
2449
  const timeline = el('div', { class: 'drawer-timeline', 'data-scroll-key': 'timeline' })
2304
2450
  const events = (d && d.events) || []
2305
- 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) {
2306
2454
  // clockAt, not a slice of the ISO string: that printed UTC, in 24-hour
2307
2455
  // with seconds, under a page that says `Times are local.` and beside a
2308
2456
  // header on the same panel printing the same instant as 11:04 PM. The
2309
2457
  // summary is a block, as board.js:754 builds the same row, or the kind
2310
2458
  // word and the sentence render glued: `lostrunner pid 999002 is gone`.
2311
- timeline.appendChild(el('div', { class: 'turn timeline-item' }, [
2312
- 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))]),
2313
2463
  el('span', { class: 'turn-role' }, [e.type]),
2314
- 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]),
2315
2465
  ]))
2316
2466
  }
2317
2467
  if (!events.length) timeline.appendChild(el('p', { class: 'sentence tone-muted' }, [d ? 'nothing recorded yet' : 'reading the timeline']))
2318
- 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)
2319
2472
  const timelineBody = el('div', { class: 'detail-section' }, [timeline])
2320
2473
  if (events.length > shownEvents) timelineBody.appendChild(capLine(shownEvents, events.length, 'events', null))
2321
2474
  box.appendChild(section('Timeline', 'this terminal, newest first', timelineBody))
@@ -2328,6 +2481,9 @@
2328
2481
  const harness = harnessSection(s, d)
2329
2482
  if (harness) box.appendChild(section('Harness', 'the working environment this leg was given', harness))
2330
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)
2331
2487
  putFocus(box, focus)
2332
2488
  }
2333
2489
 
@@ -2654,23 +2810,36 @@
2654
2810
  const focus = takeFocus(document)
2655
2811
  const grid = document.getElementById('session-grid')
2656
2812
  if (!grid) return
2657
- // park the expanded region back on the body before the list is wiped, so it
2658
- // is never orphaned by the rebuild and never loses its own content.
2659
- // Detaching a subtree resets scrollTop on every scrollable box inside it,
2660
- // so a reader half way down a 200-line diff was returned to the top by a
2661
- // rebuild of the list around them. The offsets are read before the move and
2662
- // 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.
2663
2819
  const region = detailRegion()
2664
2820
  const parked = region ? takeScroll(region) : null
2665
- if (region && region.parentNode === grid) document.body.appendChild(region)
2666
- 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)
2667
2829
  const list = [...v.sessions]
2668
2830
  const notesOf = new Map(list.map((s) => [s.session_id, rankedNotes(s)]))
2669
2831
  const urgent = (s) => needsYou(s, notesOf.get(s.session_id) || [])
2670
- // needs-you first, then started_at ascending. The re-sort every 15 seconds
2671
- // only moves the needs-you partition, so a panel never slides under the
2672
- // cursor for a reason the reader cannot see.
2832
+ // needs-you first, then started_at ascending.
2673
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())
2674
2843
  const empty = document.querySelector('.region-terminals .empty-line')
2675
2844
  if (empty) empty.hidden = list.some((s) => s.active || needsYou(s, notesOf.get(s.session_id) || []))
2676
2845
 
@@ -2689,10 +2858,26 @@
2689
2858
  // computed over the rows that are actually drawn: a sentence shared only by
2690
2859
  // terminals collapsed into the ledger is not on screen to be deduped
2691
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
2692
2871
  for (const s of live) {
2693
2872
  const panel = renderSession(s)
2694
- grid.appendChild(panel)
2695
- 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
+ }
2696
2881
  }
2697
2882
  // the per-row copies exist only now, so the pass that hides the ones the
2698
2883
  // region already says runs after the rows are in the document
@@ -2707,6 +2892,9 @@
2707
2892
  // the control they were on, at the offset they had scrolled to
2708
2893
  if (region && parked) putScroll(region, parked)
2709
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)
2710
2898
  // the rows are new elements: the ring is a class, so it is repainted onto
2711
2899
  // the terminal the reader left it on rather than stealing focus again. This
2712
2900
  // list was just re-sorted, so painting by position would move it.
@@ -2794,7 +2982,7 @@
2794
2982
  renderTrunk(v)
2795
2983
  // the panel behind the expansion just changed: status, turns and what is
2796
2984
  // next live in the session view, so redraw the region from it
2797
- if (drawer.id) { if (drawerSession()) renderDrawer(); else closeSessionDrawer() }
2985
+ if (drawer.id) { if (drawerSession()) renderDrawer({ timed: true }); else closeSessionDrawer() }
2798
2986
  }
2799
2987
 
2800
2988
  async function refresh() {
@@ -2820,6 +3008,16 @@
2820
3008
  // browser with storage blocked has to render, and that cannot be asserted
2821
3009
  // from the source text.
2822
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,
2823
3021
  // B.6: the pick that survives the poll, the rule box and the refusal the
2824
3022
  // Add button prints are all pure and are asserted without a DOM
2825
3023
  pickKey, pickIndex, whenFromBox, whenFlag, duplicateRung,