@ucsandman/legcli 0.15.1 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,70 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.1 (2026-09-19)
4
+
5
+ Three things that nagged every session, and the sharecard.
6
+
7
+ - **The terminal no longer claims "no board is reading claude usage" while
8
+ one is.** A 429 from the usage endpoint backs the board's poller off for up
9
+ to ten minutes; the terminal read the stale record as "no board", said so,
10
+ and started its own reads, one more request a minute at an endpoint already
11
+ refusing. The poller now writes `next_poll_at` on every attempt, a refusal
12
+ included, and the terminal stands down while that promise is not overdue
13
+ (`boardIsPolling`, src/usage.mjs). A board that is genuinely gone still
14
+ gets the old line and the old fallback.
15
+ - **`leg claude` opens the board only when nobody is looking at it.**
16
+ `/api/health` reports `viewers`, the count of browser tabs on the event
17
+ stream; a second terminal on a board that is already on screen adds no tab.
18
+ A guarded board (share on) says no count and opens as before.
19
+ - **A second claude login fits its row on the capacity strip.** The name
20
+ column was a fixed 7ch, and "claude/work" ran into its own track. The
21
+ column is now as wide as the longest login on the strip, set by strip.js,
22
+ so every row's track still starts flush.
23
+ - **The sharecard reaches X.** robots.txt said `Disallow: /og` to keep the
24
+ card's source page out of the index, and that prefix also blocked
25
+ `/og.png`, so X drew a bare link for four days. The line is `/og$` with an
26
+ explicit `Allow: /og.png`; the card's foot no longer overflows the frame
27
+ and the image URL is `?v=5`.
28
+
29
+ ## 0.16.0 (2026-09-19)
30
+
31
+ The board reads as one product: a polish pass inside the 2026-09-15
32
+ direction, a new mark, and a first-run screen.
33
+
34
+ - **One colour rule, kept everywhere.** Login names in the capacity strip,
35
+ the rung in a terminal row's register and the rungs in the ladder editor are
36
+ text beside an identity dot; the colour no longer lands on the word. A
37
+ warning sentence is amber only on a row that is waiting on you. The accent
38
+ blue appears on Land when it can run and on Start once a task is typed, and
39
+ nowhere else. The capacity strip is an aligned table (dot, name, track,
40
+ figure) instead of a ragged sentence.
41
+ - **Terminal rows.** The prompt is clamped to two lines (Details holds the
42
+ rest), the clock sits over one quiet row of buttons, a Land that cannot run
43
+ is not drawn (its reason is said once above the panel), and End is quiet at
44
+ rest. The register moved up to 15px and the scaffolding floor from 13 to
45
+ 14px. A row whose state changed since the last render is lit for 1.4 s, the
46
+ one motion the rows own; rows carry a faint hover. Narrow rows lost 264px of
47
+ empty height each: a `flex-basis: 24ch` meant for the row direction had been
48
+ applied as height once the row stacked. The 420px horizontal overflow is
49
+ gone.
50
+ - **One control language.** The background entry is the terminals panel's
51
+ footer, its choices quiet chips inside the sentence. Ledger cells share one
52
+ anatomy with their verbs (View, Browse, New card) on one baseline; Settings
53
+ is a row in the same grammar; disclosures carry a CSS chevron instead of a
54
+ typed `>`; the Floor and Board links are buttons.
55
+ - **The verdict is a link.** When the sentence names a terminal, pressing it
56
+ scrolls to that row and focuses its prompt.
57
+ - **First run.** A board with no terminal shows one lit panel: `leg claude`,
58
+ `leg codex` and `leg agy` each with a Copy, and one line on what appears
59
+ with the first turn.
60
+ - **The floor.** An empty station is one line, heading beside sentence, so
61
+ seven empty stations are seven lines rather than seven screens.
62
+ - **The mark.** A tile with the L cut as hip, knee and foot replaces the 🦿
63
+ emoji on the board, the floor, the favicon, the tab badge and the site.
64
+ The emoji rendered as whichever system emoji font the machine had.
65
+ - **Docs.** Nine screenshots retaken against this build; DESIGN.md and the
66
+ board guide describe the new row and strip; the OG image carries the mark.
67
+
3
68
  ## 0.15.1 (2026-09-19)
