@ucsandman/legcli 0.10.0 → 0.11.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.
@@ -0,0 +1,123 @@
1
+ // Audit trail: who did what, across every terminal and every card on this
2
+ // board. Data: /api/audit, which is owner-only — the trail names repositories
3
+ // and the people on the roster.
4
+ //
5
+ // It loads when the reader asks, never on a board render: the trail reads every
6
+ // session's events and every card's ledger, and that is not work to do on a
7
+ // timer behind a panel nobody has opened. Like the history cell, nothing here
8
+ // arrives over SSE (DESIGN.md rule 3: the live terminals lane is the only live
9
+ // region).
10
+ //
11
+ // `el`, `api`, `getToken`, `ago` and `whenAgo` are copied from sessions.js,
12
+ // which cannot export from its IIFE. The time grammar is sessions.js's: a
13
+ // change to ago() there is a change here in the same commit.
14
+ (function () {
15
+ 'use strict'
16
+
17
+ function getToken() { return localStorage.getItem('legToken') || localStorage.getItem('batonToken') || '' }
18
+ async function api(path, opts = {}) {
19
+ const headers = { 'Content-Type': 'application/json' }
20
+ const token = getToken()
21
+ if (token) headers.Authorization = `Bearer ${token}`
22
+ const r = await fetch(path, { method: opts.method || 'GET', headers, body: opts.body ? JSON.stringify(opts.body) : undefined })
23
+ const data = await r.json().catch(() => ({}))
24
+ if (!r.ok) throw new Error(data.error || `${r.status} ${r.statusText}`)
25
+ return data
26
+ }
27
+ function el(tag, attrs, children) {
28
+ const n = document.createElement(tag)
29
+ for (const [k, v] of Object.entries(attrs || {})) { if (v === null || v === undefined) continue; if (k === 'class') n.className = v; else n.setAttribute(k, v) }
30
+ for (const c of children || []) { if (c === null || c === undefined) continue; n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c) }
31
+ return n
32
+ }
33
+ function ago(ms) {
34
+ const s = Math.max(0, Math.floor(ms / 1000))
35
+ if (s < 60) return `${s}s`
36
+ const m = Math.floor(s / 60)
37
+ if (m < 60) return `${m}m`
38
+ const h = Math.floor(m / 60)
39
+ if (h < 48) return `${h}h${m % 60 ? ` ${m % 60}m` : ''}`
40
+ const d = Math.floor(h / 24)
41
+ return `${d}d${h % 24 ? ` ${h % 24}h` : ''}`
42
+ }
43
+ function whenAgo(ts) {
44
+ const t = Date.parse(ts)
45
+ return Number.isFinite(t) ? `${ago(Date.now() - t)} ago` : ''
46
+ }
47
+ function clockAt(ms) {
48
+ const d = new Date(ms)
49
+ if (!Number.isFinite(d.getTime())) return ''
50
+ return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })
51
+ }
52
+
53
+ const root = document.getElementById('audit')
54
+ if (!root) return
55
+ const whoSel = document.getElementById('audit-who')
56
+ const kindSel = document.getElementById('audit-kind')
57
+ const loadBtn = document.getElementById('audit-load')
58
+ const meta = document.getElementById('audit-meta')
59
+ const list = document.getElementById('audit-list')
60
+ let knownPeople = null
61
+
62
+ // A guest or an operator never sees this panel; the fieldset stays hidden
63
+ // until /api/audit answers, so a 403 leaves nothing on the page to click.
64
+ async function reveal() {
65
+ try {
66
+ const r = await api('/api/audit?limit=1')
67
+ root.hidden = false
68
+ knownPeople = r.people || []
69
+ fillPeople()
70
+ } catch { root.hidden = true }
71
+ }
72
+
73
+ function fillPeople() {
74
+ if (!knownPeople) return
75
+ const current = whoSel.value
76
+ while (whoSel.options.length > 1) whoSel.remove(1)
77
+ for (const p of knownPeople) whoSel.appendChild(el('option', { value: p }, [p]))
78
+ whoSel.value = current
79
+ }
80
+
81
+ function row(e) {
82
+ const line = el('div', { class: 'audit-row' })
83
+ line.appendChild(el('span', { class: 'audit-when', title: e.at }, [clockAt(Date.parse(e.at))]))
84
+ line.appendChild(el('span', { class: `chip chip-id-${e.kind === 'human' ? 'human' : (e.agent || 'leg')}` }, [e.who]))
85
+ line.appendChild(el('span', { class: 'audit-what' }, [e.what.replace(/_/g, ' ')]))
86
+ line.appendChild(el('span', { class: 'audit-summary' }, [e.summary || '']))
87
+ const where = e.where === 'card' ? `card ${e.id}` : `terminal ${String(e.id).split('-').pop()}`
88
+ line.appendChild(el('span', { class: 'chip' }, [where]))
89
+ if (e.repo) line.appendChild(el('span', { class: 'chip' }, [String(e.repo).split(/[\\/]/).pop() + (e.branch ? `@${e.branch}` : '')]))
90
+ line.appendChild(el('span', { class: 'audit-ago' }, [whenAgo(e.at)]))
91
+ return line
92
+ }
93
+
94
+ async function load() {
95
+ loadBtn.disabled = true
96
+ meta.textContent = 'reading every terminal and every card…'
97
+ list.replaceChildren()
98
+ try {
99
+ const q = new URLSearchParams({ limit: '200' })
100
+ if (whoSel.value) q.set('who', whoSel.value)
101
+ if (kindSel.value) q.set('kind', kindSel.value)
102
+ const r = await api(`/api/audit?${q}`)
103
+ knownPeople = r.people || knownPeople
104
+ fillPeople()
105
+ // L2: the verdict carries the volume it processed, so an empty trail from
106
+ // a board that looked at nothing does not read like a quiet week.
107
+ const scanned = `${r.scanned.sessions} terminal${r.scanned.sessions === 1 ? '' : 's'} and ${r.scanned.cards} card${r.scanned.cards === 1 ? '' : 's'}, ${r.scanned.events} events read`
108
+ meta.textContent = r.entries.length
109
+ ? `${r.matched} action${r.matched === 1 ? '' : 's'}${r.truncated ? `, newest ${r.entries.length} shown` : ''} — ${scanned}`
110
+ : `nothing matched — ${scanned}`
111
+ for (const e of r.entries) list.appendChild(row(e))
112
+ } catch (err) {
113
+ meta.textContent = err.message
114
+ } finally {
115
+ loadBtn.disabled = false
116
+ }
117
+ }
118
+
119
+ loadBtn.addEventListener('click', load)
120
+ whoSel.addEventListener('change', load)
121
+ kindSel.addEventListener('change', load)
122
+ reveal()
123
+ })()
@@ -391,6 +391,21 @@ code { font-family: var(--mono); font-size: 0.94em; }
391
391
  .history-filters { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px 14px; align-items: center; margin: 4px 0 18px; max-width: 64ch; }
