@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 +65 -0
- package/README.md +5 -3
- package/docs/ERRORS.md +39 -0
- package/docs/board-guide.md +33 -29
- package/docs/screenshots/board-400px.png +0 -0
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/board-empty.png +0 -0
- package/docs/screenshots/board-handoff.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/package.json +1 -1
- package/scripts/build-docs-site.mjs +3 -3
- package/scripts/npm-publish-gate.mjs +4 -0
- package/scripts/release-notes.mjs +51 -0
- package/src/attach.mjs +16 -7
- package/src/board/board.css +180 -55
- package/src/board/board.js +5 -2
- package/src/board/entry.js +4 -4
- package/src/board/favicon.svg +1 -1
- package/src/board/floor.html +2 -2
- package/src/board/floor.js +4 -1
- package/src/board/history.js +1 -1
- package/src/board/index.html +22 -8
- package/src/board/sessions.js +55 -14
- package/src/board/strip.js +5 -1
- package/src/server.mjs +1 -1
- package/src/usage-poll.mjs +4 -1
- package/src/usage.mjs +19 -1
package/src/board/sessions.js
CHANGED
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
const alsoOpen = new Set()
|
|
51
51
|
const actionNotes = new Map()
|
|
52
52
|
const lastTone = new Map()
|
|
53
|
+
const rowShape = new Map()
|
|
53
54
|
const defaultEditor = { ladder: null, climb_back: 'next-handoff', may_spend: false, reserve: {}, dirty: false, saving: false, status: '', statusClass: '' }
|
|
54
55
|
|
|
55
56
|
// localStorage THROWS rather than answering null in a browser with site data
|
|
@@ -635,6 +636,19 @@
|
|
|
635
636
|
// `card 3e1c`: the tail of the card id, which is how a card is named on its
|
|
636
637
|
// own row and out loud.
|
|
637
638
|
function cardName(id) { return `card ${String(id).split('-').filter(Boolean).slice(-1)[0] || id}` }
|
|
639
|
+
// the terminal the verdict is about when a human is blocked: the one that
|
|
640
|
+
// has waited longest. verdictLines prints it; verdictTarget names its row.
|
|
641
|
+
function blockedOf(live) {
|
|
642
|
+
return live
|
|
643
|
+
.filter((s) => notifyWait(s) && s.waiting.type !== 'quota_auto_resume' && Number.isFinite(Date.parse(s.waiting.since)))
|
|
644
|
+
.sort((x, y) => Date.parse(x.waiting.since) - Date.parse(y.waiting.since))[0]
|
|
645
|
+
}
|
|
646
|
+
function verdictTarget(list, sessions) {
|
|
647
|
+
const accounts = (list || []).filter((a) => a && a.agent && !a.loading)
|
|
648
|
+
if (!accounts.length) return null
|
|
649
|
+
const b = blockedOf((sessions || []).filter((s) => s.active))
|
|
650
|
+
return b ? b.session_id : null
|
|
651
|
+
}
|
|
638
652
|
function verdictLines(list, sessions, cards) {
|
|
639
653
|
const accounts = (list || []).filter((a) => a && a.agent && !a.loading)
|
|
640
654
|
if (!accounts.length) return { line: 'Reading the logins.', sub: '' }
|
|
@@ -668,9 +682,7 @@
|
|
|
668
682
|
// the TYPE is checked and never the presence of `since`. `quota_auto_resume`
|
|
669
683
|
// is left out here too: nobody asked the human anything, Claude Code is
|
|
670
684
|
// holding its own turn, and the row says so in its own sentence.
|
|
671
|
-
const blocked = live
|
|
672
|
-
.filter((s) => notifyWait(s) && s.waiting.type !== 'quota_auto_resume' && Number.isFinite(Date.parse(s.waiting.since)))
|
|
673
|
-
.sort((x, y) => Date.parse(x.waiting.since) - Date.parse(y.waiting.since))[0]
|
|
685
|
+
const blocked = blockedOf(live)
|
|
674
686
|
if (blocked) {
|
|
675
687
|
const who = rowName(blocked)
|
|
676
688
|
const waited = spoken(Date.now() - Date.parse(blocked.waiting.since))
|
|
@@ -881,7 +893,23 @@
|
|
|
881
893
|
const { line, sub } = verdictLines(list, view ? view.sessions : [], view ? view.cards_waiting : null)
|
|
882
894
|
const h1 = document.getElementById('verdict-line')
|
|
883
895
|
const p = document.getElementById('verdict-sub')
|
|
884
|
-
|
|
896
|
+
const target = verdictTarget(list, view ? view.sessions : [])
|
|
897
|
+
if (h1) {
|
|
898
|
+
// the sentence names a row, so the sentence is the way to that row: a
|
|
899
|
+
// button in the h1's own clothes that scrolls to the row and hands it the
|
|
900
|
+
// keyboard (the prompt is the row's first control)
|
|
901
|
+
if (target && typeof h1.replaceChildren === 'function') {
|
|
902
|
+
const link = el('button', { type: 'button', class: 'verdict-link', title: 'go to this terminal' }, [line])
|
|
903
|
+
link.addEventListener('click', () => {
|
|
904
|
+
const row = document.querySelector(`#session-grid .term[data-session-id="${target}"]`)
|
|
905
|
+
if (!row) return
|
|
906
|
+
if (typeof row.scrollIntoView === 'function') row.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
907
|
+
const first = row.querySelector('.panel-prompt, .btn')
|
|
908
|
+
if (first && typeof first.focus === 'function') first.focus({ preventScroll: true })
|
|
909
|
+
})
|
|
910
|
+
h1.replaceChildren(link)
|
|
911
|
+
} else h1.textContent = line
|
|
912
|
+
}
|
|
885
913
|
if (p) p.textContent = sub
|
|
886
914
|
// the strip is the only usage on screen until the reader opens the drawer
|
|
887
915
|
capacityStrip(list)
|
|
@@ -1717,7 +1745,14 @@
|
|
|
1717
1745
|
const urgent = needsYou(s, notes)
|
|
1718
1746
|
// the article is named so the accessibility tree does not hand the reader
|
|
1719
1747
|
// three identical triples of Land / Hand off now / Details / End
|
|
1720
|
-
|
|
1748
|
+
// what the row says, in one string: when it differs from the last render
|
|
1749
|
+
// the row is lit for a moment (board.css .term.is-changed), the one answer
|
|
1750
|
+
// the board gives to what moved while the reader was away
|
|
1751
|
+
const shape = `${s.status}|${urgent}|${notes[0] ? notes[0].cat : ''}|${promptText(s.task)}`
|
|
1752
|
+
const was = rowShape.get(s.session_id)
|
|
1753
|
+
rowShape.set(s.session_id, shape)
|
|
1754
|
+
const changed = was !== undefined && was !== shape
|
|
1755
|
+
const term = el('article', { class: `term${urgent ? ' is-urgent' : ''}${changed ? ' is-changed' : ''}`, 'data-session-id': s.session_id, 'aria-label': `${s.agent} ${tail(s.session_id)}` })
|
|
1721
1756
|
const row = el('div', { class: 'term-row' })
|
|
1722
1757
|
const body = el('div', { class: 'term-body' })
|
|
1723
1758
|
|
|
@@ -1733,7 +1768,7 @@
|
|
|
1733
1768
|
// which model actually answered, and whether it has gone quiet. The model
|
|
1734
1769
|
// token is the agent and the model it resolved to, never a default.
|
|
1735
1770
|
for (const t of registerTokens(s)) {
|
|
1736
|
-
if (t.kind === 'model') register.appendChild(el('span', { class: `term-model chip-id-${idOf(s.agent)}` }, [t.text]))
|
|
1771
|
+
if (t.kind === 'model') register.appendChild(el('span', { class: `term-model chip-id-${idOf(s.agent)}` }, [el('span', { class: `dot id-${idOf(s.agent)}`, 'aria-hidden': 'true' }), t.text]))
|
|
1737
1772
|
else register.appendChild(el('span', { class: `term-${t.kind}` }, [t.text]))
|
|
1738
1773
|
}
|
|
1739
1774
|
if (s.account !== 'default') register.appendChild(el('span', { class: 'chip' }, [s.account]))
|
|
@@ -1822,8 +1857,11 @@
|
|
|
1822
1857
|
row.appendChild(body)
|
|
1823
1858
|
|
|
1824
1859
|
// elapsed and the short id, right-aligned and small: the two facts you scan
|
|
1825
|
-
// down the column rather than read
|
|
1826
|
-
|
|
1860
|
+
// down the column rather than read. They sit over the actions in one side
|
|
1861
|
+
// column (board.css .term-side); the narrow breakpoint dissolves it.
|
|
1862
|
+
const side = el('div', { class: 'term-side' })
|
|
1863
|
+
row.appendChild(side)
|
|
1864
|
+
side.appendChild(el('div', { class: 'term-clock' }, [
|
|
1827
1865
|
el('span', { class: 'term-when elapsed', 'data-elapsed-from': String(Date.parse(s.started_at) || 0), 'data-elapsed-format': 'compact', title: `started ${new Date(s.started_at).toLocaleString()}` }, [ago(s.elapsed_ms)]),
|
|
1828
1866
|
// the tail is `codex-99ab`, printed immediately after the word `codex`:
|
|
1829
1867
|
// the prefix is the agent name twice, and it is the half that squeezed
|
|
@@ -1859,7 +1897,7 @@
|
|
|
1859
1897
|
q.addEventListener('click', () => act(s.session_id, 'request-handoff', q))
|
|
1860
1898
|
actions.appendChild(q)
|
|
1861
1899
|
}
|
|
1862
|
-
|
|
1900
|
+
side.appendChild(actions)
|
|
1863
1901
|
term.appendChild(row)
|
|
1864
1902
|
return term
|
|
1865
1903
|
}
|
|
@@ -1924,7 +1962,7 @@
|
|
|
1924
1962
|
actions.appendChild(land)
|
|
1925
1963
|
}
|
|
1926
1964
|
if (s.active) {
|
|
1927
|
-
const h = el('button', { type: 'button', class:
|
|
1965
|
+
const h = el('button', { type: 'button', class: 'btn btn-secondary', title: 'save the bundle, stop this agent, start the next option in the same terminal', 'data-focus-key': `handoff:${s.session_id}` }, ['Hand off now'])
|
|
1928
1966
|
// the same confirm row End and the picker already use. This stops the
|
|
1929
1967
|
// current turn of a working agent, and the `h` key presses this button:
|
|
1930
1968
|
// an action that costs a turn asks first, whichever hand pressed it.
|
|
@@ -1961,7 +1999,7 @@
|
|
|
1961
1999
|
no.addEventListener('click', () => act(s.session_id, `requests/${encodeURIComponent(r.by)}/dismiss`, no))
|
|
1962
2000
|
actions.append(ok, no)
|
|
1963
2001
|
}
|
|
1964
|
-
|
|
2002
|
+
side.appendChild(actions)
|
|
1965
2003
|
term.appendChild(row)
|
|
1966
2004
|
|
|
1967
2005
|
// Why can't I land expander or fallback blocker message
|
|
@@ -2573,7 +2611,7 @@
|
|
|
2573
2611
|
// favicon are the only surface a browser gives a tab nobody is looking at.
|
|
2574
2612
|
// favicon.svg itself is never touched; the dot is a variant drawn inline.
|
|
2575
2613
|
const FAVICON = '/favicon.svg'
|
|
2576
|
-
const FAVICON_DOT = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0
|
|
2614
|
+
const FAVICON_DOT = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="24" height="24" rx="6" fill="#F3F4F7"/><path d="M8 5.5v8.5l4 4h5" stroke="#0E1012" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/><circle cx="19" cy="5" r="5" fill="#E64343"/></svg>')}`
|
|
2577
2615
|
function titleBadge(n) {
|
|
2578
2616
|
const title = n > 0 ? `(${n}) Leg` : 'Leg'
|
|
2579
2617
|
if (document.title !== title) document.title = title
|
|
@@ -2792,7 +2830,7 @@
|
|
|
2792
2830
|
const repos = [...new Set(finished.map((s) => s.repo_name).filter(Boolean))]
|
|
2793
2831
|
if (repos.length) meta.textContent += `, in ${repos.slice(0, 3).join(', ')}${repos.length > 3 ? ` and ${repos.length - 3} more` : ''}`
|
|
2794
2832
|
const btn = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-expanded': finishedOpen ? 'true' : 'false', 'aria-controls': 'finished-drawer', 'data-focus-key': 'finished-toggle' },
|
|
2795
|
-
[finishedOpen ?
|
|
2833
|
+
[finishedOpen ? 'Hide' : 'View'])
|
|
2796
2834
|
btn.addEventListener('click', () => { finishedOpen = !finishedOpen; renderSessions(view) })
|
|
2797
2835
|
slot.appendChild(btn)
|
|
2798
2836
|
panel.hidden = !finishedOpen
|
|
@@ -2842,6 +2880,9 @@
|
|
|
2842
2880
|
orderDivergedAt = same ? 0 : (orderDivergedAt || Date.now())
|
|
2843
2881
|
const empty = document.querySelector('.region-terminals .empty-line')
|
|
2844
2882
|
if (empty) empty.hidden = list.some((s) => s.active || needsYou(s, notesOf.get(s.session_id) || []))
|
|
2883
|
+
// the first-run panel is the same fact in a lit panel (index.html #first-run)
|
|
2884
|
+
const firstRun = document.getElementById('first-run')
|
|
2885
|
+
if (firstRun) firstRun.hidden = list.length > 0
|
|
2845
2886
|
|
|
2846
2887
|
// A terminal that has ended or been lost is history, and history does not
|
|
2847
2888
|
// belong in the panel that shows what is live. It moves to the ledger.
|
|
@@ -2999,7 +3040,7 @@
|
|
|
2999
3040
|
// depends on it. board-updates.test.mjs uses the same pattern in board.js.
|
|
3000
3041
|
if (typeof module !== 'undefined') {
|
|
3001
3042
|
module.exports = {
|
|
3002
|
-
verdictLines, VERDICT_CH, SUB_CH, WARN_PCT, bindingOf, capFigure, capToken, shareClause, headline,
|
|
3043
|
+
verdictLines, verdictTarget, VERDICT_CH, SUB_CH, WARN_PCT, bindingOf, capFigure, capToken, shareClause, headline,
|
|
3003
3044
|
rankedNotes, needsYou, registerTokens, capacityPhrase, capacityNote, waitingNote, notifyWait, resetWait,
|
|
3004
3045
|
KEY_BUTTONS, KEY_MOVES,
|
|
3005
3046
|
// D14: the ring is state, not a pure function, so test/board-keyboard
|
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
|
|
|
@@ -140,7 +144,7 @@
|
|
|
140
144
|
const drawer = document.getElementById('capacity-drawer')
|
|
141
145
|
if (btn) {
|
|
142
146
|
btn.setAttribute('aria-expanded', capacityOpen ? 'true' : 'false')
|
|
143
|
-
btn.textContent = capacityOpen ? 'Hide capacity and models' : 'Capacity and models
|
|
147
|
+
btn.textContent = capacityOpen ? 'Hide capacity and models' : 'Capacity and models'
|
|
144
148
|
}
|
|
145
149
|
if (drawer) drawer.hidden = !capacityOpen
|
|
146
150
|
}
|
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) {
|