@ucsandman/legcli 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
 
package/src/sessions.mjs CHANGED
@@ -3,8 +3,10 @@
3
3
  // session.json the live record the board renders (atomic writes)
4
4
  // events.jsonl timeline (started, turn, warning, limit, handoff, ended)
5
5
  // control.json board → runner requests ({ handoff: true })
6
- // The runner (src/attach.mjs) is the only writer of session.json; hooks and
7
- // taps go through recordFromTap() so every write is one atomic replace.
6
+ // The runner (src/attach.mjs) owns session.json; the board's usage poller,
7
+ // Claude Code's hooks (src/taps/claude.mjs) and a board action patch fields on
8
+ // it too, all through updateSession(), so every write is one atomic replace
9
+ // under .session.lock and no writer loses another's field.
8
10
  import { existsSync, mkdirSync, readdirSync, readFileSync, appendFileSync, rmSync } from 'node:fs'
9
11
  import { join } from 'node:path'
10
12
  import { randomBytes } from 'node:crypto'
@@ -95,6 +97,12 @@ export function createSession({ id, agent, account = 'default', cwd, repo = null
95
97
  // processes cannot lose an accumulated field (files_touched, turns) to a
96
98
  // last-writer-wins race. Callers that only set fixed values pass a plain object.
97
99
  export function updateSession(id, patch, { event } = {}) {
100
+ // run.json's lock budget (250 tries, a 10 s steal), not withFileLock's
101
+ // default: past 1.2 s the default runs the read-modify-write UNLOCKED, and
102
+ // this file has the most writers in Leg — the terminal's poll, the claude
103
+ // hook, the board's usage poller, the board's order editor and every
104
+ // `leg` command. One of them losing its patch is the race the lock exists
105
+ // for, and the terminal is never the one that must not block.
98
106
  return withFileLock(join(sessionDir(id), '.session.lock'), () => {
99
107
  const cur = readSession(id)
100
108
  if (!cur) return null
@@ -113,7 +121,7 @@ export function updateSession(id, patch, { event } = {}) {
113
121
  writeJsonAtomic(join(sessionDir(id), 'session.json'), next)
114
122
  if (event) appendEvent(id, event)
115
123
  return next
116
- })
124
+ }, { retries: 250, staleMs: 10000 })
117
125
  }
118
126
 
119
127
  export function appendEvent(id, ev) {
@@ -152,6 +160,13 @@ export function takeControl(id) {
152
160
  return req
153
161
  })
154
162
  }
163
+ // The last thing a leg does: the terminal is gone, so a request nobody will
164
+ // read is removed. Under the same lock requestControl writes it with — a bare
165
+ // unlink from this side could delete a board request mid-write.
166
+ export function clearControl(id) {
167
+ const f = join(sessionDir(id), 'control.json')
168
+ return withFileLock(join(sessionDir(id), '.control.lock'), () => { rmSync(f, { force: true }) })
169
+ }
155
170
 
156
171
  export function removeSession(id) { rmSync(sessionDir(id), { recursive: true, force: true }) }
157
172
 
package/src/synthesis.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // synthesis — the agent-maintained judgment record included in the handoff bundle.
2
2
  // Spec: Leg Handoff Synthesis Layer — build spec v1
3
3
  // File: .leg/SYNTHESIS-<session-id>.md
4
- import { existsSync, readFileSync, statSync } from 'node:fs'
4
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
5
5
  import { join } from 'node:path'
6
6
 
7
7
  export const MAX_SYNTHESIS_BYTES = 4096
@@ -145,14 +145,33 @@ export function formatSynthesisSection(rawText, { sessionId = null } = {}) {
145
145
  return `## Synthesis\n\n${content.trim()}`
146
146
  }
147
147
 
148
+ // Every per-session file one checkout keeps, name → path, `.leg` winning over
149
+ // the legacy `.baton` for a name both hold (the same order synthesisFile
150
+ // probes in). One readdir per directory answers the question for every
151
+ // terminal in that checkout at once, where a path-by-path probe cost three
152
+ // existsSync per terminal — 172 of the 512 fs calls in one sessions view, for
153
+ // files that mostly are not there. `cache` is per view: a Map the caller owns,
154
+ // so nothing here outlives the request that asked.
155
+ export function sessionFileIndex(cwd, cache = null) {
156
+ if (cache && cache.has(cwd)) return cache.get(cwd)
157
+ const names = new Map()
158
+ for (const dir of ['.leg', '.baton']) {
159
+ let entries
160
+ try { entries = readdirSync(join(cwd, dir)) } catch { continue }
161
+ for (const n of entries) if (!names.has(n)) names.set(n, join(cwd, dir, n))
162
+ }
163
+ cache?.set(cwd, names)
164
+ return names
165
+ }
166
+
148
167
  // True if .leg/SYNTHESIS-<session-id>.md exists, is non-empty, and was modified within the last 3 checkpoints
149
- export function hasRecentSynthesis(session) {
168
+ export function hasRecentSynthesis(session, { index = null } = {}) {
150
169
  if (!session) return false
151
170
  const root = session.worktree?.path ?? session.repo ?? session.cwd ?? null
152
171
  if (!root || !session.session_id) return false
153
- const file = synthesisFile(root, session.session_id)
172
+ const file = sessionFileIndex(root, index).get(`SYNTHESIS-${session.session_id}.md`)
173
+ if (!file) return false
154
174
  try {
155
- if (!existsSync(file)) return false
156
175
  const st = statSync(file)
157
176
  if (!st.isFile() || st.size === 0) return false
158
177
  const checkpoints = session.checkpoints ?? []
@@ -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).