392
392
  .history-filters label { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
393
393
  .history-filters label:has(input[type="checkbox"]) { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; font-weight: var(--w-text); }
394
+ /* Audit trail (Settings). Same grammar as the history rows: a flat list with
395
+ hairline separators, the time on the left, the actor as a chip. */
396
+ .audit { border: 0; padding: 0; margin: 22px 0 0; }
397
+ .audit legend { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); padding: 0; }
398
+ .audit-filters { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; margin: 10px 0 14px; }
399
+ .audit-filters label { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
400
+ .audit-list { display: block; }
401
+ .audit-row { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: baseline; padding: 10px 0; font-size: var(--t-0); }
402
+ .audit-row + .audit-row { border-top: 1px solid var(--line); }
403
+ .audit-when { color: var(--text-3); font-variant-numeric: tabular-nums; min-width: 12ch; }
404
+ .audit-what { font-weight: var(--w-head); }
405
+ .audit-summary { color: var(--text-2); flex: 1 1 46ch; min-width: 0; overflow-wrap: anywhere; }
406
+ .audit-ago { color: var(--text-3); margin-left: auto; }
407
+ @media (max-width: 640px) { .audit-ago { margin-left: 0; } }
408
+
394
409
  .history-list { display: block; }
395
410
  .history-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 20px; align-items: baseline; padding: 12px 0; font-size: var(--t-0); }
396
411
  .history-row + .history-row, .history-row + .history-detail, .history-detail + .history-row { border-top: 1px solid var(--line); }
@@ -158,6 +158,27 @@
158
158
  <button type="button" class="btn btn-secondary" id="default-order-save">Save default</button>
159
159
  <span class="field-status" id="default-order-status" aria-live="polite"></span>
160
160
  </fieldset>
161
+ <!-- Who did what, across every terminal and every card. The owner's, like
162
+ the rest of this panel: the trail names repositories and people. It
163
+ loads when asked rather than on every board render. -->
164
+ <fieldset class="audit" id="audit" hidden>
165
+ <legend>Audit trail</legend>
166
+ <p class="field-help">Every hand-off, landing, approval, reassignment and kill on this board, newest first, with the person or the agent that did it.</p>
167
+ <div class="audit-filters">
168
+ <label for="audit-who">Who</label>
169
+ <select id="audit-who"><option value="">anyone</option></select>
170
+ <label for="audit-kind">Kind</label>
171
+ <select id="audit-kind">
172
+ <option value="">people and agents</option>
173
+ <option value="human">people</option>
174
+ <option value="agent">agents</option>
175
+ <option value="leg">Leg itself</option>
176
+ </select>
177
+ <button type="button" class="btn btn-secondary" id="audit-load">Load</button>
178
+ </div>
179
+ <p class="region-meta" id="audit-meta" role="status" aria-atomic="true"></p>
180
+ <div class="audit-list" id="audit-list"></div>
181
+ </fieldset>
161
182
  <div class="field board-facts"></div>
162
183
  </div>
163
184
  </section>
@@ -280,5 +301,6 @@
280
301
  <script src="board.js" defer></script>
281
302
  <script src="sessions.js" defer></script>
282
303
  <script src="history.js" defer></script>
304
+ <script src="audit.js" defer></script>
283
305
  </body>
284
306
  </html>
@@ -661,7 +661,7 @@
661
661
  sysMessage('removed the Leg record; the worktree and the branch are kept', 'ok')