4
69
 
5
70
  Your own Claude Code status line comes back.
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  *Claude hits the five-hour wall. The terminal reads `handing off to codex`, and codex carries on there. Nothing is retyped. ([the full 53-second run](https://legcli.com/#handoff))*
13
13
 
14
- ![The Leg board at 1280px: a headline naming the terminal that has waited on you longest; under it a capacity strip with claude at 63 percent of its fable week, codex back on Saturday, agy with no figure and grok with no reading, and a button that opens the login panels; then four terminal rows, each with its status, repo and branch, uncommitted and unpushed counts, agent and model, the prompt, the one thing worth knowing and four buttons; then a Background panel of live cards and a one-line field for starting another; then counts for finished terminals, what landed, conversations and finished cards](https://legcli.com/img/docs/terminals-1280.png)
14
+ ![The Leg board at 1280px: a headline naming the terminal that has waited on you longest; under it a capacity strip with claude at 63 percent of its fable week, codex back on Saturday, agy with no figure and grok with no reading, and a button that opens the login panels; then four terminal rows, each with its status, repo and branch, uncommitted and unpushed counts, agent and model, the prompt, the one thing worth knowing and its buttons; then the one-line field for starting a background task, joined to the panel; then counts for finished terminals, what landed, conversations and finished cards](https://legcli.com/img/docs/terminals-1280.png)
15
15
 
16
16
  You keep using your coding agents exactly as you do today, in any terminal,
17
17
  from your own config directory: Leg adds its hooks in a separate per-session
@@ -420,8 +420,10 @@ with nothing in it says so with its counts.
420
420
  first, each row with the short sha, the subject, a `repo@branch` chip, and
421
421
  when plus who. A commit a Land put there says `landed by <agent> (<id tail>)`.
422
422
  - **Buttons**, in a fixed order that never reflows: Land, Hand off now,
423
- Details, End. Once a session has ended, Remove and Remove record take End's
424
- place. Details opens an expansion in flow under the panel.
423
+ Details, End, under the clock at the right of the row. Land is drawn only
424
+ when it can run; its reason is said once above the panel. Once a session has
425
+ ended, Remove and Remove record take End's place. Details opens an expansion
426
+ in flow under the panel.
425
427
  **Hand off now** takes the first open rung of the ladder. To name the
426
428
  destination instead, open Details and use **Hand off now to**, which lists
427
429
  every rung with its model, whether it keeps the conversation, and the reason
package/docs/ERRORS.md CHANGED
@@ -3,6 +3,28 @@
3
3
  What broke, why, and what fixed it. One entry per failure, newest first. A first
4
4
  occurrence has to be written down or a repeat is never countable.
5
5
 
6
+ ## 2026-09-19: eleven versions reached npm and none reached the GitHub releases page
7
+
8
+ **Fixed by making the publish job create the tag and the GitHub release itself,
9
+ from the version's CHANGELOG.md section, and by backfilling v0.7.0 to v0.15.1.**
10
+
11
+ The releases page stopped at v0.6.0 (2026-09-15, the pre-rename name) while npm went on to
12
+ 0.15.1. Releases 0.2.0 to 0.6.0 had been made by hand with `gh release create`,
13
+ batched on 09-11 and 09-15; once `publish-npm` in `.github/workflows/ci.yml`
14
+ took over the npm side on 09-14, nothing tagged or drafted anything, and no
15
+ check noticed, because a green publish job and a stale releases page look the
16
+ same from the terminal. Wes found it on the page. The rule that holds is a
17
+ mechanism, not a note: the publish job now runs `scripts/release-notes.mjs`
18
+ and `gh release create v<version>` in the same job as `npm publish` (the job
19
+ has `contents: write` for that one step), and `scripts/npm-publish-gate.mjs`
20
+ refuses a version with no `## <version>` section in CHANGELOG.md, so a
21
+ release nobody wrote up never reaches npm either. `test/release-notes.test.mjs`
22
+ pins the extraction. The backfilled tags point at the commit each npm version
23
+ was published from (`npm view @ucsandman/legcli time` matched against
24
+ `git log --first-parent --before`), not at the `[RELEASE]` commit, since three
25
+ of those needed a follow-up fix before the gate let them through. 0.6.1 and
26
+ 0.8.1 have CHANGELOG sections but never reached npm, so they have no release.
27
+
6
28
  ## 2026-09-18: 0.14.0 pushed, CI green everywhere, and npm still served 0.13.1
7
29
 
8
30
  **Fixed by putting the `repository` block back into the lockfile root
@@ -687,3 +709,20 @@ it), and check whether a board was listening on 4747 at the time.
687
709
  - **The lesson that generalises.** A seeded board is safe to look at and not
688
710
  safe to click: any row that names a path that exists is a live control on
689
711
  that path. Seed paths must be realistic in shape and impossible in fact.
712
+
713
+ ## A width became 264px of height on every narrow row (2026-09-19)
714
+
715
+ - **What happened.** `.term-row > .term-body { flex-basis: 24ch }` gave the
716
+ prompt column a starting width on the wide board. Under 760px the row
717
+ stacks (`flex-direction: column`), the same declaration became a starting
718
+ HEIGHT, and every terminal row carried about 264px of empty space under its
719
+ last line. It shipped in 0.13.0 and survived three review passes because
720
+ each one eyeballed the 400px screenshot instead of measuring it.
721
+ - **Fix.** `flex-basis: auto` inside the narrow block. Narrow rows on the
722
+ seeded board went from 549/467/442/442 to 426/365/330/266.
723
+ - **The lesson that generalises.** A flex-basis is axis-relative; any rule
724
+ that sets one on a container whose direction flips at a breakpoint needs
725
+ the opposite value in that breakpoint. And the shot script's per-row
726
+ heights found it in one run when three visual reviews had not: the polish
727
+ loop is `_shot.mjs` numbers first, picture second (`.design/board-v5/`,
728
+ local only).
@@ -14,24 +14,23 @@ The visual system, and why it is what it is, is `DESIGN.md` at the repo root.
14
14
  ## Screenshots
15
15
 
16
16
  `docs/screenshots/` (listed here so you know what exists before you look for
17
- one). Nine were retaken on 2026-09-17 against this build, on a board seeded by
18
- `scripts/seed-wes-board.mjs` plus `scripts/seed-fake-cards.mjs --count 1
19
- --finished 10 --live 3`; the rest are from the 2026-09-15 sweep and are noted
20
- below:
17
+ one). Nine were retaken on 2026-09-19 against the polish pass (0.16.0), on a
18
+ board seeded by `scripts/seed-wes-board.mjs`; the rest are from the 2026-09-17
19
+ and 2026-09-15 sweeps and are noted below:
21
20
 
22
21
  ```
23
- terminals-1280.png the whole board at 1280 px (2026-09-17)
24
- board-400px.png the same board at 400 px (2026-09-17)
25
- capacity-drawer-1280.png the strip with Capacity and models open (2026-09-17)
26
- board-details-open.png a terminal row with its expansion open (2026-09-17)
27
- board-handoff.png one row waiting on you, with the question (2026-09-17)
22
+ terminals-1280.png the whole board at 1280 px (2026-09-19)
23
+ board-400px.png the same board at 400 px (2026-09-19)
24
+ capacity-drawer-1280.png the strip with Capacity and models open (2026-09-19)
25
+ board-details-open.png a terminal row with its expansion open (2026-09-19)
26
+ board-handoff.png one row waiting on you, with the question (2026-09-19)
28
27
  background-1280.png the Background panel and the one-line entry (2026-09-17)
29
- settings-ladder-1280.png the ladder editor in Settings (2026-09-17)
30
- board-empty.png no background tasks at all
28
+ settings-ladder-1280.png the ladder editor in Settings (2026-09-19)
29
+ board-empty.png the first-run panel: no terminal yet, three commands with Copy (2026-09-19)
31
30
  board-running.png one card running its first agent (2026-09-17)
32
31
  board-drawer.png that card expanded: where, runs, take over, timeline (2026-09-17)
33
32
  board-done.png the card finished, on the agent that finished it
34
- floor.png /floor with nothing queued
33
+ floor.png /floor with nothing queued, every station one line (2026-09-19)
35
34
  floor-landing.png /floor with every lane full
36
35
  floor-final-1280.png /floor after a card finished
37
36
  floor-final-400.png the same at 400 px
@@ -42,18 +41,20 @@ demo-2-limit-hit.png limit hit, bundle written, the row waiting on you
42
41
  demo-3-handoff-bundle.png the row expanded: bundle path, run signal, timeline
43
42
  demo-4-codex-running.png leg 2 running on fake-codex
44
43
  demo-5-done.png done, both legs on the chain
44
+ new-card-dialog.png More settings: the task and who runs it (2026-09-19)
45
45
  ```
46
46
 
47
47
  The five `demo-*.png` are one run of the sequence in [DEMO.md](DEMO.md), all at
48
48
  1280 px.
49
49
 
50
- **Still stale, as of 2026-09-17:** `share-owner-1280.png`,
51
- `share-guest-1280.png`, `board-empty.png`, `board-done.png` and the five
52
- `demo-*.png` show the old top of the board (a 56px verdict over a column of
53
- login panels) rather than the capacity strip and its drawer, and the card shots
54
- among them predate the Background panel. Their terminal rows and their floor
55
- are still accurate. Retake them with the commands below rather than trusting
56
- the top band of any of those pictures.
50
+ **Still stale, as of 2026-09-19:** `share-owner-1280.png`,
51
+ `share-guest-1280.png`, `board-done.png`, `background-1280.png`,
52
+ `board-running.png`, `board-drawer.png`, `floor-landing.png`, the two
53
+ `floor-final-*.png` and the five `demo-*.png` predate the 0.16.0 polish pass:
54
+ their rows still show the 2x2 button grid, coloured login words and the old
55
+ mark, and the oldest of them show the pre-strip top of the board. What each
56
+ one documents is still true. Retake them with the commands below rather than
57
+ trusting the chrome in any of those pictures.
57
58
 
58
59
  To retake one, seed a board with the shape a real one has and drive it to the
59
60
  state the shot needs:
@@ -174,7 +175,7 @@ reset, the source, any wall and the age of the reading, the same sentence the
174
175
  gauges carry. A login with no percentage is not a `meter` at all, because
175
176
  `aria-valuenow` would have to be a number nobody measured.
176
177
 
177
- **`Capacity and models >`**, at the end of the strip, opens a drawer holding
178
+ **`Capacity and models`**, the disclosure at the end of the strip, opens a drawer holding
178
179
  the login panels unchanged, plus a rail of model chips on each panel head:
179
180
  `fable 63%` for a measured bucket, `fable out until 9:14 PM` for a model at its
180
181
  wall, and a bare model name where Leg has a name but no figure. A percentage is
@@ -307,16 +308,18 @@ been at it, and what you can do about it.
307
308
  stops and fable continues from the bundle.`, or `from the conversation` when
308
309
  that rung is one a `--resume` keeps. Confirming posts the hand-off with the
309
310
  model on it; a rung that walled between the draw and the press comes back as
310
- the server's own 409 sentence in the row. It is a link and not a fifth button:
311
- the 2x2 grid is the shipped shape and does not reflow.
311
+ the server's own 409 sentence in the row. It is a link and not a fourth button:
312
+ the button row is the shipped shape and does not reflow.
312
313
  - **The clock**: elapsed since the session started (`4h 24m`), and the session's
313
314
  short id. The id used to print as `claude-7f3a` immediately after the word
314
315
  `claude`; the prefix is the agent name twice and it is gone.
315
- - **The buttons**, in a fixed 2x2 grid so every row's controls sit in the same
316
- place: Land, Hand off now, Details, End. Land is the primary action only when
317
- it can actually run, when it is blocked the accent moves to Hand off now,
318
- because a disabled control should not wear the one accent colour in the
319
- design. When Land is disabled its reason is printed, never left in a tooltip.
316
+ - **The buttons**, one row under the clock at the right, so every row's
317
+ controls sit in the same place: Land, Hand off now, Details, End. Land is
318
+ drawn only when it can actually run, and then it is the one primary button
319
+ on the board; when it cannot, the row does not draw it and its reason is
320
+ printed once above the panel, never left in a tooltip. Hand off now, Details
321
+ and End are quiet controls: the accent means "this can land" and nothing
322
+ else.
320
323
 
321
324
  **A terminal waiting on a human says so, and the tab says it too.** Claude
322
325
  Code's Notification hook writes `waiting` on the record when it puts up a
@@ -417,7 +420,8 @@ one off.`, because two waiters on one terminal is the failure to avoid.
417
420
  ### Terminal buttons
418
421
 
419
422
  The order is fixed, Land, Hand off now, Details, End, and it never reflows by
420
- availability: a button that does not apply is omitted, never moved.
423
+ availability: a button that does not apply is omitted, never moved. A Land that
424
+ cannot run is omitted too, with its reason hoisted above the panel.
421
425
 
422
426
  | button | shown when | what it does |
423
427
  |--------|------------|--------------|
@@ -555,7 +559,7 @@ raised surface here would compete with the terminals that are live. Each is a
555
559
  heading, a line of detail and a button that opens the detail below the row.
556
560
 
557
561
  - **N finished**, the terminals that have ended or been lost, `4 lost, 2 ended,
558
- in leg, costclaw, declick`. **View all N** opens them as full rows. On a real
562
+ in leg, costclaw, declick`. **View** opens them as full rows. On a real
559
563
  board after a day's work this is most of the list, which is exactly why it is a
560
564
  count and not the list.
561
565
  - **N landed**, what has landed on trunk across every repo the board can see,
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ucsandman/legcli",
3
- "version": "0.15.1",
3
+ "version": "0.16.1",
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. 14-day free trial, then $79 once, 30-day money-back guarantee.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -199,13 +199,13 @@ function page ({ slug, title, description, body, headings }) {
199
199
  <meta property="og:url" content="${url}">
200
200
  <meta property="og:site_name" content="Leg">
201
201
  <meta property="og:title" content="${escapeHtml(title)}">
202
- <meta property="og:image" content="${ORIGIN}/og.png?v=4">
203
- <meta property="og:image:secure_url" content="${ORIGIN}/og.png?v=4">
202
+ <meta property="og:image" content="${ORIGIN}/og.png?v=5">
203
+ <meta property="og:image:secure_url" content="${ORIGIN}/og.png?v=5">
204
204
  <meta property="og:image:type" content="image/png">
205
205
  <meta name="twitter:card" content="summary_large_image">
206
206
  <meta name="twitter:title" content="${escapeHtml(title)}">
207
207
  <meta name="twitter:description" content="${escapeHtml(description)}">
208
- <meta name="twitter:image" content="${ORIGIN}/og.png?v=4">
208
+ <meta name="twitter:image" content="${ORIGIN}/og.png?v=5">
209
209
  <link rel="preload" href="/fonts/atkinson-hyperlegible-next-var.woff2" as="font" type="font/woff2" crossorigin>
210
210
  <link rel="preload" href="/fonts/azeret-mono-var.woff2" as="font" type="font/woff2" crossorigin>
211
211
  <link rel="stylesheet" href="/style.css">
@@ -7,6 +7,7 @@
7
7
  import { appendFileSync, readFileSync } from 'node:fs'
8
8
  import { dirname, join } from 'node:path'
9
9
  import { fileURLToPath } from 'node:url'
10
+ import { releaseSection } from './release-notes.mjs'
10
11
 
11
12
  const root = join(dirname(fileURLToPath(import.meta.url)), '..')
12
13
  const EXPECTED_REPOSITORY = 'https://github.com/ucsandman/legcli.git'
@@ -103,6 +104,9 @@ try {
103
104
  if (alias.repository?.url !== EXPECTED_REPOSITORY) {
104
105
  fail('alias package repository metadata does not match the npm trusted publisher binding')
105
106
  }
107
+ // The GitHub release ci.yml creates after the publish is this section, so a
108
+ // version nobody wrote up in CHANGELOG.md never reaches npm either.
109
+ releaseSection(readFileSync(join(root, 'CHANGELOG.md'), 'utf8'), pkg.version)
106
110
 
107
111
  const primaryPublished = await registryStatus(pkg.name, pkg.version)
108
112
  const aliasPublished = await registryStatus(alias.name, alias.version)
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ // release-notes — the CHANGELOG.md section for one version, for the GitHub
3
+ // release CI creates right after it publishes that version to npm.
4
+ //
5
+ // node scripts/release-notes.mjs 0.15.1 the section body (no heading)
6
+ // node scripts/release-notes.mjs 0.15.1 --title "Leg 0.15.1: <first sentence>"
7
+ //
8
+ // Exits 1 with a sentence when CHANGELOG.md has no `## <version> (` heading, so
9
+ // the publish gate refuses a version nobody wrote up.
10
+ import { readFileSync } from 'node:fs'
11
+ import { dirname, join } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
15
+
16
+ export function releaseSection(changelog, version) {
17
+ const lines = changelog.split(/\r?\n/)
18
+ const heading = new RegExp(`^## ${version.replace(/\./g, '\\.')}(\\s|$)`)
19
+ const start = lines.findIndex((l) => heading.test(l))
20
+ if (start === -1) throw new Error(`CHANGELOG.md has no "## ${version}" section; write the release up before publishing it`)
21
+ let end = lines.findIndex((l, i) => i > start && /^## /.test(l))
22
+ if (end === -1) end = lines.length
23
+ const body = lines.slice(start + 1, end).join('\n').trim()
24
+ if (!body) throw new Error(`CHANGELOG.md's "## ${version}" section is empty`)
25
+ return body
26
+ }
27
+
28
+ // "Leg <version>: <the section's first sentence, one line, no trailing stop>",
29
+ // capped so the release list stays readable. Older sections open with a
30
+ // bullet instead of a summary paragraph; the bullet's first sentence serves.
31
+ export function releaseTitle(changelog, version, { product = 'Leg', max = 100 } = {}) {
32
+ const body = releaseSection(changelog, version)
33
+ const paragraph = body.split(/\n\s*\n/)[0].replace(/^[-*]\s+/, '').replace(/\*\*|`/g, '').replace(/\s+/g, ' ').trim()
34
+ const first = (paragraph.match(/^.*?[.!?](?=\s|$)/) ?? [paragraph])[0].replace(/[.!?:;,]$/, '')
35
+ let title = `${product} ${version}: ${first}`
36
+ if (title.length > max) title = title.slice(0, max - 1).replace(/\s+\S*$/, '') + '…'
37
+ return title
38
+ }
39
+
40
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
41
+ const args = process.argv.slice(2)
42
+ const version = args.find((a) => !a.startsWith('--'))
43
+ if (!version) { console.error('usage: node scripts/release-notes.mjs <version> [--title]'); process.exit(2) }
44
+ try {
45
+ const changelog = readFileSync(join(root, 'CHANGELOG.md'), 'utf8')
46
+ process.stdout.write((args.includes('--title') ? releaseTitle(changelog, version) : releaseSection(changelog, version)) + '\n')
47
+ } catch (e) {
48
+ console.error(e.message)
49
+ process.exit(1)
50
+ }
51
+ }
package/src/attach.mjs CHANGED
@@ -24,7 +24,7 @@ import { ensure as ensureWorktree, remove as removeWorktree } from './worktree.m
24
24
  import { canonPath, realPath, writeJsonAtomic } from './fsx.mjs'
25
25
  import { whoami, readShare, isOn as shareIsOn } from './share.mjs'
26
26
  import { readAccounts, envFor, refreshAccount } from './accounts.mjs'
27
- import { recordUsage, markLimited, chooseNext, candidates, fmtReset, WARN_PCT, readUsage, usageIsStale, isAvailable, wallActive, rungLabel, skipLine, keepsConversation as keepsConversationRule } from './usage.mjs'
27
+ import { recordUsage, markLimited, chooseNext, candidates, fmtReset, WARN_PCT, readUsage, usageIsStale, boardIsPolling, isAvailable, wallActive, rungLabel, skipLine, keepsConversation as keepsConversationRule } from './usage.mjs'
28
28
  import { entitlement, allows, describe as describeLicense } from './license.mjs'
29
29
  import { writeSettings, userStatusLine, transcriptTail as claudeTail, modelAlias, modelFromTranscript, printable } from './taps/claude.mjs'
30
30
  import { modelFlagFor } from './buckets.mjs'
@@ -118,9 +118,13 @@ export async function ensureBoard({ open = true, wait = true } = {}) {
118
118
  const host = shared ? share.bind : '127.0.0.1'
119
119
  const url = `http://${host}:${port}`
120
120
  if ((process.env.LEG_NO_BOARD || process.env.BATON_NO_BOARD) === '1') return { url: null, started: false, skipped: true }
121
- // the board is opened whether or not this terminal is the one that started
122
- // it: `leg claude` in a second terminal still means "show me the board"
123
- if (await health(port, host)) { if (open) openBoard(url); return { url, started: false } }
121
+ // A board that is already up is opened only when nobody is looking at it:
122
+ // `viewers` is the count of browser tabs on its event stream (src/server.mjs
123
+ // /api/health). Every `leg claude` used to open one more tab of a board that
124
+ // was already on screen. A guarded board (share on, 401 on health) says no
125
+ // count, and is opened as before.
126
+ const h = await health(port, host)
127
+ if (h) { if (open && !(Number.isFinite(h.viewers) && h.viewers > 0)) openBoard(url); return { url, started: false } }
124
128
  // A board that is merely busy misses the health deadline while still owning
125
129
  // the port. Treating that as "no board" spawned a second server that could
126
130
  // only die of EADDRINUSE, and the poll below then waited the full fifteen
@@ -620,8 +624,10 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
620
624
  // record and, when nothing has refreshed it for five minutes, reads the
621
625
  // endpoint itself at the old once-a-minute cadence. A board that is polling
622
626
  // keeps that record fresh, so with one up this costs a readUsage() a minute
623
- // and no request. Said once, so a terminal doing its own reading is never a
624
- // mystery.
627
+ // and no request. A board backed off by a 429 has promised its next read on
628
+ // the record (`next_poll_at`), and the terminal waits for it: its own read
629
+ // would only be one more request at an endpoint already refusing. Said
630
+ // once, so a terminal doing its own reading is never a mystery.
625
631
  let fallbackTimer = null
626
632
  if ((agent === 'claude' || agent === 'grok') && !NO_USAGE_POLL) {
627
633
  const source = agent === 'claude' ? 'claude usage endpoint' : 'grok billing proxy'
@@ -631,7 +637,10 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
631
637
  let announced = false
632
638
  const pollUsage = async () => {
633
639
  if (!isCurrentLeg(readSession(sid), { pid: child.pid, agent, account })) return
634
- if (!usageIsStale(readUsage(agent, account))) return // a board is reading this login
640
+ // a board is reading this login: its last reading is fresh, or it has
641
+ // promised the next one (a 429 backs it off, which is not its absence)
642
+ const own = readUsage(agent, account)
643
+ if (!usageIsStale(own) || boardIsPolling(own)) return
635
644
  if (!announced) { announced = true; say(`no board is reading ${agent} usage, so this terminal reads it itself once a minute`) }
636
645
  const r = await readOwn()
637
646
  const s = readSession(sid)