@ucsandman/legcli 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/README.md +95 -65
  3. package/bin/leg.mjs +123 -14
  4. package/docs/DECISIONS.md +18 -0
  5. package/docs/DEMO.md +20 -14
  6. package/docs/DEVIATIONS.md +1 -0
  7. package/docs/ERRORS.md +68 -0
  8. package/docs/ROADMAP-v2.md +50 -5
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/board-guide.md +529 -96
  11. package/docs/cli-contracts.md +241 -5
  12. package/docs/concepts.md +167 -19
  13. package/docs/configuration.md +65 -1
  14. package/docs/faq.md +21 -5
  15. package/docs/getting-started.md +15 -11
  16. package/docs/redesign-2026-09-17.md +477 -0
  17. package/docs/screenshots/background-1280.png +0 -0
  18. package/docs/screenshots/board-400px.png +0 -0
  19. package/docs/screenshots/board-details-open.png +0 -0
  20. package/docs/screenshots/board-drawer.png +0 -0
  21. package/docs/screenshots/board-handoff.png +0 -0
  22. package/docs/screenshots/board-running.png +0 -0
  23. package/docs/screenshots/capacity-drawer-1280.png +0 -0
  24. package/docs/screenshots/floor.png +0 -0
  25. package/docs/screenshots/new-card-dialog.png +0 -0
  26. package/docs/screenshots/settings-ladder-1280.png +0 -0
  27. package/docs/screenshots/terminals-1280.png +0 -0
  28. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  29. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  30. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  31. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  32. package/fixtures/live/claude/resume-model-probe.json +20 -0
  33. package/fixtures/live/claude/usage-oauth.json +87 -0
  34. package/fixtures/verified.json +1 -1
  35. package/package.json +3 -2
  36. package/scripts/board-jump-probe.mjs +335 -0
  37. package/scripts/seed-fake-cards.mjs +59 -6
  38. package/scripts/seed-wes-board.mjs +81 -12
  39. package/src/accounts.mjs +6 -1
  40. package/src/attach.mjs +378 -93
  41. package/src/audit.mjs +1 -1
  42. package/src/board/board.css +203 -11
  43. package/src/board/board.js +664 -200
  44. package/src/board/entry.js +343 -0
  45. package/src/board/floor.html +51 -39
  46. package/src/board/floor.js +585 -73
  47. package/src/board/index.html +122 -45
  48. package/src/board/sessions.js +1569 -141
  49. package/src/board/strip.js +163 -0
  50. package/src/buckets.mjs +101 -0
  51. package/src/cards.mjs +9 -1
  52. package/src/chain.mjs +13 -0
  53. package/src/hook.mjs +7 -1
  54. package/src/ledger.mjs +10 -2
  55. package/src/models.mjs +265 -0
  56. package/src/orchestrator.mjs +13 -4
  57. package/src/preferences.mjs +278 -5
  58. package/src/scheduler.mjs +24 -1
  59. package/src/server.mjs +625 -78
  60. package/src/sessions.mjs +17 -1
  61. package/src/taps/claude-usage.mjs +107 -3
  62. package/src/taps/claude.mjs +144 -5
  63. package/src/taps/codex.mjs +23 -3
  64. package/src/usage-poll.mjs +260 -0
  65. package/src/usage.mjs +439 -12
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,11 @@
1
+ {
2
+ "id": "claude-fable-limit",
3
+ "adapter": "claude",
4
+ "source": "observed-live",
5
+ "produced_by": "fixtures/live/claude/limit-rate_limit.json: StopFailure error rate_limit, last_assistant_message on the real run, 2026-09-11 (a per-model wall: the account's session and weekly windows were open)",
6
+ "where": "any",
7
+ "text": "You've reached your Fable limit. Run /usage-credits to continue or switch models with /model.",
8
+ "pattern": "You.ve reached your Fable limit",
9
+ "exit_code": null,
10
+ "classification": "limit"
11
+ }
@@ -5,7 +5,7 @@
5
5
  "produced_by": "https://code.claude.com/docs/en/costs (\"You’ve hit your Opus limit\" / \"You’ve hit your Sonnet limit\")",
6
6
  "where": "any",
7
7
  "text": "You’ve hit your Opus limit",
8
- "pattern": "You.ve hit your (?!session |weekly |usage )[A-Za-z0-9.-]+ limit",
8
+ "pattern": "You.ve (hit|reached) your (?!session |weekly |usage )[A-Za-z0-9.-]+ limit",
9
9
  "exit_code": null,
10
10
  "classification": "limit"
11
11
  }