662
662
  } else {
663
663
  await api(`/api/sessions/${encodeURIComponent(id)}/${action}`, { method: 'POST', body })
664
- if (action === 'handoff') actionNotes.set(id, { at: Date.now(), tone: 'warn', text: 'hand-off requested; this terminal switches agents in a few seconds' })
664
+ if (action === 'handoff') actionNotes.set(id, { at: Date.now(), tone: 'warn', text: body && body.agent ? `hand-off to ${optionLabel(body)} requested; this terminal switches agents in a few seconds` : 'hand-off requested; this terminal switches agents in a few seconds' })
665
665
  else if (action === 'end') actionNotes.set(id, { at: Date.now(), tone: 'warn', text: 'end requested; the agent stops after its current turn' })
666
666
  else if (action === 'land/fix') actionNotes.set(id, { at: Date.now(), tone: 'ok', text: 'applied fix' })
667
667
  }
@@ -693,6 +693,34 @@
693
693
  else wrap.appendChild(el('p', { class: 'sentence tone-muted' }, [`first eligible now: ${eligible}`]))
694
694
  wrap.appendChild(el('p', { class: 'blocker' }, ['Used after a usage limit or Hand off now. A normal exit ends this terminal.']))
695
695
 
696
+ // The picker. The Hand off now button on the panel stays the one-click
697
+ // path (it takes the order); this names a destination instead. An option
698
+ // that cannot be picked carries the reason in its own label, so nothing is
699
+ // greyed out without saying why.
700
+ const targets = Array.isArray(s.handoff_targets) ? s.handoff_targets : []
701
+ if (s.active && targets.length) {
702
+ const pick = el('div', { class: 'form-row' })
703
+ const selectId = `handoff-to-${s.session_id}`
704
+ pick.appendChild(el('label', { for: selectId }, ['Hand off now to']))
705
+ const select = el('select', { id: selectId })
706
+ select.appendChild(el('option', { value: '' }, ['the next option in the order']))
707
+ targets.forEach((t, i) => {
708
+ const note = t.available ? '' : ` — ${t.reason}${Number.isFinite(t.resets_at) ? `, back ${until(t.resets_at)}` : ''}`
709
+ // the index is the value: an account name is not ours to parse
710
+ select.appendChild(el('option', { value: String(i), disabled: t.available ? null : 'disabled' }, [optionLabel(t) + note]))
711
+ })
712
+ const go = el('button', { type: 'button', class: 'btn btn-secondary' }, ['Hand off'])
713
+ go.addEventListener('click', () => {
714
+ const t = select.value === '' ? null : targets[Number(select.value)]
715
+ act(s.session_id, 'handoff', go, t ? { agent: t.agent, account: t.account } : null)
716
+ })
717
+ pick.appendChild(el('div', { class: 'chain-rail' }, [select, go]))
718
+ if (!targets.some((t) => t.available)) {
719
+ pick.appendChild(el('p', { class: 'field-help' }, ['Every destination is at its limit or not installed; a hand-off now waits for the first reset.']))
720
+ }
721
+ wrap.appendChild(pick)
722
+ }
723
+
696
724
  const editableNow = ['starting', 'running', 'warning', 'limit', 'waiting'].includes(s.status)
