@ucsandman/legcli 0.16.0 → 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,31 @@
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
+
3
29
  ## 0.16.0 (2026-09-19)
4
30
 
5
31
  The board reads as one product: a polish pass inside the 2026-09-15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ucsandman/legcli",
3
- "version": "0.16.0",
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">
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)
@@ -237,7 +237,10 @@ code { font-family: var(--mono); font-size: 0.94em; }
237
237
  name is text: identity is the dot and the fill, never the colour of a word */
238
238
  .capacity-strip { display: flex; align-items: flex-start; gap: 12px 32px; flex-wrap: nowrap; min-height: 44px; font-size: var(--t-0); }
239
239
  .cap-tokens { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 10px 40px; flex: 1 1 auto; min-width: 0; }
240
- .cap-token { display: grid; grid-template-columns: 8px 7ch 120px minmax(0, 1fr); align-items: center; gap: 10px; min-height: 28px; }
240
+ /* the name column is as wide as the longest login on the strip (strip.js sets
241
+ --cap-name on the container): a second claude login reads "claude/work",
242
+ and a fixed 7ch put the slash over the track */
243
+ .cap-token { display: grid; grid-template-columns: 8px var(--cap-name, 7ch) 120px minmax(0, 1fr); align-items: center; gap: 10px; min-height: 28px; }
241
244
  .cap-name { font-weight: var(--w-head); color: var(--text-2); }
242
245
  .cap-track { position: relative; flex: none; width: 120px; height: 6px; background: var(--track); border-radius: 999px; box-shadow: var(--sink); overflow: hidden; }
243
246
  .cap-fill { position: absolute; inset: 0 auto 0 0; border-radius: 999px; background: var(--calm); }
@@ -704,7 +707,7 @@ dialog::backdrop { background: rgba(0,0,0,.6); }
704
707
  with its state word carries the whole fact on its own */
705
708
  .capacity-strip { display: block; }
706
709
  .cap-tokens { grid-template-columns: 1fr; }
707
- .cap-token { grid-template-columns: 8px 7ch minmax(0, 1fr); }
710
+ .cap-token { grid-template-columns: 8px var(--cap-name, 7ch) minmax(0, 1fr); }
708
711
  .cap-track { display: none; }
709
712
  .cap-figure { grid-column: 3; text-align: right; }
710
713
  .cap-open { margin-left: 0; margin-top: 12px; }
@@ -68,7 +68,7 @@
68
68
  // release lands on disk under a board that was started before it: the page
69
69
  // then draws controls the process has no routes for (an empty agent select,
70
70
  // no buckets). test/files-version.test.mjs pins this to package.json.
71
- const FILES_VERSION = '0.16.0'
71
+ const FILES_VERSION = '0.16.1'
72
72
  const TIMELINE_CAP = 12
73
73
  // mirrors LOOPBACK in src/auth.mjs; state.bind is "<host>:<port>" and an IPv6
74
74
  // host arrives bracketed
@@ -50,7 +50,7 @@
50
50
  // name. test/board-updates.test.mjs pins `state.stopped` in this file by
51
51
  // source text, which is why the module object keeps the name.
52
52
  // pinned to package.json by test/files-version.test.mjs; see board.js FILES_VERSION
53
- const FILES_VERSION = '0.16.0'
53
+ const FILES_VERSION = '0.16.1'
54
54
  const state = { es: null, retryMs: 1000, timers: [], stopped: false, sseRequest: 0, floorRequest: 0, trunkRequest: 0, headRequest: 0, cardsRequest: 0, lastReadingAt: null, bind: (typeof location !== 'undefined' && location.host) || '127.0.0.1:4747', pendingFloor: null, pendingCards: null, cards: new Map(), blockers: new Map(), doneOpen: false, ringId: null, scheduler: {}, trunkOff: false }
55
55
 
56
56
  // What the entry row reads on every render: the terminals (for the repo it
@@ -125,6 +125,10 @@
125
125
  const box = document.getElementById('capacity-tokens')
126
126
  if (!box) return
127
127
  box.textContent = ''
128
+ // the name column fits the longest login on the strip, so "claude/work"
129
+ // never runs into its own track and every row's track still starts flush
130
+ const widest = Math.max(7, ...(list || []).map((a) => String(H.accountLabel(a)).length))
131
+ box.style.setProperty('--cap-name', `${widest}ch`)
128
132
  for (const a of list || []) box.appendChild(capToken(a))
129
133
  }
130
134
 
package/src/server.mjs CHANGED
@@ -1209,7 +1209,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
1209
1209
  const you = { ...viewer, share: { on: shared, people: shared ? share.people.length : 0 } }
1210
1210
  if (!canCards) return send(res, 200, { ok: true, version: VERSION, you })
1211
1211
  const cards = listCards()
