@ucsandman/legcli 0.15.0 → 0.16.0

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
@@ -140,7 +140,7 @@
140
140
  const drawer = document.getElementById('capacity-drawer')
141
141
  if (btn) {
142
142
  btn.setAttribute('aria-expanded', capacityOpen ? 'true' : 'false')
143
- btn.textContent = capacityOpen ? 'Hide capacity and models' : 'Capacity and models >'
143
+ btn.textContent = capacityOpen ? 'Hide capacity and models' : 'Capacity and models'
144
144
  }
145
145
  if (drawer) drawer.hidden = !capacityOpen
146
146
  }
package/src/hook.mjs CHANGED
@@ -6,7 +6,8 @@
6
6
  // node hook.mjs claude-hook --session <id>
7
7
  // node hook.mjs claude-statusline --session <id>
8
8
  // The status line entry records rate_limits when a Claude Code build runs it
9
- // (2.1.268 does not; see src/taps/claude-usage.mjs) and prints one Leg line.
9
+ // (2.1.268 and 2.1.278 did not on this machine; see src/taps/claude-usage.mjs),
10
+ // prints the user's own status line first, then one Leg line.
10
11
  import { appendFileSync } from 'node:fs'
11
12
  import { join } from 'node:path'
12
13
  import { handleHook, handleStatusline, terminalSequenceFor } from './taps/claude.mjs'
@@ -47,9 +48,9 @@ try {
47
48
  const seq = terminalSequenceFor(payload)
48
49
  if (seq) process.stdout.write(JSON.stringify({ terminalSequence: seq }) + '\n')
49
50
  } else if (kind === 'claude-statusline') {
50
- const { text } = handleStatusline(sessionId, payload)
51
- try { appendFileSync(join(sessionDir(sessionId), 'hook.log'), `${new Date().toISOString()} statusline rate_limits=${JSON.stringify(payload.rate_limits ?? null)}\n`) } catch {}
52
- process.stdout.write(text + '\n')
51
+ const { text, user } = handleStatusline(sessionId, payload, raw)
52
+ try { appendFileSync(join(sessionDir(sessionId), 'hook.log'), `${new Date().toISOString()} statusline rate_limits=${JSON.stringify(payload.rate_limits ?? null)} user_rows=${user ? user.split('\n').length : 0}\n`) } catch {}
53
+ process.stdout.write((user ? user + '\n' : '') + text + '\n')
53
54
  }
54
55
  } catch {}
55
56
  process.exit(0)
@@ -1,9 +1,10 @@
1
1
  // claude usage — the 5h / 7d percentages for a Claude Code login, from the
2
2
  // same endpoint Claude Code's own /usage and built-in status line read.
3
- // Why not the status line: Claude Code 2.1.268 renders its built-in status
4
- // line and does not run a custom `statusLine` command passed via --settings
5
- // or a project settings file (verified 2026-09-11 with an `echo` command at
6
- // both levels; hooks from the same --settings file do run). So Leg asks the
3
+ // Why not the status line: Claude Code 2.1.268 and 2.1.278 render their
4
+ // built-in status line and do not run a custom `statusLine` command passed
5
+ // via --settings or a project settings file (verified 2026-09-11 and
6
+ // 2026-09-19 with an `echo` command at both levels; hooks from the same
7
+ // --settings file do run). So Leg asks the
7
8
  // usage endpoint directly with the OAuth token Claude Code stored at login.
8
9
  // The token is read by this process only, sent only to api.anthropic.com,
9
10
  // and never written anywhere (the ledger scrubs bearer tokens regardless).
@@ -1,11 +1,13 @@
1
1
  // claude tap — how `leg claude` sees inside a normal interactive Claude Code.
2
2
  // Nothing in ~/.claude is edited: the session gets one extra settings file via
3
3
  // `--settings` (hooks merge with the user's; statusLine is the only key that
4
- // replaces, so Leg's status line runs the user's own command first).
4
+ // replaces, so Leg's status-line hook runs the user's own command with the
5
+ // same stdin and prints its rows above Leg's one line).
5
6
  // Sources: code.claude.com/docs/en/hooks (StopFailure `error: rate_limit`),
6
7
  // docs/en/statusline (rate_limits.five_hour/seven_day used_percentage,
7
8
  // resets_at), docs/en/settings (`--settings` sits above user settings).
8
9
  import { existsSync, readFileSync, openSync, closeSync, fstatSync, readSync } from 'node:fs'
10
+ import { spawnSync } from 'node:child_process'
9
11
  import { join, dirname, resolve } from 'node:path'