697
725
  if (!s.hidden && editableNow) {
698
726
  let state = sessionEditors.get(s.session_id)
package/src/server.mjs CHANGED
@@ -5,12 +5,14 @@
5
5
  // LEG_BIND (127.0.0.1) + LEG_PORT (4747) + LEG_TOKEN are the
6
6
  // multiplayer seams (src/auth.mjs). BATON_* names still work as fallback.
7
7
  import http from 'node:http'
8
+ import https from 'node:https'
8
9
  import { spawnSync, execFile } from 'node:child_process'
9
10
  import { existsSync, readFileSync, readdirSync, statSync, rmSync, watch as fsWatch, mkdirSync, openSync, fstatSync, readSync, closeSync } from 'node:fs'
10
11
  import { join, dirname, resolve, extname, sep } from 'node:path'
11
12
  import { fileURLToPath } from 'node:url'
12
13
  import { checkBind, authorize, remoteAddress, presentedToken, isLoopback, isLoopbackRequest, tokenMatches } from './auth.mjs'
13
- import { readShare, isOn as shareIsOn, sharePath, identify, personNamed } from './share.mjs'
14
+ import { readShare, isOn as shareIsOn, sharePath, identify, personNamed, mayUseCards, mayUseMachine, readTls } from './share.mjs'
15
+ import { auditTrail, ACTOR_KINDS } from './audit.mjs'
14
16
  import { createLimiter } from './ratelimit.mjs'
15
17
  import { realPath, canonPath } from './fsx.mjs'
16
18
  import { listCards, readCard, readRuns, readEvents, cardDir, home } from './store.mjs'
@@ -29,7 +31,7 @@ import { sessionDetail, sessionDiff, DiffInputError } from './session-detail.mjs
29
31
  import { hasRecentSynthesis } from './synthesis.mjs'
30
32
  import { refreshPointers } from './resume.mjs'
31
33
  import { landSession, landBlocker, landingNow, pruneSessionWorktree, canLand, prepareLanding, applyLandFix } from './land.mjs'
32
- import { readUsage, recordUsage, usageIsStale, candidates, isAvailable } from './usage.mjs'
34
+ import { readUsage, recordUsage, usageIsStale, candidates, isAvailable, fmtReset } from './usage.mjs'
33
35
  import { readAccounts, envFor, LAYOUT } from './accounts.mjs'
34
36
  import { readCodexUsage } from './taps/codex.mjs'
35
37
  import { readPreferences, writePreferences, normalizeHandoffOrder, requireHandoffOrder } from './preferences.mjs'
@@ -343,6 +345,11 @@ function visibleSessionFile(file) {
343
345
 
344
346
  export function sessionsView({ viewer = null, share = null } = {}) {
345
347
  const shared = Boolean(share && shareIsOn(share))
348
+ // Decided before the map below, because the per-session payload has to know
349
+ // it: a guest owns their own terminal and may hand it off, so they get its
350
+ // list of destinations — but a reset time is this machine's usage data and
351
+ // belongs to nobody else, even on a terminal that is theirs.
352
+ const guest = shared && viewer && viewer.role !== 'owner'
346
353
  const list = reapLost(listSessions())
347
354
  const ov = overlaps(list)
348
355
  const configuredAccounts = readAccounts()
@@ -360,6 +367,21 @@ export function sessionsView({ viewer = null, share = null } = {}) {
360
367
  chain,
361
368
  preferred_next: preferredNext,
362
369
  eligible_next: eligibleNext,
370
+ // every destination this terminal could be handed to, each with the
371
+ // reason it cannot be picked right now. The board's picker renders this
372
+ // list directly, so a greyed option always carries its own explanation.
373
+ handoff_targets: chain.map((c) => {
374
+ const u = readUsage(c.agent, c.account)
375
+ const missing = availabilityKnown && s.installed[c.agent] === false
376
+ const walled = !isAvailable(u)
377
+ return {
378
+ agent: c.agent,
379
+ account: c.account,
380
+ available: !missing && !walled,
381
+ reason: missing ? 'not installed on this machine' : walled ? 'at its usage limit' : null,
382
+ resets_at: walled && !guest ? (u.limited_until ?? null) : null,
383
+ }
384
+ }),
363
385
  handoff_availability_known: availabilityKnown,
364
386
  can_edit_handoff_order: s.runtime_capabilities?.includes(HANDOFF_ORDER_CAPABILITY) ?? false,
365
387
  active: isActive(s),
@@ -387,7 +409,6 @@ export function sessionsView({ viewer = null, share = null } = {}) {
387
409
  const canon = new Map()
388
410
  const landingsFor = (key) => landings.filter((l) => { if (!canon.has(l.repo)) canon.set(l.repo, canonPath(l.repo)); return canon.get(l.repo) === key })
389
411
  const trunk = [...repos].map(([key, r]) => { try { return withLandings(trunkFor(r), landingsFor(key)) } catch { return { repo: r, commits: [] } } })
390
- const guest = shared && viewer && viewer.role !== 'owner'
391
412
  const mine = (s) => !shared || !viewer || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
392
413
  const shown = sessions.map((s) => (mine(s) ? { ...s, requests: readRequests(s.session_id).filter((r) => r.state === 'pending') } : redactSession(s)))
393
414
  return {
@@ -461,14 +482,15 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, sessionsDebounce
461
482
  // resets the count the first is reading from.
462
483
  const refreshCard = (id) => {
463
484
  const card = readCard(id)
464
- // pipeline cards and their events are the owner's: a guest never gets them
465
- const forOwner = (payload) => (viewer) => (viewer && viewer.role !== 'owner' ? null : payload)
485
+ // pipeline cards and their events belong to the people who may run them:
486
+ // the owner and any operator. A guest never gets them.
487
+ const forOwner = (payload) => (viewer) => (viewer && !mayUseCards(viewer.role) ? null : payload)
466
488
  if (!card) { for (const c of clients) c.sig.delete(id); broadcast('removed', forOwner({ card_id: id })); return }
467
489
  const events = readEvents(id)
468
490
  // broadcast() refreshes each client's viewer (and drops revoked ones) first
469
491
  broadcast('card', forOwner(summarize(card)))
470
492
  for (const c of [...clients]) {
471
- if (!c.viewer || c.viewer.role !== 'owner') { c.sig.set(id, events.length); continue }
493
+ if (!c.viewer || !mayUseCards(c.viewer.role)) { c.sig.set(id, events.length); continue }
472
494
  const from = c.sig.get(id) ?? 0
473
495
  c.sig.set(id, events.length)
474
496
  for (const e of events.slice(from)) { try { c.res.write(`event: event\ndata: ${JSON.stringify(e)}\n\n`) } catch {} }
@@ -613,7 +635,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
613
635
  }
614
636
  const limiter = createLimiter()
615
637
  const viewFor = (viewer, sh) => sessionsView({ viewer, share: sh ?? currentShare() })
616
- const forOwner = (payload) => (viewer) => (viewer && viewer.role !== 'owner' ? null : payload)
638
+ const forOwner = (payload) => (viewer) => (viewer && !mayUseCards(viewer.role) ? null : payload)
617
639
  // SSE re-identifies each client from the live roster on every push
618
640
  const reauthClient = (c) => {
619
641
  const sh = currentShare()
@@ -688,19 +710,23 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
688
710
  const rl = limiter.request(auth.person ? viewer.name : ip)
689
711
  if (!rl.ok) return send(res, 429, { error: `rate limit: more than ${limiter.max} requests a minute` }, { 'Retry-After': String(rl.retry_after) })
690
712
  const actor = { type: 'human', id: viewer.name }
691
- // a guest sees the terminals lane, read-only; the pipeline side is the owner's
692
- const guest = shared && viewer.role !== 'owner'
713
+ // What this viewer may reach, decided once from their role (src/share.mjs).
714
+ // `canCards` is the pipeline board: cards, the floor, the adapters and the
715
+ // leases, which an operator runs. `canMachine` is everything that describes
716
+ // this computer rather than the work — the settings, the trunk's repo
717
+ // paths, the history index, the worktree map — and stays the owner's.
718
+ const canCards = !shared || mayUseCards(viewer.role)
719
+ const canMachine = !shared || mayUseMachine(viewer.role)
693
720
  const ownsSession = (s) => !shared || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
694
721
  const parts = path.split('/').filter(Boolean) // ['api', ...]
695
- // history and worktrees are the whole machine's project map (every path,
696
- // every conversation on it): the owner's, never a guest's, as a group
697
- if (guest && ['cards', 'floor', 'presets', 'adapters', 'leases', 'trunk', 'history', 'worktrees'].includes(parts[1])) return send(res, 403, { error: 'the pipeline board belongs to the owner of this machine' })
722
+ if (!canCards && ['cards', 'floor', 'presets', 'adapters', 'leases'].includes(parts[1])) return send(res, 403, { error: 'the pipeline board belongs to the owner and the operators of this machine' })
723
+ if (!canMachine && ['trunk', 'history', 'worktrees', 'audit'].includes(parts[1])) return send(res, 403, { error: 'this is the map of the machine itself: every repository path and every conversation on it. It belongs to the owner of this machine.' })
698
724
  try {
699
725
  if (req.method === 'GET' && path === '/api/health') {
700
726
  const you = { ...viewer, share: { on: shared, people: shared ? share.people.length : 0 } }
701
- if (guest) return send(res, 200, { ok: true, version: VERSION, you })
727
+ if (!canCards) return send(res, 200, { ok: true, version: VERSION, you })
702
728
  const cards = listCards()
703
- return send(res, 200, { ok: true, pid: process.pid, version: VERSION, bind, port, home: home(), you, scheduler: { ...schedulerStatus(), in_process: Boolean(sched), max_concurrent: MAX_CONCURRENT }, tools: await detectTools(), columns: columnsFor(cards), cards: cards.length })
729
+ 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 })
704
730
  }
705
731
  if (req.method === 'GET' && path === '/api/adapters') return send(res, 200, { adapters: await adaptersInfo() })
706
732
  if (req.method === 'GET' && path === '/api/presets') return send(res, 200, { presets: PRESETS })
@@ -721,14 +747,14 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
721
747
  }
722
748
  if (req.method === 'GET' && path === '/api/events') {
723
749
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' })
724
- const cards = guest ? [] : listCards()
750
+ const cards = canCards ? listCards() : []
725
751
  res.write(`event: hello\ndata: ${JSON.stringify({ columns: columnsFor(cards), cards: cards.map(summarize), sessions: viewFor(viewer), ts: new Date().toISOString() })}\n\n`)
726
752
  sse.add(res, cards, viewer, { token: presentedToken(req, url), loopback: isLoopbackRequest(req) })
727
753
  return
728
754
  }
729
755
  if (req.method === 'GET' && path === '/api/sessions') return send(res, 200, viewFor(viewer))
730
756
  if (path === '/api/settings') {
731
- if (guest) return send(res, 403, { error: 'the machine settings belong to the owner of this board' })
757
+ if (!canMachine) return send(res, 403, { error: 'the machine settings belong to the owner of this board' })
732
758
  if (req.method === 'GET') return send(res, 200, { preferences: readPreferences() })
733
759
  if (req.method === 'POST' || req.method === 'PATCH') {
734
760
  const body = await readBody(req)
@@ -865,9 +891,30 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
865
891
  }
866
892
  if (req.method === 'POST' && (parts[3] === 'handoff' || parts[3] === 'end')) {
867
893
  if (!isActive(sess)) return send(res, 409, { error: `session ${id} is not active` })
868
- requestControl(id, parts[3] === 'handoff' ? { handoff: true, by: actor.id } : { end: true, by: actor.id })
869
- log(`${parts[3]} requested for ${id} by ${actor.id}`)
870
- return send(res, 200, { ok: true, requested: parts[3] })
894
+ if (parts[3] === 'end') {
895
+ requestControl(id, { end: true, by: actor.id })
896
+ log(`end requested for ${id} by ${actor.id}`)
897
+ return send(res, 200, { ok: true, requested: 'end' })
898
+ }
899
+ // Hand off now, optionally to a named destination. With no body the
900
+ // chain decides, exactly as it did before the picker existed.
901
+ const body = await readBody(req)
902
+ let target = null
903
+ if (body && body.agent !== undefined && body.agent !== null && body.agent !== '') {
904
+ const want = { agent: String(body.agent), account: String(body.account ?? 'default') }
905
+ const order = normalizeHandoffOrder(sess.handoff_order)
906
+ const chain = candidates({ agent: sess.agent, account: sess.account, accounts: readAccounts(), order })
907
+ const hit = chain.find((c) => c.agent === want.agent && c.account === want.account)
908
+ const label = `${want.agent}${want.account !== 'default' ? '/' + want.account : ''}`
909
+ if (!hit) return send(res, 400, { error: `${label} is not a destination for this terminal (${chain.map((c) => c.agent + (c.account !== 'default' ? '/' + c.account : '')).join(', ') || 'none'})` })
910
+ if (sess.installed && sess.installed[want.agent] === false) return send(res, 409, { error: `${label} is not installed on this machine` })
911
+ const u = readUsage(want.agent, want.account)
912
+ if (!isAvailable(u)) return send(res, 409, { error: `${label} is at its usage limit until ${fmtReset(u.limited_until)}; pick another or use Hand off now without a destination` })
913
+ target = hit
914
+ }
915
+ requestControl(id, target ? { handoff: true, target, by: actor.id } : { handoff: true, by: actor.id })
916
+ log(`handoff requested for ${id} by ${actor.id}${target ? ` to ${target.agent}/${target.account}` : ''}`)
917
+ return send(res, 200, { ok: true, requested: 'handoff', target })
871
918
  }
872
919
  if (req.method === 'DELETE' && parts.length === 3) {
873
920
  if (isActive(sess)) return send(res, 409, { error: 'end the session before removing it' })
@@ -944,6 +991,18 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
944
991
  const q = url.searchParams
945
992
  return send(res, 200, worktreesFor({ repo: q.get('repo') || null, dirty: q.get('dirty') !== '0' }))
946
993
  }
994
+ if (req.method === 'GET' && path === '/api/audit') {
995
+ const q = url.searchParams
996
+ const kind = q.get('kind')
997
+ if (kind && !ACTOR_KINDS.includes(kind)) return send(res, 400, { error: `kind is one of ${ACTOR_KINDS.join(', ')}` })
998
+ const limit = parseInt(q.get('limit') ?? '200', 10)
999
+ return send(res, 200, auditTrail({
1000
+ limit: Number.isFinite(limit) ? limit : 200,
1001
+ since: q.get('since'),
1002
+ who: q.get('who'),
1003
+ kind,
1004
+ }))
1005
+ }
947
1006
  if (req.method === 'GET' && path === '/api/floor') return send(res, 200, floor(listCards()))
948
1007
  if (req.method === 'GET' && path === '/api/trunk') return send(res, 200, trunk(listCards(), parseSince(url.searchParams.get('since'))))
949
1008
  if (req.method === 'GET' && path === '/api/leases') return send(res, 200, { leases: held(listCards()) })
@@ -1004,21 +1063,28 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
1004
1063
  }
1005
1064
 
1006
1065
  const onReq = (req, res) => { handle(req, res).catch((err) => { try { send(res, 500, { error: scrub(err.message) }) } catch {} }) }
1007
- const server = http.createServer(onReq)
1066
+ // TLS when a certificate pair is configured (leg share on --tls-cert/--tls-key,
1067
+ // or LEG_TLS_CERT/LEG_TLS_KEY). readTls throws rather than fall back to
1068
+ // plaintext: a board told to use TLS and quietly serving http would be the
1069
+ // worst outcome of the three.
1070
+ const tls = readTls(initialShare)
1071
+ const server = tls ? https.createServer({ cert: tls.cert, key: tls.key }, onReq) : http.createServer(onReq)
1008
1072
  // When the board is bound to a non-loopback address (share on), also listen on
1009
1073
  // 127.0.0.1 so the machine's own browser has a tokenless owner URL — a real
1010
- // remote peer's address is never loopback, so it still needs a token.
1074
+ // remote peer's address is never loopback, so it still needs a token. That one
1075
+ // stays plain http even under TLS: the certificate is for the shared name, and
1076
+ // loopback traffic never leaves this machine.
1011
1077
  const loopbackCompanion = !isLoopback(bind) ? http.createServer(onReq) : null
1012
1078
 
1013
1079
  return {
1014
1080
  server,
1015
- bind, port,
1081
+ bind, port, tls: tls ? { cert_path: tls.cert_path, key_path: tls.key_path } : null,
1016
1082
  start() {
1017
1083
  return new Promise((resolvePromise, reject) => {
1018
1084
  server.once('error', reject)
1019
1085
  server.listen(port, bind, () => {
1020
1086
  const addr = server.address()
1021
- log(`listening on http://${bind}:${addr.port} (home ${home()}${token ? ', token required' : ', loopback open'})`)
1087
+ log(`listening on ${tls ? 'https' : 'http'}://${bind}:${addr.port} (home ${home()}${token ? ', token required' : ', loopback open'}${tls ? `, TLS from ${tls.cert_path}` : ''})`)
1022
1088
  // A terminal that crashed instead of exiting left its hand-off in
1023
1089
  // .leg/RESUME.md looking live. The board is the thing that starts
1024
1090
  // after a crash, so it is where that gets corrected.
package/src/share.mjs CHANGED
@@ -6,15 +6,29 @@
6
6
  // (`BATON_PERSON`, else the owner). With it off nothing changes: loopback is
7
7
  // open and `BATON_TOKEN` is the only token.
8
8
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
9
- import { existsSync, readFileSync, mkdirSync } from 'node:fs'
9
+ import { existsSync, readFileSync, mkdirSync, statSync } from 'node:fs'
10
10
  import { networkInterfaces, userInfo } from 'node:os'
11
11
  import { createSocket } from 'node:dgram'
12
12
  import { join } from 'node:path'
13
13
  import { home } from './store.mjs'
14
14
  import { writeJsonAtomic } from './fsx.mjs'
15
15
 
16
- export const OFF = { version: 1, on: false, bind: null, bind_kind: null, port: null, owner: null, people: [] }
17
- export const ROLES = ['owner', 'guest']
16
+ export const OFF = { version: 1, on: false, bind: null, bind_kind: null, port: null, owner: null, people: [], tls: null }
17
+
18
+ // Three roles, because two were not enough to describe a second human who runs
19
+ // cards on this machine but has no business in its settings or its project map.
20
+ // owner everything: machine settings, the harness, every terminal, cards
21
+ // operator the pipeline board and their own terminals; not the settings,
22
+ // not the history index, not anyone else's terminal
23
+ // guest the terminals lane, read-only and redacted; may ask for a hand-off
24
+ export const ROLES = ['owner', 'operator', 'guest']
25
+
26
+ // One place that says what a role may reach, so no endpoint decides for itself.
27
+ // `cards` is the pipeline side of the board. `machine` is everything that
28
+ // describes this computer rather than the work: the settings, the harness
29
+ // policy, the history index and the worktree map.
30
+ export function mayUseCards(role) { return role === 'owner' || role === 'operator' }
31
+ export function mayUseMachine(role) { return role === 'owner' }
18
32
 
19
33
  export function sharePath() { return join(home(), 'share.json') }
20
34
 
@@ -55,9 +69,49 @@ export function identify(share, presented) {
55
69
  export function personNamed(share, name) { return share.people.find((p) => p.name.toLowerCase() === String(name ?? '').toLowerCase()) ?? null }
56
70
  export function isOwner(person) { return person?.role === 'owner' }
57
71
 
72
+ // ---- TLS ----
73
+ // Leg does not make certificates. It uses a pair you already have, which on a
74
+ // Tailscale network is one command (`tailscale cert <machine>.<tailnet>.ts.net`)
75
+ // and gives a certificate browsers already trust. A self-signed pair would
76
+ // teach everyone on the board to click through a warning, which is worse than
77
+ // no TLS at all on a network that is already private.
78
+ export class TlsRefused extends Error {
79
+ constructor(msg) { super(msg); this.name = 'TlsRefused'; this.exitCode = 3 }
80
+ }
81
+
82
+ function tlsPaths(share, env) {
83
+ return {
84
+ cert: env.LEG_TLS_CERT || env.BATON_TLS_CERT || share?.tls?.cert || null,
85
+ key: env.LEG_TLS_KEY || env.BATON_TLS_KEY || share?.tls?.key || null,
86
+ }
87
+ }
88
+
89
+ // → { cert, key, cert_path, key_path } | null. Throws TlsRefused when a pair is
90
+ // configured but unusable: a board that quietly fell back to plaintext after
91
+ // being told to use TLS is the one failure this must not have.
92
+ export function readTls(share = readShare(), env = process.env) {
93
+ const { cert: certPath, key: keyPath } = tlsPaths(share, env)
94
+ if (!certPath && !keyPath) return null
95
+ if (!certPath || !keyPath) throw new TlsRefused('TLS needs both a certificate and a key (--tls-cert and --tls-key, or LEG_TLS_CERT and LEG_TLS_KEY)')
96
+ for (const [label, file] of [['certificate', certPath], ['key', keyPath]]) {
97
+ if (!existsSync(file)) throw new TlsRefused(`TLS ${label} not found: ${file}`)
98
+ try { statSync(file) } catch (err) { throw new TlsRefused(`TLS ${label} ${file}: ${err.message}`) }
99
+ }
100
+ let cert
101
+ let key
102
+ try { cert = readFileSync(certPath) } catch (err) { throw new TlsRefused(`TLS certificate ${certPath}: ${err.message}`) }
103
+ try { key = readFileSync(keyPath) } catch (err) { throw new TlsRefused(`TLS key ${keyPath}: ${err.message}`) }
104
+ if (!cert.length || !key.length) throw new TlsRefused('the TLS certificate or key is empty')
105
+ return { cert, key, cert_path: certPath, key_path: keyPath }
106
+ }
107
+
108
+ export function tlsConfigured(share = readShare(), env = process.env) { return Boolean(tlsPaths(share, env).cert) }
109
+
110
+ export function scheme(share = readShare(), env = process.env) { return tlsConfigured(share, env) ? 'https' : 'http' }
111
+
58
112
  export function addPerson(name, { role = 'guest', share = readShare() } = {}) {
59
113
  if (!validName(name)) throw new Error(`bad name "${name}": letters, digits, dash and underscore, up to 32 characters`)
60
- if (!ROLES.includes(role)) throw new Error(`bad role "${role}" (owner|guest)`)
114
+ if (!ROLES.includes(role)) throw new Error(`bad role "${role}" (${ROLES.join('|')})`)
61
115
  if (personNamed(share, name)) throw new Error(`"${name}" is already on the board; baton share rotate ${name} issues a new link`)
62
116
  const token = newToken()
63
117
  const person = { name, role, token_sha256: hashToken(token), created_at: new Date().toISOString(), last_seen: null }
@@ -122,7 +176,7 @@ export async function resolveBind(kind = 'tailscale') {
122
176
  return lan.address
123
177
  }
124
178
 
125
- export function linkFor(share, token) { return `http://${share.bind}:${share.port}/?token=${token}` }
179
+ export function linkFor(share, token) { return `${scheme(share)}://${share.bind}:${share.port}/?token=${token}` }
126
180
 
127
181
  // Whose terminal this is: BATON_PERSON, else the board's owner, else 'local'.
128
182
  export function whoami(share = readShare()) {
@@ -131,9 +185,15 @@ export function whoami(share = readShare()) {
131
185
  return share.owner || 'local'
132
186
  }
133
187
 
134
- export async function turnOn({ bind = 'tailscale', port = Number(process.env.LEG_PORT || process.env.BATON_PORT || 4747), owner } = {}) {
188
+ export async function turnOn({ bind = 'tailscale', port = Number(process.env.LEG_PORT || process.env.BATON_PORT || 4747), owner, tlsCert = null, tlsKey = null } = {}) {
135
189
  const share = readShare()
136
190
  const address = await resolveBind(bind)
191
+ if (tlsCert || tlsKey) {
192
+ if (!tlsCert || !tlsKey) throw new TlsRefused('TLS needs both --tls-cert and --tls-key')
193
+ share.tls = { cert: tlsCert, key: tlsKey }
194
+ // read the pair now, so a bad one fails here and not at the next board start
195
+ readTls(share, {})
196
+ }
137
197
  share.on = true
138
198
  share.bind = address
139
199
  share.bind_kind = ['tailscale', 'lan'].includes(String(bind).toLowerCase()) ? String(bind).toLowerCase() : 'address'
package/src/taps/grok.mjs CHANGED
@@ -183,6 +183,10 @@ const LIMIT_RES = [
183
183
  ['grok-rate-limit-event', /"rate_limit"/i],
184
184
  ['grok-free-usage-exhausted', /subscription:free-usage-exhausted|You've used all of your free queries/i],
185
185
  ['grok-too-many-requests', /TOO_MANY_REQUESTS/],
186
+ // Observed live 2026-09-17 on grok 1.0.34: an account with no balance left
187
+ // answers 402, never 429, and none of the strings above appear. Without this
188
+ // the terminal sat on an exhausted login instead of handing off.
189
+ ['grok-balance-exhausted', /usage balance exhausted|status 402 Payment Required/i],
186
190
  ]
187
191
 
188
192
  // Scans log or stream text for Grok rate limit signals.
package/src/usage.mjs CHANGED
@@ -157,21 +157,37 @@ export function candidates({ agent, account = 'default', accounts, order = AGENT
157
157
  return out
158
158
  }
159
159
 
160
- // → { next: {agent, account} | null, out: [{agent, account, resets_at}] sorted by reset }
160
+ // → { next: {agent, account} | null, out: [{agent, account, resets_at}] sorted
161
+ // by reset, preferred_taken: bool }
161
162
  // `exclude` names (agent, account) pairs this choice must skip: a destination
162
163
  // the strict harness policy refused is neither available nor out, it is off
163
164
  // the list for this hand-off.
164
- export function chooseNext({ agent, account, accounts, installed, order = AGENTS, nowS = Math.floor(Date.now() / 1000), exclude = [] }) {
165
+ // `prefer` is a human's pick from the board ("Hand off now to codex"). It wins
166
+ // over the saved order when it is installed, available and not excluded. When
167
+ // it is none of those the order decides instead and `preferred_taken` is false,
168
+ // which is what the session event says: a pick made a minute ago must not leave
169
+ // a terminal stopped because that account walled in the meantime.
170
+ export function chooseNext({ agent, account, accounts, installed, order = AGENTS, nowS = Math.floor(Date.now() / 1000), exclude = [], prefer = null }) {
165
171
  const out = []
166
- for (const c of candidates({ agent, account, accounts, order })) {
172
+ const list = candidates({ agent, account, accounts, order })
173
+ const eligible = (c) => {
174
+ if (installed && installed[c.agent] === false) return false
175
+ if (exclude.some((x) => x.agent === c.agent && x.account === c.account)) return false
176
+ return isAvailable(readUsage(c.agent, c.account), nowS)
177
+ }
178
+ if (prefer) {
179
+ const hit = list.find((c) => c.agent === prefer.agent && c.account === (prefer.account ?? 'default'))
180
+ if (hit && eligible(hit)) return { next: hit, out, preferred_taken: true }
181
+ }
182
+ for (const c of list) {
167
183
  if (installed && installed[c.agent] === false) continue
168
184
  if (exclude.some((x) => x.agent === c.agent && x.account === c.account)) continue
169
185
  const u = readUsage(c.agent, c.account)
170
- if (isAvailable(u, nowS)) return { next: c, out }
186
+ if (isAvailable(u, nowS)) return { next: c, out, preferred_taken: false }
171
187
  out.push({ ...c, resets_at: u.limited_until, reason: u.limited_reason })
172
188
  }
173
189
  out.sort((a, b) => (a.resets_at ?? Infinity) - (b.resets_at ?? Infinity))
174
- return { next: null, out }
190
+ return { next: null, out, preferred_taken: false }
175
191
  }
176
192
 
177
193
  export function fmtReset(epochS) {