@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.
- package/CHANGELOG.md +88 -0
- package/README.md +1 -1
- package/docs/DECISIONS.md +8 -0
- package/docs/ERRORS.md +42 -0
- package/docs/board-guide.md +129 -31
- package/docs/cli-contracts.md +42 -0
- package/docs/configuration.md +8 -2
- package/docs/faq.md +3 -1
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/new-card-dialog.png +0 -0
- package/fixtures/verified.json +1 -1
- package/package.json +1 -1
- package/scripts/board-jump-probe.mjs +335 -0
- package/scripts/build-docs-site.mjs +3 -3
- package/src/attach.mjs +60 -57
- package/src/board/board.css +84 -2
- package/src/board/board.js +349 -261
- package/src/board/entry.js +343 -0
- package/src/board/floor.html +51 -39
- package/src/board/floor.js +585 -73
- package/src/board/index.html +55 -38
- package/src/board/sessions.js +342 -144
- package/src/board/strip.js +163 -0
- package/src/models.mjs +265 -0
- package/src/preferences.mjs +69 -5
- package/src/server.mjs +81 -33
- package/src/taps/claude-usage.mjs +16 -1
- package/src/usage-poll.mjs +260 -0
- package/src/usage.mjs +33 -1
package/src/board/sessions.js
CHANGED
|
@@ -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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
//
|
|
293
|
-
|
|
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
|
-
|
|
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
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2312
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2658
|
-
//
|
|
2659
|
-
//
|
|
2660
|
-
//
|
|
2661
|
-
//
|
|
2662
|
-
//
|
|
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
|
-
|
|
2666
|
-
|
|
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.
|
|
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
|
-
|
|
2695
|
-
|
|
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,
|