10
12
  import { fileURLToPath } from 'node:url'
11
13
  import { sessionDir, updateSession, appendEvent, readSession, workRoot } from '../sessions.mjs'
@@ -64,12 +66,37 @@ export function settingsFor(sessionId, { statusLine = null } = {}) {
64
66
  return settings
65
67
  }
66
68
 
67
- export function writeSettings(sessionId, opts) {
69
+ export function writeSettings(sessionId, opts = {}) {
68
70
  const file = join(sessionDir(sessionId), 'claude-settings.json')
69
71
  writeJsonAtomic(file, settingsFor(sessionId, opts))
72
+ // the hook process reads this back to run the user's command (see userStatusLineText)
73
+ if (opts.statusLine?.command) updateSession(sessionId, { user_statusline: { command: opts.statusLine.command } })
70
74
  return file
71
75
  }
72
76
 
77
+ // The shell Claude Code itself uses for a status-line command: /bin/sh, or on
78
+ // Windows Git Bash when installed, else PowerShell (docs/en/statusline,
79
+ // "Windows configuration").
80
+ function statusLineShell(command) {
81
+ if (process.platform !== 'win32') return { file: '/bin/sh', args: ['-c', command] }
82
+ const bash = process.env.CLAUDE_CODE_GIT_BASH_PATH || 'C:\\Program Files\\Git\\bin\\bash.exe'
83
+ if (existsSync(bash)) return { file: bash, args: ['-c', command] }
84
+ return { file: 'powershell', args: ['-NoProfile', '-Command', command] }
85
+ }
86
+
87
+ // The user's own status line, rendered: their command gets the same JSON on
88
+ // stdin Claude Code handed Leg, and whatever it prints goes above Leg's row.
89
+ // Never throws; an absent, slow (3 s) or broken command yields ''.
90
+ export function userStatusLineText(session, raw) {
91
+ const command = session?.user_statusline?.command
92
+ if (!command) return ''
93
+ try {
94
+ const { file, args } = statusLineShell(command)
95
+ const r = spawnSync(file, args, { input: raw ?? '', encoding: 'utf8', timeout: 3000, windowsHide: true })
96
+ return String(r.stdout ?? '').replace(/\s+$/, '')
97
+ } catch { return '' }
98
+ }
99
+
73
100
  function textOf(content) {
74
101
  if (typeof content === 'string') return content
75
102
  if (Array.isArray(content)) return content.filter((c) => c?.type === 'text' && c.text).map((c) => c.text).join('\n')
@@ -288,10 +315,11 @@ export function relTo(root, file) {
288
315
  return r && a.toLowerCase().startsWith(r.toLowerCase() + '/') ? a.slice(r.length + 1) : a
289
316
  }
290
317
 
291
- // Status line: record the limits and print. Returns the text to print.
292
- export function handleStatusline(sessionId, p) {
318
+ // Status line: record the limits and print. Returns Leg's row as `text` and
319
+ // the user's own status line (from `raw`, the stdin JSON) as `user`.
320
+ export function handleStatusline(sessionId, p, raw = '') {
293
321
  const s = readSession(sessionId)
294
- if (!s) return { text: '', limits: null }
322
+ if (!s) return { text: '', user: '', limits: null }
295
323
  const limits = limitsFrom(p.rate_limits)
296
324
  if (limits) recordUsage('claude', s.account, limits, 'claude statusline')
297
325
  const hot = limits ? [['5h', limits.five_hour], ['7d', limits.seven_day]].filter(([, w]) => w).sort((a, b) => b[1].pct - a[1].pct)[0] : null
@@ -312,5 +340,5 @@ export function handleStatusline(sessionId, p) {
312
340
  const next = s.chain?.[0] ? `${s.chain[0].agent}${s.chain[0].account !== 'default' ? '/' + s.chain[0].account : ''}` : 'nothing'
313
341
  const pct = limits ? ` 5h ${limits.five_hour ? Math.round(limits.five_hour.pct) + '%' : '-'} · 7d ${limits.seven_day ? Math.round(limits.seven_day.pct) + '%' : '-'}` : ''
314
342
  const text = warn ? `⚠ leg: ${hot[0]} at ${Math.round(hot[1].pct)}% → next ${next}${pct}` : `leg ·${pct || ' limits pending'} · next ${next} · board ${s.board_url ?? ''}`
315
- return { text, limits, warn }
343
+ return { text, user: userStatusLineText(s, raw), limits, warn }
316
344
  }