@ucsandman/legcli 0.13.1 → 0.15.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.
package/src/license.mjs CHANGED
@@ -8,10 +8,11 @@
8
8
  // team per-seat subscription; the key carries an expiry a few days past
9
9
  // the billing period, `leg license refresh` fetches a renewed one
10
10
  //
11
- // There is no trial. Leg pays off in the moment a limit lands mid-flow, which
12
- // is not a thing a fortnight of evaluation reliably contains; the risk reversal
13
- // is a 30-day money-back guarantee instead, which costs no code and no expiry
14
- // machinery. Key shape: LEG-<base64url payload>.<base64url signature> (legacy
11
+ // Before any key there is a 14-day trial, started the first time a session
12
+ // asks and recorded under $LEG_HOME/trial.json with every gate open. Deleting
13
+ // that file resets the clock; that is a known and accepted limit of an offline
14
+ // trial. Past the trial, or with a refused key, Leg needs a license; the risk
15
+ // reversal after buying is a 30-day money-back guarantee. Key shape: LEG-<base64url payload>.<base64url signature> (legacy
15
16
  // BATON- prefix still accepted) where the signature is Ed25519 over the payload
16
17
  // bytes exactly as encoded.
17
18
  import { createPublicKey, createPrivateKey, verify as cryptoVerify, sign as cryptoSign, createHash } from 'node:crypto'
@@ -29,11 +30,13 @@ export const PUBLIC_KEY_B64 = 'MCowBQYDK2VwAyEAIpVQymHHJAkIrZHv0u4o0bgfFmtW3Crm7
29
30
  const ACTIVE_PUBLIC_KEY = process.env.LEG_PUBLIC_KEY_B64 || process.env.BATON_PUBLIC_KEY_B64 || PUBLIC_KEY_B64
30
31
  // The date this release was cut. A personal key activates when this is on or
31
32
  // before its updates_until. Bumped with every published version.
32
- export const RELEASE_DATE = '2026-09-16'
33
+ export const RELEASE_DATE = '2026-09-18'
34
+ export const TRIAL_DAYS = 14
33
35
  export const GUARANTEE_DAYS = 30
34
36
  export const SITE = process.env.LEG_SITE || process.env.BATON_SITE || 'https://legcli.com'
35
37
  export const BUY_URL = `${SITE}/#pricing`
36
38
  export const PLANS = {
39
+ trial: { label: 'Trial', gates: ['run', 'share'] },
37
40
  personal: { label: 'Personal', gates: ['run'] },
38
41
  team: { label: 'Team', gates: ['run', 'share'] },
39
42
  }