1212
- return send(res, 200, { ok: true, pid: process.pid, version: VERSION, bind, port, home: canMachine ? home() : null, you, scheduler: { ...schedulerStatus(), in_process: Boolean(sched), max_concurrent: MAX_CONCURRENT }, tools: await detectTools(), columns: columnsFor(cards), cards: cards.length })
1212
+ return send(res, 200, { ok: true, pid: process.pid, version: VERSION, bind, port, home: canMachine ? home() : null, you, viewers: sse.clients.size, scheduler: { ...schedulerStatus(), in_process: Boolean(sched), max_concurrent: MAX_CONCURRENT }, tools: await detectTools(), columns: columnsFor(cards), cards: cards.length })
1213
1213
  }
1214
1214
  if (req.method === 'GET' && path === '/api/adapters') return send(res, 200, { adapters: await adaptersInfo() })
1215
1215
  if (req.method === 'GET' && path === '/api/presets') return send(res, 200, { presets: PRESETS })
@@ -19,7 +19,7 @@
19
19
  // endpoint reads moved here.
20
20
  import { LAYOUT, readAccounts, envFor } from './accounts.mjs'
21
21
  import { listSessions, isActive, updateSession, appendEvent } from './sessions.mjs'
22
- import { recordUsage, noteUsageError } from './usage.mjs'
22
+ import { recordUsage, noteUsageError, notePoll } from './usage.mjs'
23
23
  import { fetchClaudeUsage } from './taps/claude-usage.mjs'
24
24
  import { fetchGrokUsage } from './taps/grok.mjs'
25
25
  import { readCodexUsage } from './taps/codex.mjs'
@@ -202,6 +202,9 @@ export function createUsagePollers({
202
202
  if (noted.changed) onLog(`${agent}/${account} usage: ${r.error}`)
203
203
  }
204
204
  st.delay = r.ok ? intervalMs : Math.min(maxMs, Math.max(intervalMs, st.delay) * 2)
205
+ // the promise the terminals read (src/usage.mjs boardIsPolling): a backed
206
+ // off poller is still a poller, and no terminal should read past it
207
+ try { notePoll(agent, account, Date.now() + st.delay) } catch {}
205
208
  return { ...r, changed }
206
209
  })()
207
210
  st.inFlight = run.finally(() => { st.inFlight = null })
package/src/usage.mjs CHANGED
@@ -47,7 +47,7 @@ export function readUsage(agent, account = 'default') {
47
47
  }
48
48
 
49
49
  function emptyUsage(agent, account) {
50
- return { agent, account, five_hour: null, seven_day: null, limited_until: null, limited_reason: null, limited_at: null, source: null, observed_at: null, available_at: null, updated_at: null, buckets: [], walls: {}, history: {}, extra_usage: null, facts: null, error: null, error_since: null }
50
+ return { agent, account, five_hour: null, seven_day: null, limited_until: null, limited_reason: null, limited_at: null, source: null, observed_at: null, available_at: null, updated_at: null, buckets: [], walls: {}, history: {}, extra_usage: null, facts: null, error: null, error_since: null, next_poll_at: null }
51
51
  }
52
52
 
53
53
  // The READING's health, which is not the login's health: a 429 from the usage
@@ -82,6 +82,24 @@ export function noteUsageError(agent, account, error, { at = new Date().toISOStr
82
82
  return { error: value.error ?? null, error_since: value.error_since ?? null, changed }
83
83
  }
84
84
 
85
+ // The board's poller says when it will read this login next, on every attempt
86
+ // it makes, a refusal included. A terminal deciding whether to read the login
87
+ // itself (src/attach.mjs) asks this, not the age of the last good reading: a
88
+ // 429 backs the poller off up to ten minutes, and in that window the last
89
+ // reading is stale while a board is still very much on the login. The old
90
+ // test read that as "no board", printed as much, and added a request a minute
91
+ // to an endpoint that was already refusing.
92
+ export function notePoll(agent, account, nextAtMs) {
93
+ return mutate(agent, account, (u) => { u.next_poll_at = new Date(nextAtMs).toISOString(); return u })
94
+ }
95
+
96
+ // → true while a poller has promised a next reading that is not yet overdue.
97
+ // The grace covers the endpoint's own timeout on top of the promised moment.
98
+ export function boardIsPolling(u, nowMs = Date.now(), graceMs = 90 * 1000) {
99
+ const nextMs = Date.parse(u?.next_poll_at ?? '')
100
+ return Number.isFinite(nextMs) && nowMs <= nextMs + graceMs
101
+ }
102
+
85
103
  // The ring key for a bucket: the kind alone when it is account-wide, the kind
86
104
  // and the model when it is scoped ('weekly_scoped:fable').
87
105
  export function bucketKey(b) {