@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 +26 -0
- package/package.json +1 -1
- package/scripts/build-docs-site.mjs +3 -3
- package/src/attach.mjs +16 -7
- package/src/board/board.css +5 -2
- package/src/board/board.js +1 -1
- package/src/board/floor.js +1 -1
- package/src/board/strip.js +4 -0
- package/src/server.mjs +1 -1
- package/src/usage-poll.mjs +4 -1
- package/src/usage.mjs +19 -1
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.
|
|
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=
|
|
203
|
-
<meta property="og:image:secure_url" content="${ORIGIN}/og.png?v=
|
|
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=
|
|
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
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
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.
|
|
624
|
-
//
|
|
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
|
-
|
|
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)
|
package/src/board/board.css
CHANGED
|
@@ -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
|
-
|
|
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; }
|
package/src/board/board.js
CHANGED
|
@@ -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.
|
|
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
|
package/src/board/floor.js
CHANGED
|
@@ -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.
|
|
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
|
package/src/board/strip.js
CHANGED
|
@@ -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 })
|
package/src/usage-poll.mjs
CHANGED
|
@@ -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) {
|