@@ -5,7 +5,7 @@
5
5
  "produced_by": "https://code.claude.com/docs/en/costs (section: When a developer asks about a limit)",
6
6
  "where": "any",
7
7
  "text": "You’ve hit your session limit",
8
- "pattern": "You.ve hit your session limit",
8
+ "pattern": "You.ve (hit|reached) your session limit",
9
9
  "exit_code": null,
10
10
  "classification": "limit"
11
11
  }
@@ -5,7 +5,7 @@
5
5
  "produced_by": "https://code.claude.com/docs/en/costs (section: When a developer asks about a limit)",
6
6
  "where": "any",
7
7
  "text": "You’ve hit your weekly limit",
8
- "pattern": "You.ve hit your weekly limit",
8
+ "pattern": "You.ve (hit|reached) your weekly limit",
9
9
  "exit_code": null,
10
10
  "classification": "limit"
11
11
  }
@@ -0,0 +1,20 @@
1
+ {
2
+ "agent": "claude",
3
+ "signal": "resume-with-model",
4
+ "captured_at": "2026-09-17T21:40:00Z",
5
+ "source": "observed-live",
6
+ "claude_version": "2.1.274",
7
+ "probe": [
8
+ "echo 'reply with exactly: ok' | claude -p --model sonnet --output-format json --max-turns 1",
9
+ "echo 'what did I ask you to reply with before? answer in 5 words' | claude -p --resume <session_id> --model haiku --output-format json --max-turns 1"
10
+ ],
11
+ "observed": {
12
+ "same_session_id": true,
13
+ "conversation_kept": true,
14
+ "second_turn_result": "To reply with exactly ok.",
15
+ "second_turn_modelUsage_keys": ["claude-haiku-4-5-20251001"],
16
+ "second_turn_cacheReadInputTokens": 0,
17
+ "second_turn_is_error": false
18
+ },
19
+ "conclusion": "`--resume <id> --model <alias>` composes: the session id is kept, the prior turn is remembered, and only the new model answers. The resumed context is re-read at the new model's rates (cache read 0 on the first resumed turn), so a downshift keeps the conversation and an upshift pays the re-read at the higher price. Settles spec G1 (docs/redesign-2026-09-17.md)."
20
+ }
@@ -0,0 +1,87 @@
1
+ {
2
+ "agent": "claude",
3
+ "signal": "usage",
4
+ "source": "observed-live",
5
+ "captured_at": "2026-09-17T20:16:00Z",
6
+ "endpoint": "GET https://api.anthropic.com/api/oauth/usage",
7
+ "status": 200,
8
+ "produced_by": "docs/redesign-2026-09-17.md \"What I verified myself before writing\" (2026-09-17 20:16Z, HTTP 200, 23 keys) and the tournament candidate transcript that printed the raw limits[] rows (kind, group, percent, severity, resets_at as ISO, scope{model{id,display_name},surface}, is_active), fetched through src/taps/claude-usage.mjs fetchClaudeUsage",
9
+ "scrubbed": "no ids, no tokens, no account identifiers: only the limit rows, the two legacy windows and the spend block Leg reads",
10
+ "verified": [
11
+ "limits[] holds session 29%, weekly_all 47% and weekly_scoped 63% with scope.model.display_name \"Fable\" and is_active true",
12
+ "seven_day_opus and seven_day_sonnet are null",
13
+ "extra_usage.is_enabled false, disabled_reason \"out_of_credits\", monthly_limit 12500 (minor units)",
14
+ "spend.can_toggle false, can_purchase_credits false",
15
+ "seven_day_breakdown: Claude Code 100, Chats 0, Cowork 0"
16
+ ],
17
+ "payload": {
18
+ "five_hour": {
19
+ "utilization": 29,
20
+ "resets_at": "2026-09-17T20:30:00Z"
21
+ },
22
+ "seven_day": {
23
+ "utilization": 47,
24
+ "resets_at": "2026-09-23T19:00:00Z"
25
+ },
26
+ "seven_day_opus": null,
27
+ "seven_day_sonnet": null,
28
+ "limits": [
29
+ {
30
+ "kind": "session",
31
+ "group": "session",
32
+ "percent": 29,
33
+ "severity": "normal",
34
+ "resets_at": "2026-09-17T20:30:00Z",
35
+ "scope": null,
36
+ "is_active": false
37
+ },
38
+ {
39
+ "kind": "weekly_all",
40
+ "group": "weekly",
41
+ "percent": 47,
42
+ "severity": "normal",
43
+ "resets_at": "2026-09-23T19:00:00Z",
44
+ "scope": null,
45
+ "is_active": false
46
+ },
47
+ {
48
+ "kind": "weekly_scoped",
49
+ "group": "weekly",
50
+ "percent": 63,
51
+ "severity": "normal",
52
+ "resets_at": "2026-09-23T19:00:00Z",
53
+ "scope": {
54
+ "model": {
55
+ "id": null,
56
+ "display_name": "Fable"
57
+ },
58
+ "surface": null
59
+ },
60
+ "is_active": true
61
+ }
62
+ ],
63
+ "seven_day_breakdown": [
64
+ {
65
+ "name": "Claude Code",
66
+ "percentage": 100
67
+ },
68
+ {
69
+ "name": "Chats",
70
+ "percentage": 0
71
+ },
72
+ {
73
+ "name": "Cowork",
74
+ "percentage": 0
75
+ }
76
+ ],
77
+ "extra_usage": {
78
+ "is_enabled": false,
79
+ "disabled_reason": "out_of_credits",
80
+ "monthly_limit": 12500
81
+ },
82
+ "spend": {
83
+ "can_toggle": false,
84
+ "can_purchase_credits": false
85
+ }
86
+ }
87
+ }
@@ -2,7 +2,7 @@
2
2
  "_comment": "The single source of truth for every dated, numbered claim Leg makes in public. scripts/check-claims.mjs asserts that each surface listed here states exactly these values, and --strict additionally compares the pinned CLI versions against what those CLIs currently publish. Re-verify on a real machine, then edit this file and every surface in the same commit.",
