@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.
@@ -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
- if (h1) h1.textContent = line
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
- const term = el('article', { class: `term${urgent ? ' is-urgent' : ''}`, 'data-session-id': s.session_id, 'aria-label': `${s.agent} ${tail(s.session_id)}` })
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
- row.appendChild(el('div', { class: 'term-clock' }, [
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
- row.appendChild(actions)
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: `btn ${blocker ? 'btn-primary' : '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'])
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
- row.appendChild(actions)
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 100 100"><text y=".9em" font-size="90">\u{1F9BF}</text><circle cx="78" cy="22" r="20" fill="#E64343"/></svg>')}`
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 ? `Hide the ${finished.length}` : `View all ${finished.length}`])
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
@@ -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 })
@@ -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) {