@ucsandman/legcli 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/README.md +1 -1
- package/docs/DECISIONS.md +8 -0
- package/docs/ERRORS.md +27 -0
- package/docs/board-guide.md +129 -31
- package/docs/cli-contracts.md +42 -0
- package/docs/configuration.md +8 -2
- 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/src/attach.mjs +59 -56
- 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
|
@@ -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
|
+
}
|
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
|
-
//
|
|
469
|
-
// (src/
|
|
470
|
-
//
|
|
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
|
|
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
|
-
|
|
519
|
-
|
|
495
|
+
const safely = () => { try { pollModel() } catch {} }
|
|
496
|
+
safely()
|
|
497
|
+
usageTimer = setInterval(safely, USAGE_MS)
|
|
520
498
|
usageTimer.unref?.()
|
|
521
|
-
}
|
|
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
|
-
|
|
524
|
-
|
|
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(
|
|
530
|
-
|
|
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:
|
|
534
|
+
updateSession(sid, { usage_error: why }, { event: { type: 'status', summary: `${agent} usage unavailable: ${why}` } })
|
|
534
535
|
}
|
|
535
536
|
}
|
|
536
|
-
pollUsage().catch(() => {})
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
674
|
+
if (fallbackTimer) clearInterval(fallbackTimer)
|
|
672
675
|
return result
|
|
673
676
|
}
|
|
674
677
|
|
package/src/board/board.css
CHANGED
|
@@ -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); }
|