@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.
@@ -0,0 +1,335 @@
1
+ // board-jump-probe — the regression harness for "the page keeps jumping around
2
+ // and knocking me out of what I'm doing".
3
+ //
4
+ // A reader with a terminal expanded, the pointer inside the expansion and the
5
+ // Timeline in view is reading a region whose position on the page is decided by
6
+ // the rows ABOVE it. Every push re-sorts the list (needs-you first), rebuilds
7
+ // every row from scratch, and a row that grew a waiting sentence, or moved
8
+ // across the needs-you partition, takes the expansion with it. scrollY does not
9
+ // change, so no scroll-hold probe can see it; what moves is the content under a
10
+ // still viewport.
11
+ //
12
+ // This drives exactly that: seeds a board, expands the last live row, scrolls
13
+ // the Timeline into view, focuses a control and selects a sentence inside the
14
+ // expansion, then over ten pushes appends events and flips a row ABOVE it into
15
+ // needs-you. It records, per push, where the region sits in the viewport, what
16
+ // has focus, and whether the selection survived.
17
+ //
18
+ // node scripts/board-jump-probe.mjs # ten pushes, prints a table
19
+ // node scripts/board-jump-probe.mjs --json # the same, as JSON
20
+ //
21
+ // The verdict line is the whole point: regionTop must not move by more than
22
+ // 2px, focus must not be lost, and the selection must survive. NEVER port 4747,
23
+ // that is the operator's live board; this one binds an ephemeral port of its
24
+ // own and kills everything it started.
25
+ import { chromium } from 'playwright'
26
+ import { spawnSync } from 'node:child_process'
27
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'
28
+ import { tmpdir } from 'node:os'
29
+ import { join, dirname } from 'node:path'
30
+ import { fileURLToPath } from 'node:url'
31
+
32
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
33
+ const asJson = process.argv.includes('--json')
34
+ const POLLS = 10
35
+
36
+ const HOME = mkdtempSync(join(tmpdir(), 'leg-jump-probe-'))
37
+ process.env.LEG_HOME = HOME
38
+ process.env.BATON_HOME = HOME
39
+ process.env.LEG_TRUST = 'never'
40
+ process.env.BATON_TRUST = 'never'
41
+ process.env.LEG_QUIET = '1'
42
+ process.env.BATON_QUIET = '1'
43
+
44
+ function cleanup() {
45
+ try {
46
+ const f = join(HOME, 'sleepers.json')
47
+ if (existsSync(f)) for (const pid of JSON.parse(readFileSync(f, 'utf8'))) { try { process.kill(pid) } catch { /* already gone */ } }
48
+ } catch { /* nothing to kill */ }
49
+ try { rmSync(HOME, { recursive: true, force: true }) } catch { /* windows holds a handle sometimes */ }
50
+ }
51
+ // the seeder's sleepers outlive this process if it dies before the finally, and
52
+ // a leaked sleeper is a node process nobody owns, so the exits are covered too
53
+ process.on('uncaughtException', (e) => { cleanup(); console.error(e); process.exit(1) })
54
+ process.on('SIGINT', () => { cleanup(); process.exit(130) })
55
+
56
+ // the seeder spawns detached sleepers so the live rows have a runner pid that
57
+ // reapLost() can find; they are written to sleepers.json and killed at the end
58
+ const seed = spawnSync(process.execPath, [join(ROOT, 'scripts', 'seed-wes-board.mjs')], {
59
+ env: { ...process.env }, encoding: 'utf8',
60
+ })
61
+ if (seed.status !== 0) { console.error(seed.stdout, seed.stderr); cleanup(); process.exit(1) }
62
+
63
+ const { createBoardServer } = await import('../src/server.mjs')
64
+ const { updateSession, appendEvent } = await import('../src/sessions.mjs')
65
+
66
+ // the four live rows the seeder writes, in the order renderSessions sorts them:
67
+ // needs-you first (0049, 0257), then started_at ascending (0213, 0455).
68
+ // The expansion hangs under a RUNNING row in the middle of the list, which is
69
+ // where a reader actually leaves it, and the row that flips is the one BELOW
70
+ // it: crossing into needs-you sends that row to the top of the list and pushes
71
+ // the expansion — and everything the reader is looking at — down the page.
72
+ const EXPANDED = 's-20260915-0213-claude-95d3'
73
+ const FLIPS = 's-20260915-0455-claude-8e8a'
74
+
75
+ const srv = createBoardServer({ bind: '127.0.0.1', port: 0, token: '', scheduler: false })
76
+ const { port } = await srv.start()
77
+ const base = `http://127.0.0.1:${port}`
78
+
79
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
80
+ let browser = null
81
+
82
+ try {
83
+ browser = await chromium.launch()
84
+ const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
85
+ const errors = []
86
+ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()) })
87
+ page.on('pageerror', (e) => errors.push(String(e)))
88
+ // networkidle never fires: the board polls. Wait for real rows instead.
89
+ await page.goto(base + '/', { waitUntil: 'domcontentloaded' })
90
+ await page.waitForSelector('#session-grid .term', { timeout: 20000 })
91
+ await sleep(800)
92
+
93
+ // ---- scenario 1 (finding 5): the click that opens the expansion ---------
94
+ // The reader is scrolled down the page, looking at the terminals, and presses
95
+ // Details. #session-drawer is still parked at the end of <body> at that
96
+ // moment, so an anchor measured there is hundreds of pixels below where the
97
+ // region will land, and putting it "back" scrolls the whole window. Nothing
98
+ // the reader asked for moved, so scrollY must not move either.
99
+ await page.evaluate(() => window.scrollTo(0, 400))
100
+ await sleep(250)
101
+ const openBefore = await page.evaluate(() => ({
102
+ scrollY: Math.round(window.scrollY),
103
+ drawerParent: document.getElementById('session-drawer')?.parentNode?.id || document.getElementById('session-drawer')?.parentNode?.tagName || 'none',
104
+ }))
105
+ // .click() on the element itself, not page.click(): Playwright scrolls a
106
+ // target into view before it clicks, which would move the page for us and
107
+ // hide the very displacement this scenario measures.
108
+ await page.evaluate((key) => document.querySelector(`[data-focus-key="${key}"]`)?.click(), `details:${EXPANDED}`)
109
+ await page.waitForSelector('#session-drawer:not([hidden]) .drawer-timeline', { timeout: 20000 })
110
+ await sleep(1200)
111
+ const openAfter = await page.evaluate(() => ({
112
+ scrollY: Math.round(window.scrollY),
113
+ drawerParent: document.getElementById('session-drawer')?.parentNode?.id || document.getElementById('session-drawer')?.parentNode?.tagName || 'none',
114
+ }))
115
+ const openDrift = Math.abs(openAfter.scrollY - openBefore.scrollY)
116
+
117
+ // scroll so the Timeline is in view, which is where the reader is
118
+ await page.evaluate(() => {
119
+ const t = document.querySelector('#session-drawer [data-scroll-key="timeline"]')
120
+ if (t) window.scrollTo(0, Math.max(0, window.scrollY + t.getBoundingClientRect().top - 300))
121
+ })
122
+ await sleep(300)
123
+
124
+ // a control the reader tabbed to, and a sentence they are half way through
125
+ // selecting: both live inside the expansion and both must survive a push
126
+ await page.evaluate(() => {
127
+ document.querySelector('#session-drawer [data-focus-key="drawer-pause"]')?.focus({ preventScroll: true })
128
+ const p = document.querySelector('#session-drawer .drawer-timeline .timeline-summary')
129
+ if (p && p.firstChild) {
130
+ const r = document.createRange()
131
+ r.selectNodeContents(p)
132
+ const sel = document.getSelection()
133
+ sel.removeAllRanges()
134
+ sel.addRange(r)
135
+ }
136
+ })
137
+
138
+ const read = () => page.evaluate(() => {
139
+ const region = document.getElementById('session-drawer')
140
+ const rect = region ? region.getBoundingClientRect() : null
141
+ const a = document.activeElement
142
+ const timeline = document.querySelector('#session-drawer [data-scroll-key="timeline"]')
143
+ const sel = document.getSelection()
144
+ return {
145
+ scrollY: Math.round(window.scrollY),
146
+ regionTop: rect ? Math.round(rect.top) : null,
147
+ docTop: rect ? Math.round(rect.top + window.scrollY) : null,
148
+ focus: a ? (a.getAttribute?.('data-focus-key') || a.id || a.tagName) : 'none',
149
+ selection: sel ? String(sel).trim().slice(0, 28) : '',
150
+ timelineScroll: timeline ? Math.round(timeline.scrollTop) : null,
151
+ rows: [...document.querySelectorAll('#session-grid .term')].map((r) => r.getAttribute('data-session-id').slice(-4)).join(' '),
152
+ lines: document.querySelectorAll('#session-drawer .drawer-timeline .timeline-item').length,
153
+ // the volume behind the verdict: the raw events those lines stand for,
154
+ // counting a collapsed line as the ×N it carries
155
+ shows: [...document.querySelectorAll('#session-drawer .drawer-timeline .timeline-item')]
156
+ .reduce((n, it) => n + (Number((it.querySelector('.timeline-count')?.textContent || '').replace('×', '')) || 1), 0),
157
+ repeat: document.querySelector('#session-drawer .timeline-count')?.textContent || '',
158
+ }
159
+ })
160
+
161
+ const rows = [{ poll: 0, what: 'settled', ...await read() }]
162
+
163
+ for (let i = 1; i <= POLLS; i++) {
164
+ // a status event on the expanded terminal, the usage-poll spam Wes sees
165
+ appendEvent(EXPANDED, { type: 'status', summary: 'usage read: claude default, five_hour 38%' })
166
+ let what = 'event'
167
+ // and half way through, a row ABOVE it crosses into the needs-you partition
168
+ if (i === 4) {
169
+ updateSession(FLIPS, { status: 'warning', waiting: { type: 'idle_prompt', message: 'Claude is waiting for your input', since: new Date().toISOString() } })
170
+ what = 'row above -> needs-you'
171
+ }
172
+ if (i === 8) {
173
+ updateSession(FLIPS, { status: 'running', waiting: null })
174
+ what = 'row above -> running'
175
+ }
176
+ // 1500ms, not 2200: the region's stand-down under a live selection is
177
+ // capped at DRAWER_HOLD_MS (20s, scenario 3 below), so this phase has to
178
+ // finish inside that cap for "the selection survived every poll" to be a
179
+ // statement about the stand-down rather than about the cap.
180
+ await sleep(1500)
181
+ rows.push({ poll: i, what, ...await read() })
182
+ }
183
+
184
+ // A region that stands down while the reader is selecting text holds its
185
+ // selection trivially, by never redrawing again. The reader letting go is the
186
+ // other half of the contract: the events that arrived while they were reading
187
+ // must land, and the flood of identical status lines must be one line by now.
188
+ const held = rows.length - 1
189
+ await page.evaluate(() => document.getSelection().removeAllRanges())
190
+ for (let i = POLLS + 1; i <= POLLS + 3; i++) {
191
+ appendEvent(EXPANDED, { type: 'status', summary: 'usage read: claude default, five_hour 38%' })
192
+ let what = 'let go'
193
+ if (i === POLLS + 2) {
194
+ updateSession(FLIPS, { status: 'warning', waiting: { type: 'idle_prompt', message: 'Claude is waiting for your input', since: new Date().toISOString() } })
195
+ what = 'row above -> needs-you'
196
+ }
197
+ await sleep(2200)
198
+ rows.push({ poll: i, what, ...await read() })
199
+ }
200
+
201
+ const EXP4 = EXPANDED.slice(-4)
202
+ const FLIP4 = FLIPS.slice(-4)
203
+
204
+ // ---- scenario 2 (finding 6): the needs-you sort under an expansion -------
205
+ // The resting state of a dashboard is one terminal expanded and nobody
206
+ // touching the machine. A different terminal then hits a permission prompt.
207
+ // The hold on `expanded` had no time bound at all, so that row stayed
208
+ // wherever it was for as long as the expansion was open: the board knew (the
209
+ // row went urgent, the tab badge counted it) and would not surface it. It
210
+ // gets the same ORDER_HOLD_MS release hovering and focus already had. What
211
+ // must not move is the expansion's offset in the viewport, which is
212
+ // holdAnchor's job, so both halves are measured here.
213
+ updateSession(FLIPS, { status: 'running', waiting: null })
214
+ await page.evaluate(() => {
215
+ document.getSelection()?.removeAllRanges()
216
+ if (document.activeElement && document.activeElement.blur) document.activeElement.blur()
217
+ })
218
+ await page.mouse.move(2, 2)
219
+ await sleep(4500)
220
+ const settled = await read()
221
+ updateSession(FLIPS, { status: 'warning', waiting: { type: 'idle_prompt', message: 'Claude is waiting for your input', since: new Date().toISOString() } })
222
+ const late = [{ at: 0, what: 'flipped below', ...settled }]
223
+ for (let t = 3; t <= 39; t += 3) {
224
+ await sleep(3000)
225
+ late.push({ at: t, what: '', ...await read() })
226
+ }
227
+ const surfaced = (r) => r.rows.indexOf(FLIP4) >= 0 && r.rows.indexOf(FLIP4) < r.rows.indexOf(EXP4)
228
+ const appliedAt = late.find(surfaced)
229
+ const lateDrift = Math.max(...late.map((r) => Math.abs(r.regionTop - settled.regionTop)))
230
+
231
+ // ---- scenario 3 (finding 18): a selection must not freeze the region -----
232
+ // A double-click leaves an uncollapsed selection behind. The stand-down had
233
+ // no time bound either, so a reader who picked out a path and kept reading
234
+ // never saw another turn land: twelve finished and none of them appeared in
235
+ // sixty seconds, with nothing on screen saying the region was stale. The
236
+ // stand-down is capped at DRAWER_HOLD_MS; the selection dies with the redraw
237
+ // that ends it, which is the price of the reader seeing what is happening.
238
+ await page.evaluate(() => {
239
+ const p = document.querySelector('#session-drawer .drawer-timeline .timeline-summary')
240
+ if (p && p.firstChild) {
241
+ const r = document.createRange()
242
+ r.selectNodeContents(p)
243
+ const sel = document.getSelection()
244
+ sel.removeAllRanges()
245
+ sel.addRange(r)
246
+ }
247
+ })
248
+ await sleep(600)
249
+ const frozenBase = await read()
250
+ // distinct summaries, so collapseEvents cannot fold them into one line and
251
+ // hide the fact that nothing was drawn
252
+ for (let n = 1; n <= 6; n++) appendEvent(EXPANDED, { type: 'turn', summary: `turn ${n} finished, ${n} files` })
253
+ const frozen = [{ at: 0, ...frozenBase }]
254
+ for (let t = 5; t <= 45; t += 5) {
255
+ await sleep(5000)
256
+ frozen.push({ at: t, ...await read() })
257
+ }
258
+ const thawedAt = frozen.find((r) => r.shows > frozenBase.shows)
259
+
260
+ const base0 = rows[0]
261
+ const drift = (r) => Math.abs(r.regionTop - base0.regionTop)
262
+ const worst = Math.max(...rows.map(drift))
263
+ const focusLost = rows.filter((r) => r.focus !== base0.focus).length
264
+ const selLost = rows.slice(0, held + 1).filter((r) => !r.selection).length
265
+ const last = rows[rows.length - 1]
266
+ const caughtUp = last.shows - rows[0].shows
267
+ const verdict = {
268
+ pollsHoldingASelection: held,
269
+ pollsAfterLettingGo: rows.length - 1 - held,
270
+ baselineRegionTop: base0.regionTop,
271
+ worstRegionTopDrift: worst,
272
+ scrollYDrift: Math.max(...rows.map((r) => Math.abs(r.scrollY - base0.scrollY))),
273
+ focusLostPolls: focusLost,
274
+ selectionLostPolls: selLost,
275
+ eventsAfterLettingGo: caughtUp,
276
+ timelineLines: last.lines,
277
+ repeatCollapsedTo: last.repeat,
278
+ // scenario 1: the click that opens the expansion
279
+ openScrollYBefore: openBefore.scrollY,
280
+ openScrollYAfter: openAfter.scrollY,
281
+ openScrollYDrift: openDrift,
282
+ // scenario 2: the needs-you sort under an expansion
283
+ needsYouSurfacedAfterS: appliedAt ? appliedAt.at : null,
284
+ orderSamples: late.length,
285
+ regionTopDriftWhileResorting: lateDrift,
286
+ // scenario 3: the region under a forgotten selection
287
+ regionCaughtUpAfterS: thawedAt ? thawedAt.at : null,
288
+ eventsHeldBack: 6,
289
+ eventsLanded: thawedAt ? thawedAt.shows - frozenBase.shows : 0,
290
+ consoleErrors: errors,
291
+ pass: worst <= 2 && focusLost === 0 && selLost === 0 && caughtUp >= POLLS && last.repeat !== ''
292
+ && openDrift <= 2
293
+ && Boolean(appliedAt) && lateDrift <= 2
294
+ && Boolean(thawedAt) && thawedAt.at <= 45,
295
+ }
296
+
297
+ if (asJson) console.log(JSON.stringify({ rows, open: [openBefore, openAfter], late, frozen, verdict }, null, 2))
298
+ else {
299
+ const table = (title, cols, data) => {
300
+ console.log(title)
301
+ const w = cols.map((c) => Math.max(c.length, ...data.map((r) => String(r[c] ?? '').length)))
302
+ const line = (vals) => vals.map((v, i) => String(v ?? '').padEnd(w[i])).join(' ')
303
+ console.log(line(cols))
304
+ console.log(w.map((n) => '-'.repeat(n)).join(' '))
305
+ for (const r of data) console.log(line(cols.map((c) => r[c])))
306
+ console.log('')
307
+ }
308
+ table('the expansion under ten pushes (a selection held, then let go)',
309
+ ['poll', 'what', 'scrollY', 'regionTop', 'docTop', 'focus', 'selection', 'lines', 'shows', 'repeat', 'rows'], rows)
310
+ table('scenario 1: the click that opens the expansion (finding 5)',
311
+ ['when', 'scrollY', 'drawerParent'],
312
+ [{ when: 'before Details', ...openBefore }, { when: 'after Details', ...openAfter }])
313
+ table('scenario 2: a terminal goes needs-you under an open expansion (finding 6)',
314
+ ['at', 'what', 'rows', 'regionTop', 'scrollY'], late)
315
+ table('scenario 3: the region under a forgotten selection (finding 18)',
316
+ ['at', 'lines', 'shows', 'selection'], frozen)
317
+ console.log(`regionTop drift: ${worst}px (must be <= 2) scrollY drift: ${verdict.scrollYDrift}px`)
318
+ console.log(`focus lost on ${focusLost} of ${rows.length - 1} polls selection lost on ${selLost} of ${held} polls holding one`)
319
+ console.log(`after letting go: ${caughtUp} new events landed in the region, drawn as ${last.lines} line${last.lines === 1 ? '' : 's'}, repeats collapsed to ${last.repeat || 'nothing'}`)
320
+ console.log(`opening the expansion moved the page ${openDrift}px (${openBefore.scrollY} -> ${openAfter.scrollY}, must be <= 2)`)
321
+ console.log(appliedAt
322
+ ? `needs-you surfaced ${appliedAt.at}s after the flip, over ${late.length} samples, with the expansion held to ${lateDrift}px in the viewport`
323
+ : `needs-you NEVER surfaced in ${late[late.length - 1].at}s of an open expansion, over ${late.length} samples: rows stayed "${late[late.length - 1].rows}"`)
324
+ console.log(thawedAt
325
+ ? `the region caught up ${thawedAt.at}s after 6 turns landed under a live selection (${thawedAt.shows - frozenBase.shows} of 6 drawn)`
326
+ : `the region NEVER caught up: 6 turns landed and ${frozen[frozen.length - 1].shows - frozenBase.shows} were drawn in ${frozen[frozen.length - 1].at}s`)
327
+ if (errors.length) console.log(`console errors: ${errors.length}\n ${errors.slice(0, 5).join('\n ')}`)
328
+ console.log(verdict.pass ? 'PASS: the expansion held still under the reader' : 'FAIL: the page moved under the reader')
329
+ }
330
+ process.exitCode = verdict.pass ? 0 : 1
331
+ } finally {
332
+ await browser?.close().catch(() => {})
333
+ await srv.stop().catch(() => {})
334
+ cleanup()
335
+ }
@@ -199,13 +199,13 @@ function page ({ slug, title, description, body, headings }) {
199
199
  <meta property="og:url" content="${url}">
200
200
  <meta property="og:site_name" content="Leg">
201
201
  <meta property="og:title" content="${escapeHtml(title)}">
202
- <meta property="og:image" content="${ORIGIN}/og.png?v=3">
203
- <meta property="og:image:secure_url" content="${ORIGIN}/og.png?v=3">
202
+ <meta property="og:image" content="${ORIGIN}/og.png?v=4">
203
+ <meta property="og:image:secure_url" content="${ORIGIN}/og.png?v=4">
204
204
  <meta property="og:image:type" content="image/png">
205
205
  <meta name="twitter:card" content="summary_large_image">
206
206
  <meta name="twitter:title" content="${escapeHtml(title)}">
207
207
  <meta name="twitter:description" content="${escapeHtml(description)}">
208
- <meta name="twitter:image" content="${ORIGIN}/og.png?v=3">
208
+ <meta name="twitter:image" content="${ORIGIN}/og.png?v=4">
209
209
  <link rel="preload" href="/fonts/atkinson-hyperlegible-next-var.woff2" as="font" type="font/woff2" crossorigin>
210
210
  <link rel="preload" href="/fonts/azeret-mono-var.woff2" as="font" type="font/woff2" crossorigin>
211
211
  <link rel="stylesheet" href="/style.css">
package/src/attach.mjs CHANGED
@@ -23,15 +23,15 @@ import { ensure as ensureWorktree, remove as removeWorktree } from './worktree.m
23
23
  import { canonPath, realPath } from './fsx.mjs'
24
24
  import { whoami, readShare, isOn as shareIsOn } from './share.mjs'
25
25
  import { readAccounts, envFor, refreshAccount } from './accounts.mjs'
26
- import { recordUsage, markLimited, chooseNext, candidates, fmtReset, WARN_PCT, readUsage, isAvailable, wallActive, rungLabel, skipLine } from './usage.mjs'
26
+ import { recordUsage, markLimited, chooseNext, candidates, fmtReset, WARN_PCT, readUsage, usageIsStale, isAvailable, wallActive, rungLabel, skipLine } from './usage.mjs'
27
27
  import { entitlement, allows, describe as describeLicense } from './license.mjs'
28
28
  import { writeSettings, userStatusLine, transcriptTail as claudeTail, modelAlias, modelFromTranscript, printable } from './taps/claude.mjs'
29
29
  import { modelFlagFor, isDownshift } from './buckets.mjs'
30
30
  import { ensureTrust, trustLine } from './trust.mjs'
31
31
  import { findRollout, createTail, parseLines, readCodexUsage, transcriptTail as codexTail } from './taps/codex.mjs'
32
+ import { fetchClaudeUsage } from './taps/claude-usage.mjs'
32
33
  import { scanLog, promptsSince, logSize } from './taps/agy.mjs'
33
34
  import { fetchGrokUsage, scanLog as scanGrokLog, promptsSince as grokPromptsSince } from './taps/grok.mjs'
34
- import { fetchClaudeUsage } from './taps/claude-usage.mjs'
35
35
  import { saveSessionBundle, resumePrompt, sessionCommitDelta } from './bundle.mjs'
36
36
  import { endSessionPointer } from './resume.mjs'
37
37
  import { openBoard, pidfile } from './launcher.mjs'
@@ -56,6 +56,12 @@ const SERVER = resolveServer()
56
56
  const POLL_MS = Number(process.env.LEG_ATTACH_POLL_MS || process.env.BATON_ATTACH_POLL_MS || 2000)
57
57
  const GIT_EVERY = 3 // polls
58
58
  const USAGE_MS = Number(process.env.LEG_USAGE_POLL_MS || process.env.BATON_USAGE_POLL_MS || 60000)
59
+ // One switch for every usage read in a leg's process tree, the board's poller
60
+ // and this terminal's fallback alike (src/server.mjs usageAgentsFor). The
61
+ // suites set it so no test asks a real endpoint about this machine's logins;
62
+ // the *_BIN stubs used to do that by accident, and switched polling off for
63
+ // real users who had simply moved their claude.
64
+ const NO_USAGE_POLL = (process.env.LEG_NO_USAGE_POLL || process.env.BATON_NO_USAGE_POLL) === '1'
59
65
  const say = (line) => process.stderr.write(`[leg] ${line}\n`)
60
66
 
61
67
  async function refreshCodexUsage(account, codexHome, { timeoutMs = 8000, signal = null } = {}) {
@@ -465,77 +471,75 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
465
471
  child.on('error', (err) => { appendEvent(sid, { type: 'error', summary: `${agent} spawn error: ${err.message}` }); stop({ reason: 'exit', code: 127 }) })
466
472
  child.on('exit', (code) => stop({ reason: 'exit', code: code ?? -1 }))
467
473
 
468
- // claude: the 5h/7d percentages come from Claude Code's usage endpoint
469
- // (src/taps/claude-usage.mjs); the wall itself arrives through the
470
- // StopFailure hook.
474
+ // The percentages are NOT read here. One poller per login lives in the board
475
+ // (src/usage-poll.mjs) and writes `limits`, `usage_source` and `usage_error`
476
+ // onto this session: three terminals on one login used to ask the same
477
+ // endpoint three times a minute, draw a 429 every other minute, and print
478
+ // every one of them in this terminal's timeline.
479
+ //
480
+ // What is still this terminal's own: which model claude is actually
481
+ // answering on. The transcript path arrives on the SessionStart hook payload
482
+ // (src/taps/claude.mjs `handleHook`, `base`), so this only reads once Claude
483
+ // Code has told Leg where its jsonl is. A fallback off fable shows up here
484
+ // and nowhere else.
471
485
  let usageTimer = null
472
- const usageAbort = new AbortController()
473
486
  if (agent === 'claude') {
474
- const pollUsage = async () => {
475
- const r = await fetchClaudeUsage({ configDir: spec.env.CLAUDE_CONFIG_DIR || LAYOUT.claude.home() })
487
+ const pollModel = () => {
476
488
  const s = readSession(sid)
477
489
  if (!isCurrentLeg(s, { pid: child.pid, agent, account })) return
478
- // a 404, a body that is not JSON, or a shape with no window at all: the
479
- // card says usage unknown and the StopFailure hook still owns the limit
480
- // which model actually answered. The transcript path arrives on the
481
- // SessionStart hook payload (src/taps/claude.mjs `handleHook`, `base`),
482
- // so this only reads once Claude Code has told Leg where its jsonl is.
483
- // A fallback off fable shows up here and nowhere else.
484
490
  const seen = modelFromTranscript(s.transcript_path, { agent: 'claude' })
485
491
  if (seen && seen !== s.model) {
486
492
  updateSession(sid, { model: seen }, { event: { type: 'status', summary: `claude is answering on ${seen}${s.model ? ` (was ${s.model})` : ''}` } })
487
493
  }
488
- const usable = r.ok && r.limits && (r.limits.five_hour || r.limits.seven_day)
489
- if (usable) {
490
- recordUsage('claude', account, r.limits, 'claude usage endpoint')
491
- // the session record keeps the two windows it always had: the buckets
492
- // live on the usage record, which is per login and not per terminal
493
- updateSession(sid, { limits: { five_hour: r.limits.five_hour, seven_day: r.limits.seven_day }, usage_source: 'claude usage endpoint', usage_error: null })
494
- } else if (!s.usage_error) {
495
- const why = r.error ?? 'the usage endpoint answered with no window'
496
- updateSession(sid, { usage_error: why }, { event: { type: 'status', summary: `claude usage unavailable: ${why}` } })
497
- }
498
- }
499
- pollUsage().catch(() => {})
500
- usageTimer = setInterval(() => pollUsage().catch(() => {}), USAGE_MS)
501
- usageTimer.unref?.()
502
- } else if (agent === 'codex' && !(process.env.LEG_CODEX_BIN || process.env.BATON_CODEX_BIN)) {
503
- const pollUsage = async () => {
504
- const r = await refreshCodexUsage(account, spec.env.CODEX_HOME || LAYOUT.codex.home(), { signal: usageAbort.signal })
505
- const s = readSession(sid)
506
- if (!isCurrentLeg(s, { pid: child.pid, agent, account })) return
507
- if (r.ok) {
508
- const patch = { limits: r.limits, usage_source: 'codex app-server account/rateLimits/read', usage_error: null }
509
- if (r.available === false) {
510
- patch.status = 'limit'
511
- patch.limit = { reason: 'usage_limit_exceeded', detail: 'Codex reports ordinary usage is unavailable', resets_at: r.usage.limited_until, at: r.observed_at }
512
- }
513
- updateSession(sid, patch)
514
- } else if (!s.usage_error) {
515
- updateSession(sid, { usage_error: r.error }, { event: { type: 'status', summary: `codex usage unavailable: ${r.error}` } })
516
- }
517
494
  }
518
- pollUsage().catch(() => {})
519
- usageTimer = setInterval(() => pollUsage().catch(() => {}), USAGE_MS)
495
+ const safely = () => { try { pollModel() } catch {} }
496
+ safely()
497
+ usageTimer = setInterval(safely, USAGE_MS)
520
498
  usageTimer.unref?.()
521
- } else if (agent === 'grok') {
499
+ }
500
+
501
+ // The near-wall warning below is computed from the percentages on this
502
+ // session, and for claude and grok those are written by the board's poller
503
+ // (src/usage-poll.mjs). With LEG_NO_BOARD=1, or a board that is down, nobody
504
+ // is reading that login at all, and the 85% warning never fired: no bell, no
505
+ // nudge, straight into the wall. So the terminal watches the login's own
506
+ // record and, when nothing has refreshed it for five minutes, reads the
507
+ // endpoint itself at the old once-a-minute cadence. A board that is polling
508
+ // keeps that record fresh, so with one up this costs a readUsage() a minute
509
+ // and no request. Said once, so a terminal doing its own reading is never a
510
+ // mystery.
511
+ let fallbackTimer = null
512
+ if ((agent === 'claude' || agent === 'grok') && !NO_USAGE_POLL) {
513
+ const source = agent === 'claude' ? 'claude usage endpoint' : 'grok billing proxy'
514
+ const readOwn = () => (agent === 'claude'
515
+ ? fetchClaudeUsage({ configDir: spec.env.CLAUDE_CONFIG_DIR || LAYOUT.claude.home() })
516
+ : fetchGrokUsage({ configDir: spec.env.GROK_HOME || LAYOUT.grok.home() }))
517
+ let announced = false
522
518
  const pollUsage = async () => {
523
- const configDir = spec.env.GROK_HOME || LAYOUT.grok.home()
524
- const r = await fetchGrokUsage({ configDir })
519
+ if (!isCurrentLeg(readSession(sid), { pid: child.pid, agent, account })) return
520
+ if (!usageIsStale(readUsage(agent, account))) return // a board is reading this login
521
+ if (!announced) { announced = true; say(`no board is reading ${agent} usage, so this terminal reads it itself once a minute`) }
522
+ const r = await readOwn()
525
523
  const s = readSession(sid)
526
524
  if (!isCurrentLeg(s, { pid: child.pid, agent, account })) return
527
525
  const usable = r.ok && r.limits && (r.limits.five_hour || r.limits.seven_day)
528
526
  if (usable) {
529
- recordUsage('grok', account, r.limits, 'grok billing proxy')
530
- updateSession(sid, { limits: r.limits, usage_source: 'grok billing proxy', usage_error: null })
527
+ recordUsage(agent, account, r.limits, source)
528
+ // the two windows the card has always carried; the buckets stay on the
529
+ // usage record, which is per login and not per terminal
530
+ const limits = agent === 'claude' ? { five_hour: r.limits.five_hour, seven_day: r.limits.seven_day } : r.limits
531
+ updateSession(sid, { limits, usage_source: source, usage_error: null })
531
532
  } else if (!s.usage_error) {
532
533
  const why = r.error ?? 'the usage endpoint answered with no window'
533
- updateSession(sid, { usage_error: why }, { event: { type: 'status', summary: `grok usage unavailable: ${why}` } })
534
+ updateSession(sid, { usage_error: why }, { event: { type: 'status', summary: `${agent} usage unavailable: ${why}` } })
534
535
  }
535
536
  }
536
- pollUsage().catch(() => {})
537
- usageTimer = setInterval(() => pollUsage().catch(() => {}), USAGE_MS)
538
- usageTimer.unref?.()
537
+ const safelyUsage = () => { pollUsage().catch(() => {}) }
538
+ // LEG_NO_BOARD=1 means nobody will ever poll this login, so the reading
539
+ // happens at once rather than leaving the terminal's first minute blind
540
+ if (!boardUrl) safelyUsage()
541
+ fallbackTimer = setInterval(safelyUsage, USAGE_MS)
542
+ fallbackTimer.unref?.()
539
543
  }
540
544
 
541
545
  const timer = setInterval(() => {
@@ -666,9 +670,8 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
666
670
  timer.unref?.()
667
671
  const result = await done
668
672
  clearInterval(timer)
669
- usageAbort.abort()
670
673
  if (usageTimer) clearInterval(usageTimer)
671
- void boardUrl
674
+ if (fallbackTimer) clearInterval(fallbackTimer)
672
675
  return result
673
676
  }
674
677
 
@@ -899,7 +902,7 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
899
902
  }
900
903
  if (iso) updateSession(sid, { cwd: iso.cwd, branch: iso.branch, worktree: { path: iso.path, branch: iso.branch, base: iso.base } })
901
904
  updateSession(sid, { head_at_start: g.head, head: g.head, files_dirty: iso ? [] : g.dirty, board_url: board.url })
902
- say(`session ${sid} · ${agent}${account !== 'default' ? '/' + account : ''} · board ${board.url ?? 'off'}${board.started ? ' (started)' : ''} · next: ${chain.map((c) => c.agent + (c.account !== 'default' ? '/' + c.account : '')).join(' → ') || 'none'}`)
905
+ say(`session ${sid} · ${agent}${account !== 'default' ? '/' + account : ''} · board ${board.url ?? 'off'}${board.started ? ' (started)' : ''} · next: ${chain.map(rungLabel).join(' → ') || 'none'}`)
903
906
  if (iso) {
904
907
  const others = iso.live.map((s) => `${s.agent} ${s.session_id.split('-').pop()}`).join(', ')
905
908
  say(`another session is live in this checkout (${others}): this one works in ${iso.cwd} on ${iso.branch}; Land on its card brings it to ${iso.base ?? 'nothing (detached HEAD)'} · --no-worktree to share`)
@@ -589,8 +589,6 @@ code { font-family: var(--mono); font-size: 0.94em; }
589
589
  .ladder-add { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 10px 0 2px; font-size: var(--t-0); border-top: 1px solid var(--line); }
590
590
  .ladder-policy { display: grid; gap: 8px; margin-top: 18px; border: 0; padding: 0; }
591
591
  .ladder-policy legend { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); padding: 0; }
592
- /* the label on each agent row in the New card dialog */
593
- .fallback-row-title { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
594
592
  .confirm-row { margin-top: 12px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; background: var(--e4); border-radius: var(--r-control); padding: 12px 14px; font-size: var(--t-0); }
595
593
  .actions { display: flex; gap: 10px; flex-wrap: wrap; }
596
594
 
@@ -716,3 +714,87 @@ dialog::backdrop { background: rgba(0,0,0,.6); }
716
714
  * { transition: none !important; }
717
715
  .btn:active { transform: none; }
718
716
  }
717
+
718
+ /* ---- appended 2026-09-18: the timeline's repeat count -------------------- */
719
+ /* A run of identical status lines collapses to one line carrying ×N
720
+ (collapseEvents in sessions.js). The count is a quantity beside a sentence,
721
+ not part of it, so it is muted and set in the tabular face the rest of the
722
+ board counts in. */
723
+ .timeline-count { margin-left: 8px; color: var(--text-3); font-variant-numeric: tabular-nums; font-size: var(--t--1); }
724
+ /* ---- end of the 2026-09-18 block ---------------------------------------- */
725
+
726
+ /* ---- appended 2026-09-18: the new card dialog's two columns -------------- */
727
+ /* The dialog asks two questions and they are read in parallel, not in series:
728
+ WHAT the work is on the left, WHO runs it on the right. One column under
729
+ 900px, because at a phone's width two columns of selects is one column of
730
+ selects with half the room. The dialog itself widens only where there are
731
+ two columns to hold; under the breakpoint the base `dialog` rule still caps
732
+ it at the viewport. */
733
+ .dialog-cols { display: grid; gap: 4px 28px; }
734
+ .dialog-col { min-width: 0; }
735
+ /* the second label inside one .form-row: the path under the repo picker. It is
736
+ a label, not a heading, so it is quieter than the field's own. */
737
+ .sub-label { font-weight: var(--w-text); color: var(--text-3); }
738
+ .form-row .sub-label { font-size: var(--t--1); }
739
+
740
+ /* One rung of "Who runs it": a number, a provider, a model, its permissions,
741
+ its limits, and the three buttons that move or remove it. It wraps rather
742
+ than scrolls, so a narrow dialog stacks the controls of one row instead of
743
+ hiding them off the right edge. Separated from the row above by --line, the
744
+ only rule allowed inside a panel. */
745
+ .chain-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 10px 0; font-size: var(--t-0); }
746
+ .chain-row + .chain-row { border-top: 1px solid var(--line); }
747
+ .chain-num { flex: none; color: var(--text-3); font-variant-numeric: tabular-nums; }
748
+ .chain-row select { flex: 0 1 auto; min-width: 0; max-width: 100%; font-size: var(--t--1); }
749
+ .chain-toggle { display: inline-flex; align-items: center; gap: 6px; font-size: var(--t--1); color: var(--text-3); }
750
+ .chain-turns { flex: none; width: 11ch; font-size: var(--t--1); }
751
+ .chain-fake { flex: none; width: 16ch; font-size: var(--t--1); }
752
+ .chain-row .btn { font-size: var(--t--1); }
753
+ /* the three row buttons sit apart from the row's fields: wrapped onto a line
754
+ of their own, `max turns` beside Up/Down/Remove read as one group and the
755
+ number looked like it belonged to the buttons. */
756
+ .chain-row .btn:first-of-type { margin-left: auto; }
757
+
758
+ /* A dialog taller than the window has to scroll itself, or Create card is off
759
+ the bottom of the screen with no way to reach it. Both widths did exactly
760
+ that: 960px of dialog in a 900px window. */
761
+ dialog { max-height: calc(100vh - 40px); overflow-y: auto; }
762
+
763
+ @media (min-width: 900px) {
764
+ #new-card-dialog { max-width: 980px; }
765
+ .dialog-cols { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
766
+ }
767
+ /* ---- end of the 2026-09-18 dialog block --------------------------------- */
768
+
769
+ /* ---- the floor, 2026-09-18: stations instead of tables ------------------ */
770
+ /* The entry row is the first thing under the strip and the only control on the
771
+ page that starts work, so it sits on the raised surface the rows sit on
772
+ rather than in the page's own ground. The board's copy is inside the
773
+ Background panel and inherits its surface; this one has none to inherit. */
774
+ .floor-entry { margin-top: 20px; background: var(--e2); border: 1px solid var(--edge); border-radius: var(--r-well); padding: 20px 24px; }
775
+ .floor-entry .confirm-row { margin-top: 0; }
776
+ .floor-entry .confirm-row input[type="text"] { flex: 1 1 320px; min-width: 0; }
777
+ .floor-entry .field-help { margin-top: 10px; }
778
+ /* the count belongs to the heading it is in, one step quieter than the word */
779
+ .region-count { margin-left: 10px; font-size: var(--t-0); font-weight: var(--w-text); color: var(--text-3); font-variant-numeric: tabular-nums; }
780
+ /* the keyboard ring on a card row, the same 3px edge mark the terminal rows
781
+ carry (`.term.is-focused`) rather than a fill */
782
+ .row.is-focused { box-shadow: inset 3px 0 0 var(--focus); }
783
+
784
+ @media (max-width: 760px) {
785
+ /* R4 is a 272px block in a fixed second column: under about 390px of content
786
+ width that column alone is wider than the row, and both pages scrolled
787
+ sideways. One column, with the clock and the buttons under the sentence. */
788
+ .row { grid-template-columns: minmax(0, 1fr); padding: 22px; }
789
+ .r4 { grid-column: 1; grid-row: auto; flex-direction: row; align-items: center; flex-wrap: wrap; gap: 10px 14px; margin-top: 14px; }
790
+ .r4 .row-actions { width: 100%; }
791
+ .floor-entry { padding: 18px; }
792
+ .floor-entry .confirm-row { align-items: stretch; flex-direction: column; }
793
+ .floor-entry .confirm-row input[type="text"] { flex: 1 1 auto; }
794
+ }
795
+
796
+ /* The heading over a GROUP of controls, not over one: "Who runs it" names the
797
+ whole chain-rows group through aria-labelledby, so it cannot be a <label> --
798
+ a label with no control focuses nothing when it is clicked, which is the one
799
+ thing a label promises. Same face as the row labels beside it. */
800
+ .form-row .form-label { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }