@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.
- package/CHANGELOG.md +213 -0
- package/README.md +95 -65
- package/bin/leg.mjs +123 -14
- package/docs/DECISIONS.md +18 -0
- package/docs/DEMO.md +20 -14
- package/docs/DEVIATIONS.md +1 -0
- package/docs/ERRORS.md +68 -0
- package/docs/ROADMAP-v2.md +50 -5
- package/docs/VOCABULARY.md +27 -0
- package/docs/board-guide.md +529 -96
- package/docs/cli-contracts.md +241 -5
- package/docs/concepts.md +167 -19
- package/docs/configuration.md +65 -1
- package/docs/faq.md +21 -5
- package/docs/getting-started.md +15 -11
- package/docs/redesign-2026-09-17.md +477 -0
- package/docs/screenshots/background-1280.png +0 -0
- package/docs/screenshots/board-400px.png +0 -0
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/board-drawer.png +0 -0
- package/docs/screenshots/board-handoff.png +0 -0
- package/docs/screenshots/board-running.png +0 -0
- package/docs/screenshots/capacity-drawer-1280.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/new-card-dialog.png +0 -0
- package/docs/screenshots/settings-ladder-1280.png +0 -0
- package/docs/screenshots/terminals-1280.png +0 -0
- package/fixtures/limits/claude/claude-fable-limit.json +11 -0
- package/fixtures/limits/claude/claude-model-limit.json +1 -1
- package/fixtures/limits/claude/claude-session-limit.json +1 -1
- package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
- package/fixtures/live/claude/resume-model-probe.json +20 -0
- package/fixtures/live/claude/usage-oauth.json +87 -0
- package/fixtures/verified.json +1 -1
- package/package.json +3 -2
- package/scripts/board-jump-probe.mjs +335 -0
- package/scripts/seed-fake-cards.mjs +59 -6
- package/scripts/seed-wes-board.mjs +81 -12
- package/src/accounts.mjs +6 -1
- package/src/attach.mjs +378 -93
- package/src/audit.mjs +1 -1
- package/src/board/board.css +203 -11
- package/src/board/board.js +664 -200
- 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 +122 -45
- package/src/board/sessions.js +1569 -141
- package/src/board/strip.js +163 -0
- package/src/buckets.mjs +101 -0
- package/src/cards.mjs +9 -1
- package/src/chain.mjs +13 -0
- package/src/hook.mjs +7 -1
- package/src/ledger.mjs +10 -2
- package/src/models.mjs +265 -0
- package/src/orchestrator.mjs +13 -4
- package/src/preferences.mjs +278 -5
- package/src/scheduler.mjs +24 -1
- package/src/server.mjs +625 -78
- package/src/sessions.mjs +17 -1
- package/src/taps/claude-usage.mjs +107 -3
- package/src/taps/claude.mjs +144 -5
- package/src/taps/codex.mjs +23 -3
- package/src/usage-poll.mjs +260 -0
- package/src/usage.mjs +439 -12
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
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
|
+
}
|
package/fixtures/verified.json
CHANGED
|
@@ -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":
|
|
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.
|
|
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
|
-
//
|
|
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' },
|
|
38
|
+
await createCard({ repo: args.repo, task: title, title, chain: 'fake' }, actor)
|
|
33
39
|
created += 1
|
|
34
40
|
}
|
|
35
|
-
|
|
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`)
|