3
3
  "verifiedOn": "2026-09-11",
4
4
  "verifiedOnLong": "11 September 2026",
5
- "tests": 674,
5
+ "tests": 926,
6
6
  "clis": {
7
7
  "claude": {
8
8
  "label": "Claude Code",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ucsandman/legcli",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Usage-limit monitor and automatic handoff for Claude Code, Codex, agy and Grok. Type leg claude|codex|agy|grok and get the same interactive agent with a board alongside, auto-approve on by default, usage tracking per agent and account, a live context handoff bundle, and at the limit the next agent continuing in the same terminal. $79 once, 30-day money-back guarantee.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -67,6 +67,7 @@
67
67
  "@eslint/js": "^9",
68
68
  "eslint": "^9",
69
69
  "globals": "^16",
70
- "marked": "^18.0.13"
70
+ "marked": "^18.0.13",
71
+ "playwright": "^1.63.0"
71
72
  }
72
73
  }
@@ -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
+ }
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  // seed-fake-cards — fills a LEG_HOME with N backlog cards (fake chain, build
3
3
  // pipeline) through the same path the CLI uses (src/cards.mjs createCard), for
4
- // perf tests and manual board load-testing.
5
- // node scripts/seed-fake-cards.mjs --home <dir> --repo <git repo path> --count 50
4
+ // perf tests and manual board load-testing. --finished and --live add a
5
+ // realistic mix of finished and live cards, driven through humanAction
6
+ // (src/orchestrator.mjs) rather than by hand-editing card.json.
7
+ // node scripts/seed-fake-cards.mjs --home <dir> --repo <git repo path> --count 50 --finished 10 --live 3
6
8
  import { resolve } from 'node:path'
7
9
 
