@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.
- package/CHANGELOG.md +114 -0
- package/README.md +51 -13
- package/bin/leg.mjs +104 -62
- package/docs/DECISIONS.md +8 -0
- package/docs/DEVIATIONS.md +34 -0
- package/docs/ERRORS.md +1 -1
- package/docs/ROADMAP-v2.md +12 -1
- package/docs/adapters.md +5 -2
- package/docs/cli-contracts.md +14 -6
- package/docs/concepts.md +52 -14
- package/docs/configuration.md +5 -3
- package/docs/faq.md +4 -3
- package/docs/review-2026-09-18.md +172 -0
- package/fixtures/verified.json +1 -1
- package/package.json +1 -1
- package/src/accounts.mjs +67 -7
- package/src/attach.mjs +184 -48
- package/src/board/board.js +1 -1
- package/src/board/floor.js +1 -1
- package/src/bundle.mjs +33 -11
- package/src/digest.mjs +197 -0
- package/src/git.mjs +97 -0
- package/src/handoff.mjs +20 -2
- package/src/history/common.mjs +3 -1
- package/src/history/providers/claude.mjs +5 -1
- package/src/history/worktrees.mjs +105 -25
- package/src/hook.mjs +5 -4
- package/src/launcher.mjs +1 -1
- package/src/limits.mjs +19 -1
- package/src/scheduler-status.mjs +22 -0
- package/src/scheduler.mjs +3 -14
- package/src/server.mjs +171 -38
- package/src/session-detail.mjs +25 -2
- package/src/sessions.mjs +18 -3
- package/src/synthesis.mjs +23 -4
- package/src/taps/claude-usage.mjs +5 -4
- package/src/taps/claude.mjs +34 -6
- package/src/usage.mjs +27 -1
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
|
657
|
-
//
|
|
658
|
-
//
|
|
659
|
-
|
|
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(
|
|
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 =
|
|
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) {
|
|
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
|
|
823
|
-
// next watcher hint is measured against the
|
|
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
|
|
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 (
|
|
863
|
-
lastFingerprint = sig
|
|
972
|
+
if (!changed && !forced) return
|
|
864
973
|
pushSessions()
|
|
865
974
|
}
|
|
866
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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]
|
package/src/session-detail.mjs
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
7
|
-
//
|
|
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 =
|
|
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
|
|
4
|
-
// line and
|
|
5
|
-
// or a project settings file (verified 2026-09-11
|
|
6
|
-
// both levels; hooks from the same
|
|
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).
|