@@ -86,6 +89,7 @@ export function verifyLicense(key, { publicKeyB64 = ACTIVE_PUBLIC_KEY, today = i
86
89
  }
87
90
 
88
91
  export function licensePath() { return join(home(), 'license.json') }
92
+ export function trialPath() { return join(home(), 'trial.json') }
89
93
 
90
94
  function readJson(f) { try { return JSON.parse(readFileSync(f, 'utf8')) } catch { return null } }
91
95
  function writeJson(f, obj) {
@@ -114,14 +118,30 @@ export function deactivate() {
114
118
  return had
115
119
  }
116
120
 
117
- // What this machine may do right now: a valid key, or nothing, with the reason
118
- // the stored key (if any) was refused.
119
- export function entitlement({ today = isoToday(), releaseDate = RELEASE_DATE } = {}) {
121
+ // The trial starts the first time something asks with start on (a session;
122
+ // `leg license status` asks with it off, so looking does not start the clock).
123
+ export function trial({ today = isoToday(), start = true } = {}) {
124
+ let t = readJson(trialPath())
125
+ if (!t?.started && start) { t = { started: today }; writeJson(trialPath(), t) }
126
+ if (!t?.started) return { started: null, daysLeft: TRIAL_DAYS, expired: false }
127
+ const used = Math.floor((Date.parse(today) - Date.parse(t.started)) / 86400000)
128
+ const daysLeft = Math.max(0, TRIAL_DAYS - used)
129
+ return { started: t.started, daysLeft, expired: used >= TRIAL_DAYS || used < 0 }
130
+ }
131
+
132
+ // What this machine may do right now. A valid key wins; otherwise the trial;
133
+ // otherwise nothing, with the reason the stored key (if any) was refused.
134
+ export function entitlement({ today = isoToday(), releaseDate = RELEASE_DATE, startTrial = true } = {}) {
120
135
  const lic = readLicense()
121
- if (!lic) return { ok: false, plan: 'none', reason: 'no-license', refused: null }
122
- const v = verifyLicense(lic.key, { today, releaseDate })
123
- if (v.ok) return { ok: true, plan: v.payload.plan, seats: v.payload.seats ?? 1, payload: v.payload, source: 'license' }
124
- return { ok: false, plan: 'none', reason: v.reason, refused: v.reason }
136
+ let refused = null
137
+ if (lic) {
138
+ const v = verifyLicense(lic.key, { today, releaseDate })
139
+ if (v.ok) return { ok: true, plan: v.payload.plan, seats: v.payload.seats ?? 1, payload: v.payload, source: 'license' }
140
+ refused = v.reason
141
+ }
142
+ const t = trial({ today, start: startTrial })
143
+ if (!t.expired) return { ok: true, plan: 'trial', started: t.started, daysLeft: t.daysLeft, source: 'trial', refused }
144
+ return { ok: false, plan: 'none', reason: refused ?? 'trial-expired', refused }
125
145
  }
126
146
 
127
147
  export function allows(ent, gate) {
@@ -136,13 +156,18 @@ export function explain(reason, payload) {
136
156
  case 'unknown-plan': return `the key names a plan this version does not know (${payload?.plan})`
137
157
  case 'personal-updates-ended': return `this Personal key covers releases up to ${payload?.updates_until}; this release is dated ${RELEASE_DATE}. Keep the version you have, or renew at ${BUY_URL}`
138
158
  case 'team-expired': return `this Team key expired on ${payload?.expires}; run "leg license refresh" (the subscription renews it) or see ${BUY_URL}`
139
- case 'no-license': return `Leg needs a license key. Buy one at ${BUY_URL} (${GUARANTEE_DAYS}-day money-back guarantee), then: leg license activate <key>`
159
+ case 'trial-expired': return `the ${TRIAL_DAYS}-day trial has ended; Leg needs a license key. Buy one at ${BUY_URL} (${GUARANTEE_DAYS}-day money-back guarantee), then: leg license activate <key>`
140
160
  default: return String(reason)
141
161
  }
142
162
  }
143
163
 
144
164
  export function describe(ent) {
145
165
  if (!ent.ok) return `no license: ${explain(ent.reason)}`
166
+ if (ent.plan === 'trial') {
167
+ const note = ent.refused ? ` (stored key refused: ${ent.refused})` : ''
168
+ if (!ent.started) return `${TRIAL_DAYS}-day trial, not started; it starts with your first leg <agent>${note}`
169
+ return `${TRIAL_DAYS}-day trial, ${ent.daysLeft} day${ent.daysLeft === 1 ? '' : 's'} left; buy at ${BUY_URL}${note}`
170
+ }
146
171
  const p = ent.payload
147
172
  const until = p.plan === 'personal' ? `updates through ${p.updates_until}` : `renews; valid through ${p.expires}`
148
173
  return `${PLANS[p.plan].label} license ${p.id}${p.seats > 1 ? `, ${p.seats} seats` : ''}, ${until}`
package/src/limits.mjs CHANGED
@@ -29,7 +29,25 @@ function loadSignals() {
29
29
  return out
30
30
  }
31
31
 
32
- export const SIGNALS = loadSignals()
32
+ // The fixture tree (24 files, one RegExp compile each) loads on first use,
33
+ // not at import: a command that pulls in this module (via runner.mjs,
34
+ // transitively via the scheduler or orchestrator) but never calls classify()
35
+ // never pays for it. `SIGNALS` stays a plain array to every reader (test/
36
+ // and scripts/limits-table.mjs both use SIGNALS.length/.filter/.map at their
37
+ // own top level, with no loader to call first) via a Proxy whose `get` trap
38
+ // loads and memoises on first property access.
39
+ let cachedSignals = null
40
+ function ensureSignals() {
41
+ if (!cachedSignals) cachedSignals = loadSignals()
42
+ return cachedSignals
43
+ }
44
+
45
+ export const SIGNALS = new Proxy([], {
46
+ get(_target, prop) { return Reflect.get(ensureSignals(), prop) },
47
+ has(_target, prop) { return Reflect.has(ensureSignals(), prop) },
48
+ ownKeys() { return Reflect.ownKeys(ensureSignals()) },
49
+ getOwnPropertyDescriptor(_target, prop) { return Reflect.getOwnPropertyDescriptor(ensureSignals(), prop) },
50
+ })
33
51
 
34
52
  const AUTH_SOURCE_RE = /another auth source is set/i
35
53
 
@@ -0,0 +1,22 @@
1
+ // scheduler-status — the scheduler's pidfile and its two cheap readers,
2
+ // split out of src/scheduler.mjs so a caller that only wants "is the
3
+ // scheduler running" (the launcher, `leg status`) does not have to pull in
4
+ // the whole orchestrator → land/mergequeue/stations/chain/pipeline/contract/
5
+ // commands/runner/limits graph. src/scheduler.mjs re-exports both, so every
6
+ // existing import of them keeps working unchanged.
7
+ import { existsSync, readFileSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+ import { home } from './store.mjs'
10
+
11
+ export const MAX_CONCURRENT = Math.max(1, parseInt((process.env.LEG_MAX_CONCURRENT || process.env.BATON_MAX_CONCURRENT) || '2', 10) || 2)
12
+
13
+ export function pidfile() { return join(home(), 'scheduler.pid') }
14
+
15
+ export function schedulerStatus() {
16
+ const f = pidfile()
17
+ if (!existsSync(f)) return { running: false, pid: null }
18
+ const pid = parseInt(readFileSync(f, 'utf8').trim(), 10)
19
+ let alive = false
20
+ try { process.kill(pid, 0); alive = true } catch {}
21
+ return { running: alive, pid, stale: !alive }
22
+ }
package/src/scheduler.mjs CHANGED
@@ -4,14 +4,14 @@
4
4
  // blocked_by event per blocker change. Cards run inside this process via
5
5
  // orchestrator.runCard (async, non-blocking).
6
6
  import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
7
- import { join } from 'node:path'
8
7
  import { conflicts } from './leases.mjs'
9
8
  import { canonPath } from './fsx.mjs'
10
9
  import { runCard, orphanedRun, unsettledRun, driverAlive } from './orchestrator.mjs'
11
- import { listCards, ledgerAppend, ledgerLog, home, sleep } from './store.mjs'
10
+ import { listCards, ledgerAppend, ledgerLog, sleep } from './store.mjs'
12
11
  import { readSession, isActive } from './sessions.mjs'
12
+ import { pidfile, schedulerStatus, MAX_CONCURRENT } from './scheduler-status.mjs'
13
13
 
14
- export const MAX_CONCURRENT = Math.max(1, parseInt((process.env.LEG_MAX_CONCURRENT || process.env.BATON_MAX_CONCURRENT) || '2', 10) || 2)
14
+ export { pidfile, schedulerStatus, MAX_CONCURRENT }
15
15
  const ACTIVE = ['running', 'handing_off']
16
16
 
17
17
  // Two cards on one repo compare by the canonical path, so two spellings of a
@@ -60,8 +60,6 @@ export function heldByLiveTerminal(card) {
60
60
  } catch { return null }
61
61
  }
62
62
 
63
- export function pidfile() { return join(home(), 'scheduler.pid') }
64
-
65
63
  export function createScheduler({ max = MAX_CONCURRENT, intervalMs = 1000, actor = { type: 'leg' } } = {}) {
66
64
  const state = { blockedKeys: new Map(), inflight: new Map(), stopped: false, ticks: 0 }
67
65
 
@@ -130,12 +128,3 @@ export function createScheduler({ max = MAX_CONCURRENT, intervalMs = 1000, actor
130
128
 
131
129
  return { tick, run, stop, state }
132
130
  }
133
-
134
- export function schedulerStatus() {
135
- const f = pidfile()
136
- if (!existsSync(f)) return { running: false, pid: null }
137
- const pid = parseInt(readFileSync(f, 'utf8').trim(), 10)
138
- let alive = false
139
- try { process.kill(pid, 0); alive = true } catch {}
140
- return { running: alive, pid, stale: !alive }
141
- }
package/src/server.mjs CHANGED
@@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url'
13
13
  import { checkBind, authorize, remoteAddress, presentedToken, isLoopback, isLoopbackRequest, tokenMatches } from './auth.mjs'
14
14
  import { readShare, isOn as shareIsOn, sharePath, identify, personNamed, mayUseCards, mayUseMachine, readTls } from './share.mjs'
15
15
  import { auditTrail, ACTOR_KINDS } from './audit.mjs'
16
+ import { buildDigest, DEFAULT_SINCE } from './digest.mjs'
16
17
  import { createLimiter } from './ratelimit.mjs'
17
18
  import { realPath, canonPath } from './fsx.mjs'
18
19
  import { listCards, readCard, readRuns, readEvents, cardDir, home, ledgerAppend, ledgerUpdate } from './store.mjs'
@@ -33,15 +34,17 @@ import { sessionDetail, sessionDiff, DiffInputError } from './session-detail.mjs
33
34
  import { hasRecentSynthesis } from './synthesis.mjs'
34
35
  import { refreshPointers } from './resume.mjs'
35
36
  import { landSession, landBlocker, landingNow, pruneSessionWorktree, canLand, prepareLanding, applyLandFix } from './land.mjs'
36
- import { readUsage, usageIsStale, candidates, isAvailable, fmtReset, binding, evaluateLadder, rungLabel, wallActive } from './usage.mjs'
37
+ import { readUsage, usageIsStale, candidates, isAvailable, fmtReset, binding, evaluateLadder, rungLabel, wallActive, keepsConversation } from './usage.mjs'
37
38
  import { readAccounts } from './accounts.mjs'
38
39
  import { createUsagePollers, USAGE_AGENTS } from './usage-poll.mjs'
39
40
  import { readCodexUsage, transcriptTail as codexTranscriptTail } from './taps/codex.mjs'
40
41
  import { readPreferences, writePreferences, normalizeHandoffOrder, requireHandoffOrder, ladderFor, requireHandoffLadder, requireClimbBack, requireReserve, orderFromLadder } from './preferences.mjs'
41
- import { isDownshift } from './buckets.mjs'
42
42
  import { listModels } from './models.mjs'
43
43
  import { listHistory, findRecord, recordDetail, refreshIndex, readIndex, providerSupport, HistoryInputError, PROVIDER_NAMES } from './history/index.mjs'
44
- import { listWorktrees } from './history/worktrees.mjs'
44
+ // the async variant: a cold worktree list is forty git processes, and run on
45
+ // this process's stack that is seconds of a board that answers nothing.
46
+ // `leg worktrees` keeps the synchronous one.
47
+ import { listWorktreesAsync } from './history/worktrees.mjs'
45
48
 
46
49
  const SELF = fileURLToPath(import.meta.url)
47
50
  export function resolveBoardDir() {
@@ -62,11 +65,6 @@ const TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=
62
65
  const log = (msg) => { const q = process.env.LEG_QUIET ?? process.env.BATON_QUIET; if (q !== '1') process.stdout.write(`[board] ${new Date().toISOString()} ${msg}\n`) }
63
66
 
64
67
  // ---- read models ----
65
- function lastEventOf(id) {
66
- const evs = readEvents(id)
67
- return evs.length ? evs[evs.length - 1] : null
68
- }
69
-
70
68
  export function columnsFor(cards) {
71
69
  const names = new Set()
72
70
  for (const c of cards) for (const s of c.pipeline ?? []) names.add(s.name)
@@ -246,6 +244,9 @@ function cardsWaiting() {
246
244
  cardsWaitingCache = { at: Date.now(), data }
247
245
  return data
248
246
  }
247
+ // A card that just started or stopped waiting on a human makes that answer
248
+ // stale at once, and the sessions push it triggers is the reader's only cue.
249
+ function forgetCardsWaiting() { cardsWaitingCache = { at: 0, data: null } }
249
250
 
250
251
  // `events` is the card's ledger, already read by the caller. readEvents() does
251
252
  // a readdir, a full readFileSync, a JSON.parse per line and a sort every call,
@@ -348,13 +349,19 @@ function logTail(id, run, tail = 200) {
348
349
  }
349
350
 
350
351
  function floor(cards) {
352
+ // One ledger read per card in this answer, however many parts of it ask:
353
+ // readEvents is a readdir, a whole readFileSync and a JSON.parse per line,
354
+ // and the floor page polls this every two seconds.
355
+ const events = new Map()
356
+ const eventsOf = (id) => { if (!events.has(id)) events.set(id, readEvents(id)); return events.get(id) }
351
357
  const running = cards.filter((c) => ['running', 'handing_off'].includes(c.status)).map((c) => {
352
- const s = summarize(c)
358
+ const s = summarize(c, eventsOf(c.card_id))
353
359
  return { card_id: c.card_id, title: c.title, station: c.station, status: c.status, adapter: s.active_adapter, leg: c.leg, leases: c.leases?.length ? c.leases : ['**'], last_event: s.last_event, since: s.active_run?.started_at ?? c.updated_at, elapsed_ms: s.elapsed_ms, repo_name: s.repo_name }
354
360
  })
355
361
  const waiting = cards.filter((c) => ['waiting_human', 'needs_approval', 'paused'].includes(c.status)).map((c) => ({ card_id: c.card_id, title: c.title, station: c.station, status: c.status, since: c.updated_at, actions: availableActions(c) }))
356
362
  const queued = cards.filter((c) => c.status === 'queued').map((c) => {
357
- const last = lastEventOf(c.card_id)
363
+ const evs = eventsOf(c.card_id)
364
+ const last = evs.length ? evs[evs.length - 1] : null
358
365
  return { card_id: c.card_id, title: c.title, station: c.station, leases: c.leases?.length ? c.leases : ['**'], blocked_by: last?.type === 'blocked_by' ? last.summary : null }
359
366
  })
360
367
  return {
@@ -386,6 +393,23 @@ function parseSince(s) {
386
393
  return parseInt(m[1], 10) * { m: 60000, h: 3600000, d: 86400000 }[m[2]]
387
394
  }
388
395
 
396
+ // The floor page polls /api/trunk every two seconds, and one answer reads every
397
+ // card's whole ledger to find the `landed` events inside the window. Cached for
398
+ // the same beat trunkFor uses, keyed by the window asked for; a landing clears
399
+ // it, because a landing is the one thing this list exists to show.
400
+ const TRUNK_VIEW_TTL = 15000
401
+ const trunkViewCache = new Map()
402
+ function trunkView(sinceMs) {
403
+ const hit = trunkViewCache.get(sinceMs)
404
+ if (hit && Date.now() - hit.at < TRUNK_VIEW_TTL) return hit.data
405
+ const data = trunk(listCards(), sinceMs)
406
+ // `since` comes off the query string, so the key space is the caller's: keep
407
+ // it a cache rather than a leak
408
+ if (trunkViewCache.size > 50) trunkViewCache.clear()
409
+ trunkViewCache.set(sinceMs, { at: Date.now(), data })
410
+ return data
411
+ }
412
+
389
413
  let toolsCache = null
390
414
  // Is this agent actually here? Ask its adapter to resolve the binary the
391
415
  // runner would spawn (codex's native exe, an npm entry, a BATON_<AGENT>_BIN
@@ -440,13 +464,30 @@ function backgroundHistoryRefresh() {
440
464
  execFile(process.execPath, [LEG_BIN, 'history', 'refresh', '--json'], { env: process.env, windowsHide: true, timeout: 120000 }, () => { historyRefreshing = false })
441
465
  } catch { historyRefreshing = false }
442
466
  }
443
- function worktreesFor({ repo = null, dirty = true } = {}) {
467
+ // One refresh at a time per query, and never on this stack. The list is up to
468
+ // twenty `git worktree list` calls plus twenty `git status` calls at about half
469
+ // a second each; synchronously that was measured at 9-25 s in which the board
470
+ // served no stylesheet, no click and no SSE frame. listWorktreesAsync runs them
471
+ // four at a time through execFile, so the loop keeps turning, and while a
472
+ // refresh is in flight every other caller is answered from the list already in
473
+ // hand rather than starting a second fan-out — a page that polls this must
474
+ // never be able to queue them.
475
+ const worktreesInflight = new Map()
476
+ async function worktreesFor({ repo = null, dirty = true } = {}) {
444
477
  const key = `${repo ?? ''}|${dirty}`
445
478
  const hit = worktreesCache.get(key)
446
479
  if (hit && Date.now() - hit.at < WORKTREES_TTL) return hit.data
447
- const data = listWorktrees({ repo, dirty, dirtyLimit: 20, repoLimit: 20 })
448
- worktreesCache.set(key, { at: Date.now(), data })
449
- return data
480
+ let refresh = worktreesInflight.get(key)
481
+ if (!refresh) {
482
+ refresh = listWorktreesAsync({ repo, dirty, dirtyLimit: 20, repoLimit: 20 })
483
+ .then((data) => { worktreesCache.set(key, { at: Date.now(), data }); return data })
484
+ .finally(() => { worktreesInflight.delete(key) })
485
+ worktreesInflight.set(key, refresh)
486
+ }
487
+ // the very first caller has nothing to answer from and waits for git; every
488
+ // caller after it reads the last list while the refresh finishes behind it
489
+ if (hit) { refresh.catch(() => {}); return hit.data }
490
+ return refresh
450
491
  }
451
492
 
452
493
  // canLand shells out to git several times for one worktree, and the view runs
@@ -601,7 +642,19 @@ function visibleSessionFile(file) {
601
642
  return !value.includes('*** Begin Patch') && !value.includes('*** End Patch')
602
643
  }
603
644
 
604
- export function sessionsView({ viewer = null, share = null } = {}) {
645
+ // What every row carries that nothing on the board reads. One row per terminal
646
+ // ships in every SSE push and in every /api/sessions answer, so a field no
647
+ // board file and no test reads off a row is paid for on every push: `argv` is
648
+ // the command line, `files_touched` is already merged into `files` beside
649
+ // `files_dirty`, `runtime_capabilities` is summarised by
650
+ // `can_edit_handoff_order`, and the other four are the runner's own bookkeeping.
651
+ // The record on disk keeps all of them, and so does GET /api/sessions/<id>,
652
+ // which is the route that hands over the whole record.
653
+ const ROW_DROPPED = ['argv', 'runner_pid', 'head_at_start', 'checkpoints', 'agent_sessions', 'runtime_capabilities', 'files_touched']
654
+
655
+ // `read` is the usage reader, one per view (see the memo below). A test counts
656
+ // through it; nothing else passes it.
657
+ export function sessionsView({ viewer = null, share = null, read = readUsage } = {}) {
605
658
  const shared = Boolean(share && shareIsOn(share))
606
659
  // Decided before the map below, because the per-session payload has to know
607
660
  // it: a guest owns their own terminal and may hand it off, so they get its
@@ -613,6 +666,22 @@ export function sessionsView({ viewer = null, share = null } = {}) {
613
666
  const configuredAccounts = readAccounts()
614
667
  // the spending rules the picker has to print, read once for the whole view
615
668
  const prefs = readPreferences()
669
+ // Four files on disk were read 143 times per answer: evaluateLadder memoises
670
+ // inside one call, and this view makes one call per terminal, then reads the
671
+ // same login again for `capacity` and again for the accounts payload. One
672
+ // reader for the whole view, so a login is read at most once however many
673
+ // terminals stand on it — and every part of the answer is then built from the
674
+ // same reading, which is also the only way `capacity` and a picker row can
675
+ // never disagree.
676
+ const usageOf = new Map()
677
+ const readUsageOnce = (agent, account = 'default') => {
678
+ const key = `${agent}--${account}`
679
+ if (!usageOf.has(key)) usageOf.set(key, read(agent, account))
680
+ return usageOf.get(key)
681
+ }
682
+ // one readdir per checkout for the whole view instead of three existsSync per
683
+ // terminal (src/synthesis.mjs sessionFileIndex)
684
+ const synthesisIndex = new Map()
616
685
  const sessions = list.map((s) => {
617
686
  const land = readLand(s.session_id)
618
687
  const handoffOrder = normalizeHandoffOrder(s.handoff_order)
@@ -626,12 +695,14 @@ export function sessionsView({ viewer = null, share = null } = {}) {
626
695
  // and the rung an automatic hand-off would take can never disagree. The
627
696
  // picker is a human pressing a button, so the reserve is a note here, not
628
697
  // a refusal (B.3).
629
- const rungs = evaluateLadder({ from, list: chain, installed: availabilityKnown ? s.installed : null, maySpend: prefs.may_spend, reserve: prefs.reserve, automatic: false, climbBack: prefs.climb_back, ladder: handoffLadder })
698
+ const rungs = evaluateLadder({ from, list: chain, installed: availabilityKnown ? s.installed : null, maySpend: prefs.may_spend, reserve: prefs.reserve, automatic: false, climbBack: prefs.climb_back, ladder: handoffLadder, read: readUsageOnce })
630
699
  const open = availabilityKnown ? rungs.find((r) => r.ok) : null
631
700
  const eligibleNext = open ? { agent: open.agent, account: open.account, ...(open.model ? { model: open.model } : {}) } : null
632
701
  const can = s.worktree ? canLandFor(s) : { ok: false, blockers: [{ code: 'no_worktree', message: 'this terminal works in the checkout itself: there is no branch of its own to land', fix: null }] }
702
+ const row = { ...s }
703
+ for (const k of ROW_DROPPED) delete row[k]
633
704
  return {
634
- ...s,
705
+ ...row,
635
706
  handoff_order: handoffOrder,
636
707
  chain,
637
708
  preferred_next: preferredNext,
@@ -653,10 +724,11 @@ export function sessionsView({ viewer = null, share = null } = {}) {
653
724
  // A row that is blocked keeps a reason a guest may read.
654
725
  reason: guest ? (r.ok ? null : guestReason(r.reason)) : r.reason,
655
726
  resets_at: !guest ? (r.resets_at ?? null) : null,
656
- // the probe in fixtures/live/claude/resume-model-probe.json: a claude
657
- // downshift resumes the same conversation; everything else is primed
658
- // from the bundle, codex included until its own resume is observed
659
- keeps_conversation: Boolean(isDownshift(from, r) && r.agent === 'claude' && s.agent_session_id),
727
+ // the same rule the terminal applies at the switch (src/usage.mjs
728
+ // keepsConversation): a claude downshift, or another claude login that
729
+ // can see this transcript, resumes the conversation; everything else
730
+ // is primed from the bundle, codex included until its resume is observed
731
+ keeps_conversation: keepsConversation({ from, to: r, session: s }),
660
732
  // the cost word is not static: `credits` on a claude/fable rung means
661
733
  // this machine's login has usage credits switched on (preferences.mjs
662
734
  // rungCost reads extra_usage.enabled), which is a fact about the
@@ -670,10 +742,10 @@ export function sessionsView({ viewer = null, share = null } = {}) {
670
742
  // and never persisted: it depends on the model the row is running, and
671
743
  // the record only knows the login. A guest never gets it: it is a
672
744
  // percentage of this machine's usage (.design/BOARD-DESIGN.md 6.13).
673
- ...(guest ? {} : { capacity: binding(readUsage(s.agent, s.account), s.model ?? null) }),
745
+ ...(guest ? {} : { capacity: binding(readUsageOnce(s.agent, s.account), s.model ?? null) }),
674
746
  can_edit_handoff_order: s.runtime_capabilities?.includes(HANDOFF_ORDER_CAPABILITY) ?? false,
675
747
  active: isActive(s),
676
- has_synthesis: hasRecentSynthesis(s),
748
+ has_synthesis: hasRecentSynthesis(s, { index: synthesisIndex }),
677
749
  overlap: ov.get(s.session_id) ?? [],
678
750
  elapsed_ms: Date.now() - Date.parse(s.started_at),
679
751
  // Older attached Codex processes can retain one parser mistake where an
@@ -688,7 +760,7 @@ export function sessionsView({ viewer = null, share = null } = {}) {
688
760
  })
689
761
  const accounts = []
690
762
  for (const agent of Object.keys(configuredAccounts)) for (const account of configuredAccounts[agent]) {
691
- const u = readUsage(agent, account)
763
+ const u = readUsageOnce(agent, account)
692
764
  // buckets, walls, extra_usage and facts are owner-only for the same reason
693
765
  // the percentages are: they say how much of this machine's login is gone.
694
766
  // The guest branch at the bottom of this function drops the slot to
@@ -783,15 +855,41 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, sessionsDebounce
783
855
  try { c.res.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`) } catch {}
784
856
  }
785
857
  }
858
+ // The terminals verdict says "card 3e1c has waited on you for 12 minutes",
859
+ // and it reads `cards_waiting` off the sessions payload (sessionsView). A card
860
+ // entering or leaving a human-waiting status changes that payload with nothing
861
+ // in the sessions tree behind it, so the stat fingerprint cannot see it and
862
+ // the health tick no longer rebuilds the view regardless. The card watcher
863
+ // says so instead — the only card change that reaches the terminals page, and
864
+ // it costs nothing on a board where no card moves.
865
+ const waitingCards = new Set()
866
+ const noteWaiting = (id, status) => {
867
+ const waiting = WAITING_STATUSES.includes(status)
868
+ if (waiting === waitingCards.has(id)) return false
869
+ if (waiting) waitingCards.add(id)
870
+ else waitingCards.delete(id)
871
+ forgetCardsWaiting()
872
+ return true
873
+ }
786
874
  // Re-read and re-emit exactly one card's files — never the whole ledger. Each
787
875
  // client carries its own high-water mark, so a second client connecting never
788
876
  // resets the count the first is reading from.
789
877
  const refreshCard = (id) => {
878
+ // /api/trunk is built from the card ledgers, and one of them just changed:
879
+ // the floor polls that list every two seconds and a `landed` row must not
880
+ // wait out the cache behind it
881
+ trunkViewCache.clear()
790
882
  const card = readCard(id)
791
883
  // pipeline cards and their events belong to the people who may run them:
792
884
  // the owner and any operator. A guest never gets them.
793
885
  const forOwner = (payload) => (viewer) => (viewer && !mayUseCards(viewer.role) ? null : payload)
794
- if (!card) { for (const c of clients) c.sig.delete(id); broadcast('removed', forOwner({ card_id: id })); return }
886
+ if (!card) {
887
+ for (const c of clients) c.sig.delete(id)
888
+ if (noteWaiting(id, null)) scheduleSessionsPush({ force: true })
889
+ broadcast('removed', forOwner({ card_id: id }))
890
+ return
891
+ }
892
+ if (noteWaiting(id, card.status)) scheduleSessionsPush({ force: true })
795
893
  const events = readEvents(id)
796
894
  // broadcast() refreshes each client's viewer (and drops revoked ones) first
797
895
  broadcast('card', forOwner(summarize(card, events)))
@@ -816,11 +914,11 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, sessionsDebounce
816
914
  let sessionsTimer = null
817
915
  let lastSessionsPush = 0
818
916
  const pushSessions = () => {
819
- sessionsTimer = null
820
917
  try { broadcast('sessions', (viewer) => viewFor(viewer)) } catch (err) { log(`sessions view: ${err.message}`) }
821
918
  lastSessionsPush = Date.now()
822
- // the health tick pushes on its own schedule: record what it sent, so the
823
- // next watcher hint is measured against the page's real contents
919
+ // the health tick and a card that changed hands push on their own schedule:
920
+ // record what was sent, so the next watcher hint is measured against the
921
+ // page's real contents
824
922
  lastFingerprint = sessionsFingerprint()
825
923
  }
826
924
  // One live agent rewrites its record about every six seconds and takes a
@@ -854,16 +952,30 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, sessionsDebounce
854
952
  return sig
855
953
  }
856
954
  let lastFingerprint = null
955
+ // Has anything the view is built from moved since the last push? Asked by the
956
+ // watcher's debounce and by the health tick, and it is the reason neither
957
+ // rebuilds a view that would come out identical to the one the page has.
958
+ const sessionsChanged = () => {
959
+ const sig = sessionsFingerprint()
960
+ if (sig === lastFingerprint) return false
961
+ lastFingerprint = sig
962
+ return true
963
+ }
964
+ let forcePush = false
857
965
  const pushIfChanged = () => {
858
966
  sessionsTimer = null
859
- const sig = sessionsFingerprint()
967
+ const forced = forcePush
968
+ forcePush = false
969
+ const changed = sessionsChanged()
860
970
  // the hint was noise: the view would rebuild to exactly what the page
861
971
  // already has, so nothing is rebuilt and nothing is sent
862
- if (sig === lastFingerprint) return
863
- lastFingerprint = sig
972
+ if (!changed && !forced) return
864
973
  pushSessions()
865
974
  }
866
- const scheduleSessionsPush = () => {
975
+ // `force` is for a change the sessions tree cannot show (a card that started
976
+ // waiting on the human): the debounce and the minimum interval still apply.
977
+ const scheduleSessionsPush = ({ force = false } = {}) => {
978
+ if (force) forcePush = true
867
979
  if (sessionsTimer) return
868
980
  const wait = Math.max(sessionsDebounceMs, sessionsMinIntervalMs - (Date.now() - lastSessionsPush))
869
981
  sessionsTimer = setTimeout(pushIfChanged, wait)
@@ -895,7 +1007,20 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, sessionsDebounce
895
1007
  healthTimer = setInterval(() => {
896
1008
  const ts = new Date().toISOString()
897
1009
  broadcast('health', (viewer) => (viewer && viewer.role !== 'owner' ? { ok: true, ts } : { ok: true, scheduler: { ...schedulerStatus(), max_concurrent: MAX_CONCURRENT }, ts }))
898
- pushSessions()
1010
+ // This used to push the whole sessions view unconditionally, six times a
1011
+ // minute per client, which is the ~512 fs calls and the git subprocesses
1012
+ // of a full rebuild whether or not one byte had changed: an idle board
1013
+ // with 43 terminals measured 7,294 fs calls, 8 git processes and 1.14 CPU
1014
+ // seconds a minute for nothing. The page's elapsed clocks tick
1015
+ // client-side, the usage poller pushes what it reads, and a card that
1016
+ // starts waiting on a human forces a push of its own, so the tick asks
1017
+ // the same question a watcher hint asks and stays quiet when the answer
1018
+ // is no. One thing moves no file: a runner that died (a closed window, a
1019
+ // crash) — the unconditional rebuild used to catch it through reapLost
1020
+ // inside the view, so the liveness pass runs here on its own, and the
1021
+ // record it marks lost is the write that moves the fingerprint.
1022
+ try { reapLost(listSessions()) } catch (err) { log(`reap: ${err.message}`) }
1023
+ if (sessionsChanged()) pushSessions()
899
1024
  }, healthIntervalMs)
900
1025
  }
901
1026
  const stopWatch = () => {
@@ -1078,7 +1203,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
1078
1203
  const ownsSession = (s) => !shared || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
1079
1204
  const parts = path.split('/').filter(Boolean) // ['api', ...]
1080
1205
  if (!canCards && ['cards', 'floor', 'presets', 'adapters', 'leases', 'models'].includes(parts[1])) return send(res, 403, { error: 'the pipeline board belongs to the owner and the operators of this machine' })
1081
- 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.' })
1206
+ if (!canMachine && ['trunk', 'history', 'worktrees', 'audit', 'digest'].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.' })
1082
1207
  try {
1083
1208
  if (req.method === 'GET' && path === '/api/health') {
1084
1209
  const you = { ...viewer, share: { on: shared, people: shared ? share.people.length : 0 } }
@@ -1287,8 +1412,9 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
1287
1412
  landSession(sess, { by: actor.id, autoCommit: true })
1288
1413
  .catch((err) => log(`land ${id}: ${err.message}`))
1289
1414
  // a landing moves the branch under every worktree cut from it, so
1290
- // the cached land-ability goes with the cached trunk
1291
- .finally(() => { trunkCache.clear(); canLandCache.clear(); try { sse.broadcast('sessions', (v) => viewFor(v)) } catch {} })
1415
+ // the cached land-ability goes with the cached trunk — and the
1416
+ // landed commit is exactly what /api/trunk is asked for
1417
+ .finally(() => { trunkCache.clear(); trunkViewCache.clear(); canLandCache.clear(); try { sse.broadcast('sessions', (v) => viewFor(v)) } catch {} })
1292
1418
  log(`land requested for ${id} by ${actor.id}`)
1293
1419
  return send(res, 202, { ok: true, requested: 'land' })
1294
1420
  }
@@ -1495,7 +1621,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
1495
1621
  }
1496
1622
  if (req.method === 'GET' && path === '/api/worktrees') {
1497
1623
  const q = url.searchParams
1498
- return send(res, 200, worktreesFor({ repo: q.get('repo') || null, dirty: q.get('dirty') !== '0' }))
1624
+ return send(res, 200, await worktreesFor({ repo: q.get('repo') || null, dirty: q.get('dirty') !== '0' }))
1499
1625
  }
1500
1626
  if (req.method === 'GET' && path === '/api/audit') {
1501
1627
  const q = url.searchParams
@@ -1509,8 +1635,15 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
1509
1635
  kind,
1510
1636
  }))
1511
1637
  }
1638
+ if (req.method === 'GET' && path === '/api/digest') {
1639
+ // what happened while the owner was away (src/digest.mjs); owner only,
1640
+ // gated with the audit trail above: it names repositories and prompts
1641
+ let d
1642
+ try { d = buildDigest({ since: url.searchParams.get('since') ?? DEFAULT_SINCE }) } catch (err) { return send(res, 400, { error: err.message }) }
1643
+ return send(res, 200, d)
1644
+ }
1512
1645
  if (req.method === 'GET' && path === '/api/floor') return send(res, 200, floor(listCards()))
1513
- if (req.method === 'GET' && path === '/api/trunk') return send(res, 200, trunk(listCards(), parseSince(url.searchParams.get('since'))))
1646
+ if (req.method === 'GET' && path === '/api/trunk') return send(res, 200, trunkView(parseSince(url.searchParams.get('since'))))
1514
1647
  if (req.method === 'GET' && path === '/api/leases') return send(res, 200, { leases: held(listCards()) })
1515
1648
  if (parts[1] === 'cards' && parts[2]) {
1516
1649
  const id = parts[2]
@@ -67,6 +67,26 @@ function isTracked(root, rel) {
67
67
  return typeof out === 'string' && out.trim().length > 0
68
68
  }
69
69
 
70
+ // The same question for a whole list, in one git process instead of one per
71
+ // file. The drawer polls every 3 seconds while it is open, and a terminal that
72
+ // touched six files spent six `git ls-files` there — seven subprocesses per
73
+ // poll, on the board's one event loop. `ls-files` lists the tracked paths among
74
+ // the ones it is given, so a path missing from the output is untracked; a path
75
+ // that names a directory is answered the way --error-unmatch answered it, by
76
+ // the tracked files underneath it.
77
+ function trackedAmong(root, rels) {
78
+ if (!rels.length) return new Set()
79
+ const out = git(root, ['ls-files', '-z', '--', ...rels])
80
+ if (typeof out !== 'string') return new Set()
81
+ const listed = out.split('\0').filter(Boolean)
82
+ const tracked = new Set(listed)
83
+ for (const rel of rels) {
84
+ if (tracked.has(rel)) continue
85
+ if (listed.some((p) => p.startsWith(rel + '/'))) tracked.add(rel)
86
+ }
87
+ return tracked
88
+ }
89
+
70
90
  export function sessionFiles(session) {
71
91
  const root = workRoot(session)
72
92
  if (!root) return []
@@ -78,9 +98,12 @@ export function sessionFiles(session) {
78
98
  const i = insideRoot(root, f)
79
99
  if (!i || files.has(i.rel)) continue
80
100
  const c = counts.get(i.rel) ?? null
81
- const state = c ? 'modified' : (isTracked(root, i.rel) ? 'committed' : 'new')
82
- files.set(i.rel, { path: i.rel, adds: c?.adds ?? null, dels: c?.dels ?? null, dirty: dirty.has(i.rel), state })
101
+ files.set(i.rel, { path: i.rel, adds: c?.adds ?? null, dels: c?.dels ?? null, dirty: dirty.has(i.rel), state: c ? 'modified' : null })
83
102
  }
103
+ // one git process for every file whose state is still open, rather than one each
104
+ const unknown = [...files.values()].filter((f) => f.state === null).map((f) => f.path)
105
+ const tracked = trackedAmong(root, unknown)
106
+ for (const rel of unknown) files.get(rel).state = tracked.has(rel) ? 'committed' : 'new'
84
107
  return [...files.values()].sort((a, b) => a.path.localeCompare(b.path))
85
108
  }
86
109