8
10
  function parseArgs(argv) {
@@ -15,21 +17,72 @@ function parseArgs(argv) {
15
17
  }
16
18
 
17
19
  const args = parseArgs(process.argv.slice(2))
18
- if (!args.home) { process.stderr.write('usage: seed-fake-cards.mjs --home <dir> --repo <git repo path> [--count 50]\n'); process.exit(2) }
19
- if (!args.repo) { process.stderr.write('usage: seed-fake-cards.mjs --home <dir> --repo <git repo path> [--count 50]\n'); process.exit(2) }
20
+ if (!args.home) { process.stderr.write('usage: seed-fake-cards.mjs --home <dir> --repo <git repo path> [--count 50] [--finished 0] [--live 0]\n'); process.exit(2) }
21
+ if (!args.repo) { process.stderr.write('usage: seed-fake-cards.mjs --home <dir> --repo <git repo path> [--count 50] [--finished 0] [--live 0]\n'); process.exit(2) }
20
22
  const count = parseInt(args.count ?? '50', 10) || 50
23
+ const finishedCount = parseInt(args.finished ?? '0', 10) || 0
24
+ const liveCount = parseInt(args.live ?? '0', 10) || 0
21
25
 
22
26
  // LEG_HOME must be set before store.mjs (and the ledger.mjs it imports) load.
23
27
  process.env.LEG_HOME = resolve(args.home)
24
28
  process.env.BATON_HOME = process.env.LEG_HOME
25
29
  const { createCard } = await import('../src/cards.mjs')
30
+ const { humanAction } = await import('../src/orchestrator.mjs')
26
31
 
27
32
  const TITLES = ['Add retry to fetch', 'Fix flaky lease test', 'Refactor log tail', 'Wire up SSE health', 'Trim README', 'Bump adapter timeout', 'Dedupe blocked events', 'Guard null station', 'Speed up card list', 'Polish error copy']
33
+ const actor = { type: 'human', id: 'seed' }
28
34
 
29
35
  let created = 0
30
36
  for (let i = 0; i < count; i++) {
31
37
  const title = `${TITLES[i % TITLES.length]} #${i + 1}`
32
- await createCard({ repo: args.repo, task: title, title, chain: 'fake' }, { type: 'human', id: 'seed' })
38
+ await createCard({ repo: args.repo, task: title, title, chain: 'fake' }, actor)
33
39
  created += 1
34
40
  }
35
- process.stdout.write(`seeded ${created} card(s) into ${process.env.BATON_HOME}\n`)
41
+
42
+ // Finished cards: queue, start, then drive a leg_result straight to done or
43
+ // failed. `chain: 'fake'` has no fallback, so a `failed` outcome exhausts the
44
+ // chain immediately (src/chain.mjs leg_result) instead of handing off.
45
+ let done = 0
46
+ let failed = 0
47
+ for (let i = 0; i < finishedCount; i++) {
48
+ const wantDone = i % 2 === 0
49
+ const title = `Seeded finished card #${i + 1}`
50
+ const card = await createCard({ repo: args.repo, task: title, title, chain: 'fake', queue: true }, actor)
51
+ humanAction(card.card_id, 'start', {}, actor)
52
+ humanAction(card.card_id, 'leg_result', { outcome: wantDone ? 'completed' : 'failed', adapter: 'fake' }, actor)
53
+ if (wantDone) done += 1
54
+ else failed += 1
55
+ }
56
+
57
+ // Live cards: cycle through queued, running, needs_approval and paused, each
58
+ // reached through the same transitions a real run takes. `fake=sleep` keeps a
59
+ // running (or queued, on a board that is actually driving its scheduler) card
60
+ // from finishing out from under the board the moment it is looked at.
61
+ const LIVE_KINDS = ['queued', 'running', 'needs_approval', 'paused']
62
+ const liveSeeded = { queued: 0, running: 0, needs_approval: 0, paused: 0 }
63
+ for (let i = 0; i < liveCount; i++) {
64
+ const kind = LIVE_KINDS[i % LIVE_KINDS.length]
65
+ const title = `Seeded live card #${i + 1}`
66
+ if (kind === 'needs_approval') {
67
+ // a second, approval-gated leg to hand off into
68
+ const card = await createCard({ repo: args.repo, task: title, title, chain: 'fake-claude,fake-codex', approve: 'fake-codex', queue: true }, actor)
69
+ humanAction(card.card_id, 'start', {}, actor)
70
+ humanAction(card.card_id, 'leg_result', { outcome: 'incomplete', adapter: 'fake-claude' }, actor)
71
+ humanAction(card.card_id, 'bundle_written', {}, actor)
72
+ } else {
73
+ const card = await createCard({ repo: args.repo, task: title, title, chain: 'fake', fake_mode: 'fake=sleep', queue: true }, actor)
74
+ if (kind === 'running' || kind === 'paused') humanAction(card.card_id, 'start', {}, actor)
75
+ if (kind === 'paused') humanAction(card.card_id, 'pause', {}, actor)
76
+ }
77
+ liveSeeded[kind] += 1
78
+ }
79
+
80
+ // test/perf.test.mjs matches "seeded N card(s)" as a substring, so the
81
+ // original --count-only sentence stays intact; the finished/live counts are
82
+ // appended rather than folded into it.
83
+ const liveSummary = LIVE_KINDS.map((k) => `${liveSeeded[k]} ${k}`).join(', ')
84
+ const extra = []
85
+ if (finishedCount) extra.push(`${finishedCount} finished (${done} done, ${failed} failed)`)
86
+ if (liveCount) extra.push(`${liveCount} live (${liveSummary})`)
87
+ const suffix = extra.length ? `, ${extra.join(', ')}` : ''
88
+ process.stdout.write(`seeded ${created} card(s)${suffix} into ${process.env.BATON_HOME}\n`)