@ucsandman/legcli 0.11.0 → 0.13.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/README.md +95 -65
  3. package/bin/leg.mjs +123 -14
  4. package/docs/DECISIONS.md +18 -0
  5. package/docs/DEMO.md +20 -14
  6. package/docs/DEVIATIONS.md +1 -0
  7. package/docs/ERRORS.md +68 -0
  8. package/docs/ROADMAP-v2.md +50 -5
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/board-guide.md +529 -96
  11. package/docs/cli-contracts.md +241 -5
  12. package/docs/concepts.md +167 -19
  13. package/docs/configuration.md +65 -1
  14. package/docs/faq.md +21 -5
  15. package/docs/getting-started.md +15 -11
  16. package/docs/redesign-2026-09-17.md +477 -0
  17. package/docs/screenshots/background-1280.png +0 -0
  18. package/docs/screenshots/board-400px.png +0 -0
  19. package/docs/screenshots/board-details-open.png +0 -0
  20. package/docs/screenshots/board-drawer.png +0 -0
  21. package/docs/screenshots/board-handoff.png +0 -0
  22. package/docs/screenshots/board-running.png +0 -0
  23. package/docs/screenshots/capacity-drawer-1280.png +0 -0
  24. package/docs/screenshots/floor.png +0 -0
  25. package/docs/screenshots/new-card-dialog.png +0 -0
  26. package/docs/screenshots/settings-ladder-1280.png +0 -0
  27. package/docs/screenshots/terminals-1280.png +0 -0
  28. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  29. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  30. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  31. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  32. package/fixtures/live/claude/resume-model-probe.json +20 -0
  33. package/fixtures/live/claude/usage-oauth.json +87 -0
  34. package/fixtures/verified.json +1 -1
  35. package/package.json +3 -2
  36. package/scripts/board-jump-probe.mjs +335 -0
  37. package/scripts/seed-fake-cards.mjs +59 -6
  38. package/scripts/seed-wes-board.mjs +81 -12
  39. package/src/accounts.mjs +6 -1
  40. package/src/attach.mjs +378 -93
  41. package/src/audit.mjs +1 -1
  42. package/src/board/board.css +203 -11
  43. package/src/board/board.js +664 -200
  44. package/src/board/entry.js +343 -0
  45. package/src/board/floor.html +51 -39
  46. package/src/board/floor.js +585 -73
  47. package/src/board/index.html +122 -45
  48. package/src/board/sessions.js +1569 -141
  49. package/src/board/strip.js +163 -0
  50. package/src/buckets.mjs +101 -0
  51. package/src/cards.mjs +9 -1
  52. package/src/chain.mjs +13 -0
  53. package/src/hook.mjs +7 -1
  54. package/src/ledger.mjs +10 -2
  55. package/src/models.mjs +265 -0
  56. package/src/orchestrator.mjs +13 -4
  57. package/src/preferences.mjs +278 -5
  58. package/src/scheduler.mjs +24 -1
  59. package/src/server.mjs +625 -78
  60. package/src/sessions.mjs +17 -1
  61. package/src/taps/claude-usage.mjs +107 -3
  62. package/src/taps/claude.mjs +144 -5
  63. package/src/taps/codex.mjs +23 -3
  64. package/src/usage-poll.mjs +260 -0
  65. package/src/usage.mjs +439 -12
package/src/server.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  import http from 'node:http'
8
8
  import https from 'node:https'
9
9
  import { spawnSync, execFile } from 'node:child_process'
10
- import { existsSync, readFileSync, readdirSync, statSync, rmSync, watch as fsWatch, mkdirSync, openSync, fstatSync, readSync, closeSync } from 'node:fs'
10
+ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, rmSync, watch as fsWatch, mkdirSync, openSync, fstatSync, readSync, closeSync } from 'node:fs'
11
11
  import { join, dirname, resolve, extname, sep } from 'node:path'
12
12
  import { fileURLToPath } from 'node:url'
13
13
  import { checkBind, authorize, remoteAddress, presentedToken, isLoopback, isLoopbackRequest, tokenMatches } from './auth.mjs'
@@ -15,26 +15,31 @@ import { readShare, isOn as shareIsOn, sharePath, identify, personNamed, mayUseC
15
15
  import { auditTrail, ACTOR_KINDS } from './audit.mjs'
16
16
  import { createLimiter } from './ratelimit.mjs'
17
17
  import { realPath, canonPath } from './fsx.mjs'
18
- import { listCards, readCard, readRuns, readEvents, cardDir, home } from './store.mjs'
18
+ import { listCards, readCard, readRuns, readEvents, cardDir, home, ledgerAppend, ledgerUpdate } from './store.mjs'
19
+ import { saveSessionBundle } from './bundle.mjs'
20
+ import { transcriptTail as claudeTranscriptTail } from './taps/claude.mjs'
19
21
  import { humanAction } from './orchestrator.mjs'
20
22
  import { createCard, CardInputError } from './cards.mjs'
21
- import { IllegalTransition, availableActions } from './chain.mjs'
23
+ import { IllegalTransition, availableActions, NON_TERMINAL, TERMINAL } from './chain.mjs'
22
24
  import { held } from './leases.mjs'
23
25
  import { PRESETS } from './presets.mjs'
24
26
  import { names as adapterNames, get as getAdapter, isFake } from './adapters/index.mjs'
25
27
  import { createScheduler, schedulerStatus, MAX_CONCURRENT } from './scheduler.mjs'
26
- import { remove as removeWorktree, worktreeDirty } from './worktree.mjs'
28
+ import { ensure as ensureWorktree, remove as removeWorktree, worktreeDirty } from './worktree.mjs'
27
29
  import { scrub } from './runner.mjs'
28
30
  import { resolveChb } from './handoff.mjs'
29
- import { listSessions, readSession, readEvents as readSessionEvents, requestControl, removeSession, overlaps, isActive, sessionsRoot, reapLost, readLand, readLandings, readRequests, writeRequests, appendEvent as appendSessionEvent, updateSession, HANDOFF_ORDER_CAPABILITY } from './sessions.mjs'
31
+ import { listSessions, readSession, readEvents as readSessionEvents, requestControl, removeSession, overlaps, isActive, sessionsRoot, reapLost, readLand, readLandings, readRequests, writeRequests, appendEvent as appendSessionEvent, updateSession, HANDOFF_ORDER_CAPABILITY, SUPERVISED_AGENTS } from './sessions.mjs'
30
32
  import { sessionDetail, sessionDiff, DiffInputError } from './session-detail.mjs'
31
33
  import { hasRecentSynthesis } from './synthesis.mjs'
32
34
  import { refreshPointers } from './resume.mjs'
33
35
  import { landSession, landBlocker, landingNow, pruneSessionWorktree, canLand, prepareLanding, applyLandFix } from './land.mjs'
34
- import { readUsage, recordUsage, usageIsStale, candidates, isAvailable, fmtReset } from './usage.mjs'
35
- import { readAccounts, envFor, LAYOUT } from './accounts.mjs'
36
- import { readCodexUsage } from './taps/codex.mjs'
37
- import { readPreferences, writePreferences, normalizeHandoffOrder, requireHandoffOrder } from './preferences.mjs'
36
+ import { readUsage, usageIsStale, candidates, isAvailable, fmtReset, binding, evaluateLadder, rungLabel, wallActive } from './usage.mjs'
37
+ import { readAccounts } from './accounts.mjs'
38
+ import { createUsagePollers, USAGE_AGENTS } from './usage-poll.mjs'
39
+ import { readCodexUsage, transcriptTail as codexTranscriptTail } from './taps/codex.mjs'
40
+ import { readPreferences, writePreferences, normalizeHandoffOrder, requireHandoffOrder, ladderFor, requireHandoffLadder, requireClimbBack, requireReserve, orderFromLadder } from './preferences.mjs'
41
+ import { isDownshift } from './buckets.mjs'
42
+ import { listModels } from './models.mjs'
38
43
  import { listHistory, findRecord, recordDetail, refreshIndex, readIndex, providerSupport, HistoryInputError, PROVIDER_NAMES } from './history/index.mjs'
39
44
  import { listWorktrees } from './history/worktrees.mjs'
40
45
 
@@ -75,16 +80,192 @@ export function columnOf(card) {
75
80
  return card.station
76
81
  }
77
82
 
78
- export function summarize(card) {
83
+ // ---- the work stat on a live card's row (redesign C.3) --------------------
84
+ // `4 files, +212 -18`. Parsed from git's own one-line summary, never counted
85
+ // here: a part the line does not carry is left off the object, so the row can
86
+ // print only what was measured and never estimate the rest.
87
+ export function parseShortstat(line) {
88
+ const text = String(line ?? '')
89
+ const files = /(\d+)\s+files?\s+changed/.exec(text)
90
+ const ins = /(\d+)\s+insertions?\(\+\)/.exec(text)
91
+ const del = /(\d+)\s+deletions?\(-\)/.exec(text)
92
+ if (!files && !ins && !del) return null
93
+ const out = {}
94
+ if (files) out.files = parseInt(files[1], 10)
95
+ if (ins) out.insertions = parseInt(ins[1], 10)
96
+ if (del) out.deletions = parseInt(del[1], 10)
97
+ return out
98
+ }
99
+
100
+ function measureWork(card) {
101
+ if (!card.worktree || !existsSync(card.worktree)) return null
102
+ const base = card.trunk || 'main'
103
+ const r = spawnSync('git', ['diff', '--shortstat', `${base}..HEAD`], { cwd: card.worktree, windowsHide: true, encoding: 'utf8', timeout: 8000, env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
104
+ if (r.status !== 0) return null
105
+ return parseShortstat(r.stdout)
106
+ }
107
+
108
+ // One `git diff` per live card per push would put a subprocess per card inside
109
+ // the board's one event loop, which is the stall canLandFor already exists to
110
+ // avoid. Keyed on the card's own revision, with the expiries spread across the
111
+ // window so twenty cards never re-read together.
112
+ const WORK_TTL = 15000
113
+ const workCache = new Map()
114
+ function workFor(card) {
115
+ const key = `${card.card_id}|${card.updated_at ?? ''}`
116
+ const hit = workCache.get(key)
117
+ if (hit && Date.now() < hit.until) return hit.data
118
+ let data = null
119
+ try { data = measureWork(card) } catch { data = null }
120
+ if (workCache.size > 300) workCache.clear()
121
+ workCache.set(key, { until: Date.now() + WORK_TTL / 2 + Math.random() * WORK_TTL, data })
122
+ return data
123
+ }
124
+
125
+ // The test and land verdicts a live card has already earned. Both are read from
126
+ // the card's own ledger, because that is where each station records its result:
127
+ // a test station writes no run.json (src/stations/test.mjs), and a landing is a
128
+ // `landed` / `bounced` / `failed` event (src/chain.mjs). Last one wins; a card
129
+ // that has run neither carries neither key.
130
+ export function cardOutcomes(events) {
131
+ const out = {}
132
+ for (const ev of events ?? []) {
133
+ const summary = String(ev.summary ?? '')
134
+ const m = /^test (green|red)\b/.exec(summary)
135
+ if (m) out.tests = { state: m[1], at: ev.ts }
136
+ if (ev.type === 'landed') {
137
+ const sha = /\b([0-9a-f]{7,40})\b/.exec(summary)
138
+ out.land = { state: 'landed', reason: null, sha: sha ? sha[1] : null }
139
+ } else if (ev.type === 'bounced' && /^land bounced/.test(summary)) {
140
+ out.land = { state: 'bounced', reason: ev.body ? String(ev.body).slice(0, 200) : summary, sha: null }
141
+ } else if (ev.type === 'failed' && /\bland\b/.test(summary)) {
142
+ out.land = { state: 'failed', reason: ev.body ? String(ev.body).slice(0, 200) : summary, sha: null }
143
+ }
144
+ }
145
+ return out
146
+ }
147
+
148
+ // The last messages a hand-off bundle quotes, from the agent's own transcript.
149
+ // An agent Leg cannot read a transcript for contributes none, and the bundle is
150
+ // written from the record alone rather than with invented text.
151
+ function sessionMessages(s) {
152
+ try {
153
+ if (s.agent === 'claude') return claudeTranscriptTail(s.transcript_path)
154
+ if (s.agent === 'codex') return codexTranscriptTail(s.transcript_path)
155
+ } catch { /* an unreadable transcript is not a reason to refuse the bundle */ }
156
+ return []
157
+ }
158
+
159
+ // This terminal's ladder from the rung it is standing on, downward. A card made
160
+ // from a terminal starts where the terminal is, not at the top: the rungs above
161
+ // it are the ones this work has already used up (redesign C.4).
162
+ export function ladderFromCurrentRung(session) {
163
+ const ladder = ladderFor(session).filter((r) => r && r.agent)
164
+ const exact = ladder.findIndex((r) => r.agent === session.agent && (r.account ?? 'default') === (session.account ?? 'default') && (r.model ?? null) === (session.model ?? null))
165
+ const byAgent = exact >= 0 ? exact : ladder.findIndex((r) => r.agent === session.agent)
166
+ return byAgent >= 0 ? ladder.slice(byAgent) : ladder
167
+ }
168
+
169
+ // ---- carrying a checkout's uncommitted work into another one --------------
170
+ // "End, and keep going as a card" on a lone terminal (the ordinary case: a
171
+ // terminal only cuts a worktree of its own when a second live session shares
172
+ // the checkout, src/attach.mjs isolate()) has to move the work the human was
173
+ // looking at, not just the branch it sits on. Captured in two halves, because
174
+ // git keeps them apart: a patch of everything it tracks, staged and unstaged
175
+ // (`git diff HEAD --binary`), and the bytes of the files it does not
176
+ // (`git ls-files --others`). The second half is the one `git stash create`
177
+ // cannot carry, and it is where a terminal's newest file always is.
178
+ const CARRY_SKIP = ['.git', '.leg-worktrees', '.baton-worktrees', '.context-handoffs', '.leg', '.baton', 'node_modules']
179
+ const CARRY_MAX_BYTES = 8 * 1024 * 1024
180
+ function gitIn(dir, args, opts = {}) {
181
+ const r = spawnSync('git', ['-C', dir, ...args], { windowsHide: true, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 30000, env: { ...process.env, MSYS_NO_PATHCONV: '1' }, ...opts })
182
+ if (r.error) throw r.error
183
+ if (r.status !== 0) throw new Error(`git ${args[0]} failed: ${String(r.stderr ?? '').trim().slice(0, 200)}`)
184
+ return r.stdout
185
+ }
186
+
187
+ export function captureUncommitted(dir) {
188
+ const status = gitIn(dir, ['status', '--porcelain'])
189
+ if (!status.trim()) return null
190
+ // --binary keeps a changed image or lockfile intact; the output is ASCII
191
+ const patch = gitIn(dir, ['diff', 'HEAD', '--binary'])
192
+ const files = []
193
+ for (const rel of gitIn(dir, ['ls-files', '--others', '--exclude-standard']).split('\n').map((s) => s.trim()).filter(Boolean)) {
194
+ if (CARRY_SKIP.includes(rel.split('/')[0])) continue
195
+ const from = join(dir, rel)
196
+ let st
197
+ try { st = statSync(from) } catch { continue }
198
+ if (!st.isFile()) continue
199
+ // refuse loudly rather than carry half the work: the caller turns this
200
+ // into a 409 and the terminal is left alone
201
+ if (st.size > CARRY_MAX_BYTES) throw new Error(`${rel} is ${Math.round(st.size / 1048576)}MB, too large to carry into the card's checkout`)
202
+ files.push({ rel, data: readFileSync(from) })
203
+ }
204
+ if (!patch.trim() && !files.length) return null
205
+ const names = new Set(status.trim().split('\n').map((l) => l.slice(3).trim()).filter(Boolean))
206
+ for (const f of files) names.add(f.rel)
207
+ return { patch, files, names: [...names] }
208
+ }
209
+
210
+ export function carryUncommitted(dir, carried) {
211
+ if (!carried) return 0
212
+ if (carried.patch.trim()) {
213
+ const r = spawnSync('git', ['-C', dir, 'apply', '--binary', '--whitespace=nowarn', '-'], { input: carried.patch, windowsHide: true, encoding: 'utf8', timeout: 30000, env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
214
+ if (r.status !== 0) throw new Error(`the tracked changes did not apply: ${String(r.stderr ?? '').trim().slice(0, 200)}`)
215
+ }
216
+ for (const f of carried.files) {
217
+ const to = join(dir, f.rel)
218
+ mkdirSync(dirname(to), { recursive: true })
219
+ writeFileSync(to, f.data)
220
+ }
221
+ return (carried.names ?? []).length
222
+ }
223
+
224
+ // The cards waiting on a human, for the terminals verdict to read (redesign
225
+ // C.5): `card 3e1c has waited on you for 12 minutes.` needs the id, the title,
226
+ // the station and the moment it stopped, so a bare count cannot write the
227
+ // sentence the spec asks for. The oldest one is `first`, because that is the
228
+ // one the sentence names.
229
+ // Cached for a beat: listCards() reads one file per card, and this is computed
230
+ // on every sessions push.
231
+ const CARDS_WAITING_TTL = 5000
232
+ const WAITING_STATUSES = ['needs_approval', 'waiting_human']
233
+ let cardsWaitingCache = { at: 0, data: null }
234
+ function cardsWaiting() {
235
+ if (cardsWaitingCache.data && Date.now() - cardsWaitingCache.at < CARDS_WAITING_TTL) return cardsWaitingCache.data
236
+ let waiting = []
237
+ try { waiting = listCards().filter((c) => WAITING_STATUSES.includes(c.status)) } catch { waiting = [] }
238
+ // `updated_at` is when the card reached this state, which is what "has
239
+ // waited on you for 12 minutes" measures from
240
+ waiting.sort((a, b) => (String(a.updated_at ?? '') < String(b.updated_at ?? '') ? -1 : 1))
241
+ const first = waiting[0] ?? null
242
+ const data = {
243
+ count: waiting.length,
244
+ first: first ? { id: first.card_id, title: first.title ?? null, station: first.station ?? null, since: first.updated_at ?? null } : null,
245
+ }
246
+ cardsWaitingCache = { at: Date.now(), data }
247
+ return data
248
+ }
249
+
250
+ // `events` is the card's ledger, already read by the caller. readEvents() does
251
+ // a readdir, a full readFileSync, a JSON.parse per line and a sort every call,
252
+ // and this function needed it three times per push (the last event, the legs
253
+ // that started at a finished station, and the test/land outcomes of a live
254
+ // card) on the one event loop the board serves every request from. Read once,
255
+ // reused; a caller that has no events passes none and pays for one read.
256
+ export function summarize(card, events = null) {
79
257
  const st = (card.pipeline ?? []).find((s) => s.name === card.station) ?? null
80
- const last = lastEventOf(card.card_id)
258
+ // `cards.map(summarize)` would hand this the array index, so the type is
259
+ // checked rather than the emptiness
260
+ const evs = Array.isArray(events) ? events : readEvents(card.card_id)
261
+ const last = evs.length ? evs[evs.length - 1] : null
81
262
  const runs = readRuns(card.card_id)
82
263
  const activeRun = runs.find((r) => ['launching', 'running'].includes(r.status)) ?? null
83
264
  // Once a station is over (done/failed) card.leg is reset, so the rail is
84
265
  // rebuilt from the legs that actually started at this station.
85
266
  const terminal = ['done', 'failed', 'killed'].includes(card.status)
86
267
  const startedLegs = terminal && st?.kind === 'agent'
87
- ? readEvents(card.card_id).filter((ev) => ev.type === 'leg_started' && ev.station === card.station).map((ev) => ev.leg)
268
+ ? evs.filter((ev) => ev.type === 'leg_started' && ev.station === card.station).map((ev) => ev.leg)
88
269
  : []
89
270
  const lastLeg = startedLegs.length ? Math.max(...startedLegs) : card.leg
90
271
  // card.leg is reset when the station ends, so a finished card read its adapter
@@ -101,12 +282,23 @@ export function summarize(card) {
101
282
  if (i > card.leg) return 'pending'
102
283
  return ['running', 'handing_off'].includes(card.status) ? 'active' : 'pending'
103
284
  }
285
+ // What a live card is carrying right now: the diff it has built, the last
286
+ // test verdict, the last land verdict, and the rung it is on. Measured only
287
+ // for a card that is still going: a finished one is a single line in the
288
+ // ledger, and a git subprocess for each of ten of those buys nothing (C.1).
289
+ const live = NON_TERMINAL.includes(card.status)
290
+ const work = live ? workFor(card) : null
291
+ const outcomes = live ? cardOutcomes(evs) : {}
104
292
  return {
105
293
  ...card,
106
294
  column: columnOf(card),
107
295
  station_kind: st?.kind ?? null,
108
296
  active_adapter: entry?.adapter ?? null,
109
297
  active_mode: entry?.mode ?? null,
298
+ ...(work ? { work } : {}),
299
+ ...(outcomes.tests ? { tests: outcomes.tests } : {}),
300
+ ...(outcomes.land ? { land: outcomes.land } : {}),
301
+ ...(live && entry ? { agent_model: { agent: entry.adapter ?? null, model: entry.model ?? null } } : {}),
110
302
  chain_view: st?.kind === 'agent' ? st.chain.map((e, i) => ({
111
303
  adapter: e.adapter, mode: e.mode ?? null, approve: Boolean(e.approve),
112
304
  // a leg before the last one ended in a handoff (only the last leg can complete a station)
@@ -293,8 +485,18 @@ function trunkFor(repo) {
293
485
  const hit = trunkCache.get(repo)
294
486
  if (hit && Date.now() - hit.at < 15000) return hit.data
295
487
  const g = (args) => { const r = spawnSync('git', args, { cwd: repo, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } }); return r.status === 0 ? r.stdout.trim() : null }
488
+ // The branch this repo actually calls its trunk: origin's default if there is
489
+ // one, then the usual names, then whatever this checkout is on. A repo whose
490
+ // default is `develop` used to read as "main" here, and the board's one-line
491
+ // entry then posted a card against a branch that does not exist.
296
492
  let branch = null
297
- for (const b of ['main', 'master', 'trunk']) if (g(['rev-parse', '--verify', '--quiet', b]) !== null) { branch = b; break }
493
+ const originHead = g(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
494
+ if (originHead) {
495
+ const name = originHead.replace(/^origin\//, '')
496
+ if (name && g(['rev-parse', '--verify', '--quiet', `refs/heads/${name}`]) !== null) branch = name
497
+ }
498
+ if (!branch) for (const b of ['main', 'master', 'trunk']) if (g(['rev-parse', '--verify', '--quiet', b]) !== null) { branch = b; break }
499
+ if (!branch) branch = g(['symbolic-ref', '--short', 'HEAD']) || null
298
500
  const log = branch ? g(['log', '--format=%h%x1f%s%x1f%cr%x1f%an', '-6', branch]) : null
299
501
  const data = {
300
502
  repo, repo_name: repo.split(/[\\/]/).filter(Boolean).pop(), branch,
@@ -319,14 +521,28 @@ function withLandings(t, landings) {
319
521
  // What another human sees of a terminal that is not theirs: that it is there,
320
522
  // nothing it has said, read or written. No task, no paths, no file names, no
321
523
  // limit text, no bundle, no events.
524
+ // No usage either. `limits` is the five-hour and seven-day percentage of this
525
+ // machine's login, written onto the record by the poller and by every claude
526
+ // status line, and `warning` carries the same percentage with the clock it
527
+ // resets on. Both are the number the capacity drawer, the accounts payload and
528
+ // guestReason all withhold, so neither may ride out on a row instead
529
+ // (.design/BOARD-DESIGN.md 6.13). The band survives, the figure does not.
322
530
  function redactSession(s) {
323
531
  return {
324
532
  session_id: s.session_id, agent: s.agent, account: s.account, status: s.status, active: s.active,
325
533
  started_at: s.started_at, elapsed_ms: s.elapsed_ms, turns: s.turns, repo_name: s.repo_name, branch: s.branch,
326
- owner: s.owner ?? null, limits: s.limits ?? null, lineage: s.lineage ?? null,
327
- warning: s.warning ? { window: s.warning.window, pct: s.warning.pct, resets_at: s.warning.resets_at } : null,
328
- limit: s.limit ? { reason: s.limit.reason, resets_at: s.limit.resets_at ?? null } : null,
329
- waiting: s.waiting ?? null,
534
+ owner: s.owner ?? null, lineage: s.lineage ?? null,
535
+ warning: s.warning ? { window: s.warning.window } : null,
536
+ limit: s.limit ? { reason: s.limit.reason } : null,
537
+ // `ahead` is owner-only for the same reason `files` is: how far someone
538
+ // else's branch has moved is a fact about their work, and the register
539
+ // prints it beside the dirty count that is already withheld here.
540
+ // `waiting` and `model` are owner-only, and a guest keeps both on their own
541
+ // terminal (that row is not redacted at all). On someone else's row they are
542
+ // dropped: `waiting` carries either the verbatim question an agent asked —
543
+ // the prompt text this function exists to hide — or a reset time, which is
544
+ // this machine's usage data (.design/BOARD-DESIGN.md 6.13); and `model` is
545
+ // which of this machine's model buckets someone else's work is spending.
330
546
  worktree: s.worktree ? { branch: s.worktree.branch, base: s.worktree.base } : null,
331
547
  // the branch is already on the worktree chip: naming it again costs nothing
332
548
  // and is what the board's land line reads
@@ -338,6 +554,48 @@ function redactSession(s) {
338
554
  }
339
555
  }
340
556
 
557
+ // A guest's OWN terminal is not redacted: it is their work. The login it runs
558
+ // on is still this machine's, though, and every figure on the record that was
559
+ // measured from the owner's accounts is the same secret `capacity`, `buckets`
560
+ // and the accounts payload already withhold: the two window percentages
561
+ // (`limits`), the near-wall warning, the reset clock on a limit or an all-out
562
+ // wait, and the name of the reading source. The row keeps every fact about the
563
+ // work and loses every figure about the login (.design/BOARD-DESIGN.md 6.13).
564
+ function scrubOwnerUsage(s) {
565
+ const out = { ...s }
566
+ delete out.limits
567
+ delete out.all_out
568
+ delete out.usage_source
569
+ delete out.usage_error
570
+ // the band survives, the figure and the clock do not: their own row may say
571
+ // it is near a wall, the same way a redacted row does
572
+ if (out.warning) out.warning = { window: out.warning.window }
573
+ if (out.limit) out.limit = { ...out.limit, resets_at: null }
574
+ // a 'reset' wait is a reset time with a sentence around it; the guest still
575
+ // learns that their terminal is waiting for one
576
+ if (out.waiting && out.waiting.type === 'reset') out.waiting = { type: 'reset', since: out.waiting.since ?? null }
577
+ return out
578
+ }
579
+
580
+ // The usage poller appends `claude usage unavailable since 9:03 AM: <reason>`
581
+ // to every active session of a login, a guest's own terminal included
582
+ // (src/usage-poll.mjs). That line is the owner's reading — the reason their
583
+ // endpoint is refusing and the clock it started — so it leaves with the rest of
584
+ // the figures scrubOwnerUsage withholds. The recovery line says nothing about
585
+ // the login and stays.
586
+ const isUsageFailureEvent = (e) => e?.type === 'status' && /^\w+ usage unavailable since /.test(String(e?.summary ?? ''))
587
+
588
+ // A guest owns their own terminal, so its picker rows are theirs to read, but
589
+ // a rung's reason can quote this machine's usage ("at 63%, not below 80%",
590
+ // "past your 10% reserve"), and a percentage of this machine's login belongs to
591
+ // nobody else (.design/BOARD-DESIGN.md 6.13). Only the reasons that say nothing
592
+ // about how much is left survive the crossing.
593
+ const GUEST_REASONS = new Set(['not installed on this machine', 'at its usage limit', 'shares the window that is out, buys nothing', 'refused for this hand-off'])
594
+ function guestReason(reason) {
595
+ if (!reason) return null
596
+ return GUEST_REASONS.has(reason) ? reason : 'not available right now'
597
+ }
598
+
341
599
  function visibleSessionFile(file) {
342
600
  const value = String(file ?? '')
343
601
  return !value.includes('*** Begin Patch') && !value.includes('*** End Patch')
@@ -353,13 +611,24 @@ export function sessionsView({ viewer = null, share = null } = {}) {
353
611
  const list = reapLost(listSessions())
354
612
  const ov = overlaps(list)
355
613
  const configuredAccounts = readAccounts()
614
+ // the spending rules the picker has to print, read once for the whole view
615
+ const prefs = readPreferences()
356
616
  const sessions = list.map((s) => {
357
617
  const land = readLand(s.session_id)
358
618
  const handoffOrder = normalizeHandoffOrder(s.handoff_order)
359
- const chain = candidates({ agent: s.agent, account: s.account, accounts: configuredAccounts, order: handoffOrder })
619
+ // this terminal's own ladder, else the long-hand form of its order
620
+ const handoffLadder = ladderFor(s)
621
+ const from = { agent: s.agent, account: s.account, model: s.model ?? null }
622
+ const chain = candidates({ agent: s.agent, account: s.account, model: s.model ?? null, accounts: configuredAccounts, order: handoffOrder, ladder: handoffLadder })
360
623
  const preferredNext = chain[0] ?? null
361
624
  const availabilityKnown = Boolean(s.installed)
362
- const eligibleNext = availabilityKnown ? (chain.find((next) => s.installed[next.agent] !== false && isAvailable(readUsage(next.agent, next.account))) ?? null) : null
625
+ // one pass, the same one the chooser makes, so a greyed row in the picker
626
+ // and the rung an automatic hand-off would take can never disagree. The
627
+ // picker is a human pressing a button, so the reserve is a note here, not
628
+ // 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 })
630
+ const open = availabilityKnown ? rungs.find((r) => r.ok) : null
631
+ const eligibleNext = open ? { agent: open.agent, account: open.account, ...(open.model ? { model: open.model } : {}) } : null
363
632
  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 }] }
364
633
  return {
365
634
  ...s,
@@ -370,19 +639,38 @@ export function sessionsView({ viewer = null, share = null } = {}) {
370
639
  // every destination this terminal could be handed to, each with the
371
640
  // reason it cannot be picked right now. The board's picker renders this
372
641
  // list directly, so a greyed option always carries its own explanation.
373
- handoff_targets: chain.map((c) => {
374
- const u = readUsage(c.agent, c.account)
375
- const missing = availabilityKnown && s.installed[c.agent] === false
376
- const walled = !isAvailable(u)
377
- return {
378
- agent: c.agent,
379
- account: c.account,
380
- available: !missing && !walled,
381
- reason: missing ? 'not installed on this machine' : walled ? 'at its usage limit' : null,
382
- resets_at: walled && !guest ? (u.limited_until ?? null) : null,
383
- }
384
- }),
642
+ // one row per RUNG now: `claude/opus` and `claude/sonnet` are separate
643
+ // destinations, each with what it costs, whether it keeps the
644
+ // conversation, and the reason it cannot (or should not) be picked.
645
+ handoff_targets: rungs.map((r) => ({
646
+ agent: r.agent,
647
+ account: r.account,
648
+ model: r.model ?? null,
649
+ available: r.ok,
650
+ // A row that CAN be picked has nothing to explain: the reserve and the
651
+ // `below:N` rules come back as a note on an ok row (usage.mjs), and
652
+ // generalising that note reads as a refusal beside a button that works.
653
+ // A row that is blocked keeps a reason a guest may read.
654
+ reason: guest ? (r.ok ? null : guestReason(r.reason)) : r.reason,
655
+ 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),
660
+ // the cost word is not static: `credits` on a claude/fable rung means
661
+ // this machine's login has usage credits switched on (preferences.mjs
662
+ // rungCost reads extra_usage.enabled), which is a fact about the
663
+ // owner's account that the accounts payload drops on purpose. A guest
664
+ // gets the row and not the word.
665
+ ...(guest ? {} : { cost: r.cost }),
666
+ })),
385
667
  handoff_availability_known: availabilityKnown,
668
+ handoff_ladder: handoffLadder,
669
+ // the bucket that will actually stop this terminal, computed per request
670
+ // and never persisted: it depends on the model the row is running, and
671
+ // the record only knows the login. A guest never gets it: it is a
672
+ // percentage of this machine's usage (.design/BOARD-DESIGN.md 6.13).
673
+ ...(guest ? {} : { capacity: binding(readUsage(s.agent, s.account), s.model ?? null) }),
386
674
  can_edit_handoff_order: s.runtime_capabilities?.includes(HANDOFF_ORDER_CAPABILITY) ?? false,
387
675
  active: isActive(s),
388
676
  has_synthesis: hasRecentSynthesis(s),
@@ -401,7 +689,15 @@ export function sessionsView({ viewer = null, share = null } = {}) {
401
689
  const accounts = []
402
690
  for (const agent of Object.keys(configuredAccounts)) for (const account of configuredAccounts[agent]) {
403
691
  const u = readUsage(agent, account)
404
- accounts.push({ agent, account, five_hour: u.five_hour, seven_day: u.seven_day, limited_until: u.limited_until, limited_reason: u.limited_reason, source: u.source, observed_at: u.observed_at, updated_at: u.updated_at, stale: usageIsStale(u), live: sessions.filter((s) => s.active && s.agent === agent && s.account === account).length })
692
+ // buckets, walls, extra_usage and facts are owner-only for the same reason
693
+ // the percentages are: they say how much of this machine's login is gone.
694
+ // The guest branch at the bottom of this function drops the slot to
695
+ // {agent, account, live, shared}, so nothing here reaches them.
696
+ // `error`/`error_since` are the READING's health, not the login's: they say
697
+ // the board could not ask, which is why a percentage is old. Owner-only for
698
+ // the same reason the percentages are, and dropped by the guest branch below
699
+ // with the rest of the slot.
700
+ accounts.push({ agent, account, five_hour: u.five_hour, seven_day: u.seven_day, limited_until: u.limited_until, limited_reason: u.limited_reason, source: u.source, observed_at: u.observed_at, updated_at: u.updated_at, stale: usageIsStale(u), live: sessions.filter((s) => s.active && s.agent === agent && s.account === account).length, buckets: u.buckets ?? [], walls: u.walls ?? {}, extra_usage: u.extra_usage ?? null, facts: u.facts ?? null, error: u.error ?? null, error_since: u.error_since ?? null })
405
701
  }
406
702
  const repos = new Map()
407
703
  for (const s of sessions) if (s.repo && (s.active || s.worktree) && !repos.has(canonPath(s.repo))) repos.set(canonPath(s.repo), s.repo)
@@ -410,7 +706,11 @@ export function sessionsView({ viewer = null, share = null } = {}) {
410
706
  const landingsFor = (key) => landings.filter((l) => { if (!canon.has(l.repo)) canon.set(l.repo, canonPath(l.repo)); return canon.get(l.repo) === key })
411
707
  const trunk = [...repos].map(([key, r]) => { try { return withLandings(trunkFor(r), landingsFor(key)) } catch { return { repo: r, commits: [] } } })
412
708
  const mine = (s) => !shared || !viewer || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
413
- const shown = sessions.map((s) => (mine(s) ? { ...s, requests: readRequests(s.session_id).filter((r) => r.state === 'pending') } : redactSession(s)))
709
+ const shown = sessions.map((s) => {
710
+ if (!mine(s)) return redactSession(s)
711
+ const row = { ...s, requests: readRequests(s.session_id).filter((r) => r.state === 'pending') }
712
+ return guest ? scrubOwnerUsage(row) : row
713
+ })
414
714
  return {
415
715
  sessions: shown,
416
716
  // a guest sees which accounts exist and which are busy, never how much of
@@ -421,6 +721,12 @@ export function sessionsView({ viewer = null, share = null } = {}) {
421
721
  // a guest sees what landed, not where the repo lives on this machine
422
722
  trunk: guest ? trunk.map((t) => ({ repo_name: t.repo_name, branch: t.branch, commits: t.commits ?? [] })) : trunk,
423
723
  you: viewer,
724
+ // the terminals verdict has to be able to say "card 3e1c has waited on you
725
+ // for 12 minutes" without reading the whole pipeline board (redesign C.5).
726
+ // A guest has no cards at all; an operator runs them, approves them and is
727
+ // exactly the human one can be waiting on, so the gate is the cards
728
+ // permission the /api/cards routes use, not the owner flag.
729
+ ...(!shared || mayUseCards(viewer?.role ?? 'owner') ? { cards_waiting: cardsWaiting() } : {}),
424
730
  share: { on: shared, bind: shared ? share.bind : null, people: shared ? share.people.length : 0 },
425
731
  preferences: guest ? null : readPreferences(),
426
732
  ts: new Date().toISOString(),
@@ -488,7 +794,7 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, sessionsDebounce
488
794
  if (!card) { for (const c of clients) c.sig.delete(id); broadcast('removed', forOwner({ card_id: id })); return }
489
795
  const events = readEvents(id)
490
796
  // broadcast() refreshes each client's viewer (and drops revoked ones) first
491
- broadcast('card', forOwner(summarize(card)))
797
+ broadcast('card', forOwner(summarize(card, events)))
492
798
  for (const c of [...clients]) {
493
799
  if (!c.viewer || !mayUseCards(c.viewer.role)) { c.sig.set(id, events.length); continue }
494
800
  const from = c.sig.get(id) ?? 0
@@ -611,8 +917,25 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, sessionsDebounce
611
917
  return { add, stop, broadcast, clients }
612
918
  }
613
919
 
920
+ // Which logins this board reads usage for. LEG_CLAUDE_BIN and LEG_GROK_BIN say
921
+ // where a CLI lives, not that its login is fake, and gating on them switched
922
+ // every percentage, every 5h/7d number and every usage_error off for a user
923
+ // whose claude simply lives somewhere Leg's resolver does not look — with
924
+ // nothing on the board to say why. codex is the one agent whose binary the
925
+ // poller really needs, because it spawns the app-server to ask it; a stub there
926
+ // cannot answer, so codex alone is skipped on its BIN var. A suite that must
927
+ // reach no endpoint at all says so in one variable, LEG_NO_USAGE_POLL=1
928
+ // (test/helpers.mjs sets it for every spawned board), and an injected fetcher
929
+ // always wins: a test that supplied a reader is asking for it to be used.
930
+ export function usageAgentsFor({ env = process.env, injected = {} } = {}) {
931
+ const set = (name) => env[`LEG_${name}`] || env[`BATON_${name}`]
932
+ const off = set('NO_USAGE_POLL') === '1'
933
+ const stubbedCodex = Boolean(set('CODEX_BIN'))
934
+ return USAGE_AGENTS.filter((a) => injected[a] || (!off && !(a === 'codex' && stubbedCodex)))
935
+ }
936
+
614
937
  // ---- the server ----
615
- export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN || process.env.BATON_TOKEN || '', scheduler = (process.env.LEG_NO_SCHEDULER || process.env.BATON_NO_SCHEDULER) !== '1', share, usagePolling = false, usageReader = readCodexUsage } = {}) {
938
+ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN || process.env.BATON_TOKEN || '', scheduler = (process.env.LEG_NO_SCHEDULER || process.env.BATON_NO_SCHEDULER) !== '1', share, usagePolling = false, usageReader = readCodexUsage, usageFetchers = {} } = {}) {
616
939
  // An explicit `share` (tests) is fixed; the real server passes none and reads
617
940
  // share.json from disk, re-reading it per request (mtime-cached) so `leg
618
941
  // share add|rotate|rm` takes effect on a live board — a new link works at
@@ -655,28 +978,63 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
655
978
  return null
656
979
  }
657
980
  const sse = createSse({ viewFor, reauth: reauthClient })
658
- let sched = null
659
- let usageTimer = null
660
- let usageController = null
661
- let usageInFlight = null
662
- const refreshCodexAccounts = () => {
663
- if (!usagePolling || ((process.env.LEG_CODEX_BIN || process.env.BATON_CODEX_BIN) && usageReader === readCodexUsage) || usageInFlight) return usageInFlight
664
- usageController = new AbortController()
665
- const signal = usageController.signal
666
- usageInFlight = Promise.all((readAccounts().codex ?? ['default']).map(async (account) => {
667
- const codexHome = envFor('codex', account).CODEX_HOME || LAYOUT.codex.home()
668
- const r = await usageReader({ codexHome, timeoutMs: 8000, signal })
669
- if (!r.ok) return false
670
- recordUsage('codex', account, r.limits, 'codex app-server account/rateLimits/read', { observed_at: r.observed_at, available: r.available })
671
- return true
672
- })).then((changed) => {
673
- if (changed.some(Boolean)) sse.broadcast('sessions', (viewer) => viewFor(viewer))
674
- }).catch((err) => log(`codex usage refresh: ${err.message}`)).finally(() => {
675
- usageInFlight = null
676
- usageController = null
677
- })
678
- return usageInFlight
981
+ // A card that adopted a live terminal's checkout waits in the backlog until
982
+ // that terminal has really stopped. `end` is a REQUEST: src/attach.mjs reads
983
+ // control.json on its own poll (2s by default) and kills the child at the
984
+ // next tick, while the scheduler ticks every second, so a card queued here
985
+ // would put a headless agent in the same working tree as the interactive one
986
+ // for at least a tick, and longer if the agent is mid-turn. The session
987
+ // record is how the board learns a terminal ended (attach writes `ended`,
988
+ // sessions.mjs reaps a lost one), so that is what this waits on.
989
+ const ENQUEUE_POLL_MS = Math.max(50, Number(process.env.LEG_END_AS_CARD_POLL_MS || process.env.BATON_END_AS_CARD_POLL_MS || 500))
990
+ const ENQUEUE_WAIT_MS = Math.max(1000, Number(process.env.LEG_END_AS_CARD_WAIT_MS || process.env.BATON_END_AS_CARD_WAIT_MS || 600000))
991
+ const enqueueTimers = new Map()
992
+ function enqueueWhenSessionEnds(cardId, sessionId, who) {
993
+ const from = Date.now()
994
+ const arm = () => {
995
+ const t = setTimeout(tick, ENQUEUE_POLL_MS)
996
+ t.unref?.()
997
+ enqueueTimers.set(cardId, t)
998
+ }
999
+ const tick = () => {
1000
+ enqueueTimers.delete(cardId)
1001
+ const card = readCard(cardId)
1002
+ // killed, removed, or started by hand: it is not this timer's any more
1003
+ if (!card || card.status !== 'backlog') return
1004
+ const s = readSession(sessionId)
1005
+ if (!s || !isActive(s)) {
1006
+ try {
1007
+ const next = humanAction(cardId, 'enqueue', {}, who)
1008
+ log(`end-as-card ${cardId}: terminal ${sessionId} has stopped, the card is queued`)
1009
+ sse.broadcast('card', forOwner(summarize(next)))
1010
+ } catch (err) { log(`end-as-card ${cardId}: ${err.message}`) }
1011
+ return
1012
+ }
1013
+ if (Date.now() - from > ENQUEUE_WAIT_MS) {
1014
+ // never start it behind the human's back after a long wait: say why it
1015
+ // is sitting there and leave Run to them
1016
+ try { ledgerAppend(cardId, { actor: who, type: 'blocked_by', summary: `terminal ${sessionId} has not stopped, so this card is still in the backlog; press Run once it has` }) } catch { /* the log line below is the record */ }
1017
+ log(`end-as-card ${cardId}: terminal ${sessionId} still active after ${Math.round(ENQUEUE_WAIT_MS / 1000)}s, left in the backlog`)
1018
+ return
1019
+ }
1020
+ arm()
1021
+ }
1022
+ arm()
679
1023
  }
1024
+ let sched = null
1025
+ // ---- usage polling: one poller per LOGIN, not one per terminal ----
1026
+ // The terminals used to ask their agent's endpoint once a minute each, so a
1027
+ // login with three of them drew three times the requests and three copies of
1028
+ // every 429 (src/usage-poll.mjs). The board asks once per login, backs off on
1029
+ // a failure, and pushes what it read onto that login's active sessions.
1030
+ const usageFetchersFor = { codex: usageReader, ...usageFetchers }
1031
+ const injectedUsage = { codex: usageReader !== readCodexUsage, claude: Boolean(usageFetchers.claude), grok: Boolean(usageFetchers.grok) }
1032
+ const usagePollers = createUsagePollers({
1033
+ agents: usageAgentsFor({ injected: injectedUsage }),
1034
+ fetchers: usageFetchersFor,
1035
+ onChange: () => sse.broadcast('sessions', (viewer) => viewFor(viewer)),
1036
+ onLog: (msg) => log(msg),
1037
+ })
680
1038
 
681
1039
  async function handle(req, res) {
682
1040
  const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
@@ -719,7 +1077,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
719
1077
  const canMachine = !shared || mayUseMachine(viewer.role)
720
1078
  const ownsSession = (s) => !shared || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
721
1079
  const parts = path.split('/').filter(Boolean) // ['api', ...]
722
- if (!canCards && ['cards', 'floor', 'presets', 'adapters', 'leases'].includes(parts[1])) return send(res, 403, { error: 'the pipeline board belongs to the owner and the operators of this machine' })
1080
+ 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' })
723
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.' })
724
1082
  try {
725
1083
  if (req.method === 'GET' && path === '/api/health') {
@@ -730,14 +1088,22 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
730
1088
  }
731
1089
  if (req.method === 'GET' && path === '/api/adapters') return send(res, 200, { adapters: await adaptersInfo() })
732
1090
  if (req.method === 'GET' && path === '/api/presets') return send(res, 200, { presets: PRESETS })
1091
+ // Which models this machine can start each agent on (src/models.mjs).
1092
+ // Sits beside /api/adapters because it answers the second half of the
1093
+ // same question — an adapter says WHO can run, this says WHAT it runs as
1094
+ // — and it is guarded the same way: a guest picking a model is a guest
1095
+ // spending the owner's plan. Never waits on a child process: agy's and
1096
+ // grok's lists come off an hourly cache and refresh behind the answer.
1097
+ if (req.method === 'GET' && path === '/api/models') return send(res, 200, listModels())
733
1098
  if (req.method === 'GET' && path === '/api/cards') {
734
1099
  const cards = listCards()
735
- return send(res, 200, { columns: columnsFor(cards), cards: cards.map(summarize) })
1100
+ return send(res, 200, { columns: columnsFor(cards), cards: cards.map((c) => summarize(c)) })
736
1101
  }
737
1102
  if (req.method === 'POST' && path === '/api/cards') {
738
1103
  const body = await readBody(req)
739
1104
  try {
740
- const card = await createCard(body, actor)
1105
+ // a pipeline that names a file is a CLI flag, never a request body
1106
+ const card = await createCard(body, actor, { allowPipelineFile: false })
741
1107
  sse.broadcast('card', forOwner(summarize(card)))
742
1108
  return send(res, 201, { card: summarize(card) })
743
1109
  } catch (err) {
@@ -748,7 +1114,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
748
1114
  if (req.method === 'GET' && path === '/api/events') {
749
1115
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' })
750
1116
  const cards = canCards ? listCards() : []
751
- res.write(`event: hello\ndata: ${JSON.stringify({ columns: columnsFor(cards), cards: cards.map(summarize), sessions: viewFor(viewer), ts: new Date().toISOString() })}\n\n`)
1117
+ res.write(`event: hello\ndata: ${JSON.stringify({ columns: columnsFor(cards), cards: cards.map((c) => summarize(c)), sessions: viewFor(viewer), ts: new Date().toISOString() })}\n\n`)
752
1118
  sse.add(res, cards, viewer, { token: presentedToken(req, url), loopback: isLoopbackRequest(req) })
753
1119
  return
754
1120
  }
@@ -761,6 +1127,15 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
761
1127
  try {
762
1128
  const patch = {}
763
1129
  if (body.handoff_order !== undefined) patch.handoff_order = requireHandoffOrder(body.handoff_order)
1130
+ // the ladder and the rules around it (B.3). `handoff_order` is
1131
+ // rewritten from the ladder inside writePreferences, so the two
1132
+ // keys on disk can never disagree.
1133
+ if (body.handoff_ladder !== undefined) patch.handoff_ladder = requireHandoffLadder(body.handoff_ladder)
1134
+ if (body.climb_back !== undefined) patch.climb_back = requireClimbBack(body.climb_back)
1135
+ if (body.may_spend !== undefined) patch.may_spend = Boolean(body.may_spend)
1136
+ if (body.reserve !== undefined) patch.reserve = requireReserve(body.reserve)
1137
+ if (body.notify_terminal !== undefined) patch.notify_terminal = Boolean(body.notify_terminal)
1138
+ if (body.notify_board !== undefined) patch.notify_board = Boolean(body.notify_board)
764
1139
  if (body.harness !== undefined) {
765
1140
  // the board may narrow the policy or turn the feature off; turning
766
1141
  // it on is the first-run consent flow, which shows what will be
@@ -771,7 +1146,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
771
1146
  if (body.harness?.policy !== undefined && rank.indexOf(body.harness.policy) > rank.indexOf(current.policy)) return send(res, 400, { error: `the board may only narrow the harness policy (now ${current.policy}); widen it from a terminal: leg harness policy ${body.harness.policy}` })
772
1147
  patch.harness = { policy: body.harness?.policy, enabled: body.harness?.enabled === false ? false : undefined }
773
1148
  }
774
- if (!Object.keys(patch).length) return send(res, 400, { error: 'nothing to change: send handoff_order or harness' })
1149
+ if (!Object.keys(patch).length) return send(res, 400, { error: 'nothing to change: send handoff_ladder, handoff_order, climb_back, may_spend, reserve, notify_terminal, notify_board or harness' })
775
1150
  const preferences = writePreferences(patch)
776
1151
  sse.broadcast('sessions', (v) => viewFor(v))
777
1152
  return send(res, 200, { preferences })
@@ -820,11 +1195,32 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
820
1195
  sse.broadcast('sessions', (v) => viewFor(v))
821
1196
  return send(res, 200, { ok: true, request: hit })
822
1197
  }
823
- if (req.method === 'GET' && parts.length === 3) return send(res, 200, { session: sess, events: readSessionEvents(id), requests: readRequests(id) })
1198
+ // A guest owns their own terminal, so this route hands them the record
1199
+ // verbatim — including the two window percentages, the reading source
1200
+ // and the poller's usage_error, every one of which the LIST route
1201
+ // scrubs (`scrubOwnerUsage`, sessionsView). One GET of their own
1202
+ // session id was the whole share boundary walked around. The same scrub
1203
+ // runs here, and the poller's failure line is kept off their timeline:
1204
+ // it names the owner's reason and the clock it started.
1205
+ if (req.method === 'GET' && parts.length === 3) {
1206
+ const asGuest = shared && viewer.role !== 'owner'
1207
+ const events = readSessionEvents(id)
1208
+ return send(res, 200, {
1209
+ session: asGuest ? scrubOwnerUsage(sess) : sess,
1210
+ events: asGuest ? events.filter((e) => !isUsageFailureEvent(e)) : events,
1211
+ requests: readRequests(id),
1212
+ })
1213
+ }
824
1214
  // the card's drawer: what the agent last said, what it changed, what it
825
1215
  // has done. Only ever this viewer's own terminal; the guard above sent
826
1216
  // anyone else away before we read a transcript.
827
- if (req.method === 'GET' && parts[3] === 'detail') return send(res, 200, sessionDetail(sess))
1217
+ if (req.method === 'GET' && parts[3] === 'detail') {
1218
+ const detail = sessionDetail(sess)
1219
+ // the drawer of a guest's OWN terminal is theirs; the poller's line
1220
+ // about the owner's login is not (the same event the route above drops)
1221
+ if (shared && viewer.role !== 'owner') detail.events = detail.events.filter((e) => !isUsageFailureEvent(e))
1222
+ return send(res, 200, detail)
1223
+ }
828
1224
  if (req.method === 'GET' && parts[3] === 'diff') {
829
1225
  try {
830
1226
  return send(res, 200, sessionDiff(sess, url.searchParams.get('file') ?? ''))
@@ -842,7 +1238,13 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
842
1238
  }
843
1239
  const body = await readBody(req)
844
1240
  try {
845
- const order = requireHandoffOrder(body.handoff_order)
1241
+ // one route, two shapes: the old list of agents, and the ladder of
1242
+ // rungs that replaced it. Sending either rewrites the other, the
1243
+ // same way preferences.json keeps them in step.
1244
+ const ladder = body.handoff_ladder !== undefined ? requireHandoffLadder(body.handoff_ladder) : null
1245
+ const order = ladder ? orderFromLadder(ladder) : requireHandoffOrder(body.handoff_order)
1246
+ const rungs = ladder ?? ladderFor({ handoff_order: order })
1247
+ const summary = ladder ? `handoff ladder changed to ${rungs.map((r) => rungLabel(r)).join(' → ')}` : `handoff order changed to ${order.join(' → ')}`
846
1248
  const next = updateSession(id, (current) => {
847
1249
  if (!['starting', 'running', 'warning', 'limit', 'waiting'].includes(current.status)) {
848
1250
  const conflict = new Error(`handoff order cannot change while this terminal is ${current.status}`)
@@ -851,9 +1253,10 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
851
1253
  }
852
1254
  return {
853
1255
  handoff_order: order,
854
- chain: candidates({ agent: current.agent, account: current.account, accounts: readAccounts(), order }),
1256
+ handoff_ladder: rungs,
1257
+ chain: candidates({ agent: current.agent, account: current.account, model: current.model ?? null, accounts: readAccounts(), order, ladder: rungs }),
855
1258
  }
856
- }, { event: { type: 'status', by: actor.id, summary: `handoff order changed to ${order.join(' → ')}` } })
1259
+ }, { event: { type: 'status', by: actor.id, summary } })
857
1260
  sse.broadcast('sessions', (v) => viewFor(v))
858
1261
  return send(res, 200, { session: next })
859
1262
  } catch (err) {
@@ -889,6 +1292,107 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
889
1292
  log(`land requested for ${id} by ${actor.id}`)
890
1293
  return send(res, 202, { ok: true, requested: 'land' })
891
1294
  }
1295
+ // "I have to leave, keep going." The terminal's context becomes a card
1296
+ // that continues from the SAME rung, and the terminal then ends exactly
1297
+ // the way End ends it. Where the card works depends on what the
1298
+ // terminal had:
1299
+ // - its own worktree: the card adopts it (redesign G4; two worktrees
1300
+ // on one branch is the conflict machine the roadmap rejects) and
1301
+ // waits in the BACKLOG until the terminal has really stopped, since
1302
+ // `end` is a request the runner reads on its own poll and the
1303
+ // scheduler ticks once a second.
1304
+ // - no worktree of its own (the ordinary case): a checkout of its own
1305
+ // is cut from that branch and the uncommitted work is carried into
1306
+ // it, so the card continues from what the human was looking at
1307
+ // rather than from the last commit.
1308
+ if (req.method === 'POST' && parts[3] === 'end-as-card') {
1309
+ // a card is the pipeline board, which belongs to the owner and the
1310
+ // operators of this machine even when the terminal is the caller's
1311
+ if (!canCards) return send(res, 403, { error: 'the pipeline board belongs to the owner and the operators of this machine' })
1312
+ if (!isActive(sess)) return send(res, 409, { error: `session ${id} is not active` })
1313
+ if (!sess.repo) return send(res, 409, { error: 'this terminal is not in a git repository, so there is no branch for a card to continue on' })
1314
+ let bundle
1315
+ try {
1316
+ bundle = saveSessionBundle(sess, { messages: sessionMessages(sess), why: 'ended as a card' })
1317
+ } catch (err) {
1318
+ // nothing has been ended yet: refuse rather than end a terminal
1319
+ // whose context was never written down
1320
+ return send(res, 409, { error: `the hand-off bundle could not be written, so this terminal was left alone: ${scrub(err.message).slice(0, 200)}` })
1321
+ }
1322
+ const rungs = ladderFromCurrentRung(sess)
1323
+ // one model per adapter is all a card's chain can carry, so a ladder
1324
+ // with claude/fable and claude/opus in it keeps the FIRST, which is
1325
+ // the rung this terminal is on
1326
+ const models = {}
1327
+ for (const r of rungs) if (r.model && !models[r.agent]) models[r.agent] = r.model
1328
+ const task = `${sess.task ?? 'Continue the work already under way in this checkout.'}\n\nContinue from the bundle at ${bundle.path}.`
1329
+ const adopted = sess.worktree?.path && existsSync(sess.worktree.path) ? sess.worktree.path : null
1330
+ const trunkBranch = sess.worktree?.base || sess.branch || 'main'
1331
+ // The checkout the human has been working in. Read BEFORE anything is
1332
+ // created or ended: if the work cannot be read, nothing has happened
1333
+ // yet and the terminal is left exactly as it was.
1334
+ // the repository root, which is what `git status --porcelain`,
1335
+ // `git diff` and `git ls-files` all report paths against, so the
1336
+ // patch and the file list line up with the new checkout's root
1337
+ const workRoot = adopted ?? ((sess.repo && existsSync(sess.repo)) ? sess.repo : sess.cwd)
1338
+ let carried = null
1339
+ if (!adopted) {
1340
+ try { carried = captureUncommitted(workRoot) } catch (err) {
1341
+ return send(res, 409, { error: `the uncommitted work in this checkout could not be read, so this terminal was left alone: ${scrub(err.message).slice(0, 200)}` })
1342
+ }
1343
+ }
1344
+ let card
1345
+ try {
1346
+ card = await createCard({
1347
+ repo: sess.repo, task,
1348
+ chain: rungs.map((r) => r.agent).join(',') || sess.agent,
1349
+ model: models,
1350
+ trunk: trunkBranch,
1351
+ title: sess.task ? String(sess.task).slice(0, 60) : `continued from ${id}`,
1352
+ // never queued here: the card is enqueued below, once its
1353
+ // checkout is its own and nothing else is writing to it
1354
+ queue: false,
1355
+ }, actor)
1356
+ } catch (err) {
1357
+ if (err instanceof CardInputError) return send(res, 400, { error: `the card could not be created, so this terminal was left alone: ${err.message}` })
1358
+ throw err
1359
+ }
1360
+ let carriedFiles = 0
1361
+ if (adopted) {
1362
+ ledgerUpdate(card.card_id, { patch: { lineage: { from: id }, worktree: adopted, worktree_adopted: true, worktree_branch: sess.worktree?.branch ?? null } })
1363
+ } else {
1364
+ try {
1365
+ const wt = ensureWorktree(sess.repo, card.card_id, { trunk: trunkBranch })
1366
+ carriedFiles = carryUncommitted(wt.path, carried)
1367
+ ledgerUpdate(card.card_id, { patch: { lineage: { from: id }, worktree: wt.path, worktree_branch: wt.branch } })
1368
+ } catch (err) {
1369
+ // nothing has been ended and nothing has been queued: take the
1370
+ // half-made card off the board rather than leave it there
1371
+ try { removeWorktree(sess.repo, card.card_id, { force: true }) } catch { /* it may never have been cut */ }
1372
+ try { rmSync(cardDir(card.card_id), { recursive: true, force: true }) } catch { /* the board never saw it */ }
1373
+ return send(res, 409, { error: `the work in this checkout could not be carried into a checkout of its own, so this terminal was left alone: ${scrub(err.message).slice(0, 200)}` })
1374
+ }
1375
+ }
1376
+ // `handoff_written` is the ledger's word for "a bundle was written and
1377
+ // the work moved on", which is exactly what happened here. The audited
1378
+ // line with the actor is on the terminal's side, below.
1379
+ const where = adopted
1380
+ ? ', in the terminal\'s own worktree, once that terminal has stopped'
1381
+ : `, in a worktree of its own cut from ${trunkBranch}${carriedFiles ? `, carrying ${carriedFiles} uncommitted file(s) over` : ''}`
1382
+ ledgerAppend(card.card_id, { actor, type: 'handoff_written', summary: `continued from terminal ${id}${where}` })
1383
+ requestControl(id, { end: true, by: actor.id })
1384
+ appendSessionEvent(id, { type: 'handed_off', by: actor.id, summary: `${actor.id} ended this terminal and kept it going as card ${card.card_id}` })
1385
+ updateSession(id, (cur) => ({ lineage: { ...(cur.lineage ?? {}), to: card.card_id } }))
1386
+ if (adopted) enqueueWhenSessionEnds(card.card_id, id, actor)
1387
+ else {
1388
+ try { humanAction(card.card_id, 'enqueue', {}, actor) } catch (err) { log(`end-as-card ${card.card_id}: ${err.message}`) }
1389
+ }
1390
+ log(`end-as-card for ${id} by ${actor.id}: ${card.card_id}${adopted ? ` in ${adopted} (queued when ${id} stops)` : ` in a checkout of its own${carriedFiles ? `, ${carriedFiles} file(s) carried` : ''}`}`)
1391
+ const next = summarize(readCard(card.card_id))
1392
+ sse.broadcast('card', forOwner(next))
1393
+ sse.broadcast('sessions', (v) => viewFor(v))
1394
+ return send(res, 201, { card: next, bundle: { id: bundle.id, path: bundle.path }, carried: { files: carriedFiles, adopted: Boolean(adopted) } })
1395
+ }
892
1396
  if (req.method === 'POST' && (parts[3] === 'handoff' || parts[3] === 'end')) {
893
1397
  if (!isActive(sess)) return send(res, 409, { error: `session ${id} is not active` })
894
1398
  if (parts[3] === 'end') {
@@ -901,19 +1405,21 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
901
1405
  const body = await readBody(req)
902
1406
  let target = null
903
1407
  if (body && body.agent !== undefined && body.agent !== null && body.agent !== '') {
904
- const want = { agent: String(body.agent), account: String(body.account ?? 'default') }
1408
+ const want = { agent: String(body.agent), account: String(body.account ?? 'default'), model: body.model ? String(body.model).toLowerCase() : null }
905
1409
  const order = normalizeHandoffOrder(sess.handoff_order)
906
- const chain = candidates({ agent: sess.agent, account: sess.account, accounts: readAccounts(), order })
907
- const hit = chain.find((c) => c.agent === want.agent && c.account === want.account)
908
- const label = `${want.agent}${want.account !== 'default' ? '/' + want.account : ''}`
909
- if (!hit) return send(res, 400, { error: `${label} is not a destination for this terminal (${chain.map((c) => c.agent + (c.account !== 'default' ? '/' + c.account : '')).join(', ') || 'none'})` })
1410
+ const ladder = ladderFor(sess)
1411
+ const chain = candidates({ agent: sess.agent, account: sess.account, model: sess.model ?? null, accounts: readAccounts(), order, ladder })
1412
+ const hit = chain.find((c) => c.agent === want.agent && c.account === want.account && (want.model ? (c.model ?? null) === want.model : true))
1413
+ const label = rungLabel(want)
1414
+ if (!hit) return send(res, 400, { error: `${label} is not a destination for this terminal (${chain.map((c) => rungLabel(c)).join(', ') || 'none'})` })
910
1415
  if (sess.installed && sess.installed[want.agent] === false) return send(res, 409, { error: `${label} is not installed on this machine` })
911
1416
  const u = readUsage(want.agent, want.account)
912
1417
  if (!isAvailable(u)) return send(res, 409, { error: `${label} is at its usage limit until ${fmtReset(u.limited_until)}; pick another or use Hand off now without a destination` })
913
- target = hit
1418
+ if (hit.model && wallActive(u.walls?.[hit.model])) return send(res, 409, { error: `${label} is out until ${fmtReset(u.walls[hit.model].limited_until)}; pick another rung or use Hand off now without a destination` })
1419
+ target = { agent: hit.agent, account: hit.account, ...(hit.model ? { model: hit.model } : {}) }
914
1420
  }
915
1421
  requestControl(id, target ? { handoff: true, target, by: actor.id } : { handoff: true, by: actor.id })
916
- log(`handoff requested for ${id} by ${actor.id}${target ? ` to ${target.agent}/${target.account}` : ''}`)
1422
+ log(`handoff requested for ${id} by ${actor.id}${target ? ` to ${rungLabel(target)}` : ''}`)
917
1423
  return send(res, 200, { ok: true, requested: 'handoff', target })
918
1424
  }
919
1425
  if (req.method === 'DELETE' && parts.length === 3) {
@@ -1039,6 +1545,48 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
1039
1545
  sse.broadcast('removed', forOwner({ card_id: id }))
1040
1546
  return send(res, 200, { removed: id })
1041
1547
  }
1548
+ // Take over: sit down in the card's worktree yourself. The card is
1549
+ // paused first (its child is killed and its bundle written by the
1550
+ // existing transition), then Leg hands back the one command that opens
1551
+ // a terminal there. This is the only command the board ever hands a
1552
+ // human, because a browser tab cannot open one (redesign C.4).
1553
+ if (req.method === 'POST' && parts[3] === 'take-over') {
1554
+ if (TERMINAL.includes(card.status)) return send(res, 409, { error: `card ${id} is ${card.status}: there is nothing running to take over. Rerun it, or open its worktree yourself.` })
1555
+ const raw = String(summarize(card).active_adapter ?? '')
1556
+ const agent = [raw, raw.replace(/^fake-/, '')].find((n) => SUPERVISED_AGENTS.includes(n)) ?? null
1557
+ if (!agent) return send(res, 409, { error: `this card's current leg runs ${raw || 'no agent'}, which is not one of the agents leg can open a terminal for (${SUPERVISED_AGENTS.join(', ')})` })
1558
+ // Every non-terminal status moves to `paused` before the command is
1559
+ // handed back, not just `running`. A `queued` or `handing_off` card
1560
+ // is in the set the scheduler starts from, and it ticks once a
1561
+ // second: the human would paste this command into a worktree an
1562
+ // agent had just been launched in, which is the collision Take over
1563
+ // exists to prevent. The transition also writes the actor's own
1564
+ // `taken_over` line, so the audit trail names who has the checkout.
1565
+ let next
1566
+ try { next = humanAction(id, 'take_over', {}, actor) } catch (err) {
1567
+ if (err instanceof IllegalTransition) return send(res, 409, { error: err.message })
1568
+ throw err
1569
+ }
1570
+ // A card that never ran has no checkout of its own, and the terminal
1571
+ // that takes it over must never open in the human's main checkout
1572
+ // (src/attach.mjs cardWorkRoot refuses that). Cut its worktree now, on
1573
+ // the trunk the card would have used, so the command below has a
1574
+ // place to open.
1575
+ const cur = readCard(id) ?? next
1576
+ if (!(cur.worktree && existsSync(cur.worktree)) && cur.repo) {
1577
+ try {
1578
+ const wt = ensureWorktree(cur.repo, id, { trunk: cur.trunk || trunkFor(cur.repo).branch || 'main' })
1579
+ ledgerUpdate(id, { patch: { worktree: wt.path, worktree_branch: wt.branch } })
1580
+ } catch (err) {
1581
+ return send(res, 409, { error: `could not cut a checkout for ${id}: ${err.message}` })
1582
+ }
1583
+ }
1584
+ const command = `leg ${agent} --resume-card ${id}`
1585
+ log(`take-over for ${id} by ${actor.id}: ${command}`)
1586
+ const view = summarize(readCard(id) ?? next)
1587
+ sse.broadcast('card', forOwner(view))
1588
+ return send(res, 200, { card: view, command })
1589
+ }
1042
1590
  if (req.method === 'POST' && parts[3]) {
1043
1591
  const map = { run: 'enqueue', queue: 'enqueue', approve: 'approve', reassign: 'reassign', pause: 'pause', resume: 'resume', kill: 'kill', handoff: 'handoff_now', 'handoff-now': 'handoff_now', rerun: 'rerun' }
1044
1592
  const action = map[parts[3]]
@@ -1099,9 +1647,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
1099
1647
  sched.run().catch((err) => log(`scheduler crashed: ${err.message}`))
1100
1648
  }
1101
1649
  if (usagePolling) {
1102
- refreshCodexAccounts()
1103
- usageTimer = setInterval(refreshCodexAccounts, 60000)
1104
- usageTimer.unref?.()
1650
+ usagePollers.start().catch((err) => log(`usage polling: ${err.message}`))
1105
1651
  }
1106
1652
  if (loopbackCompanion) {
1107
1653
  loopbackCompanion.on('error', (err) => log(`loopback companion: ${err.message}`))
@@ -1112,8 +1658,9 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
1112
1658
  })
1113
1659
  },
1114
1660
  async stop() {
1115
- if (usageTimer) { clearInterval(usageTimer); usageTimer = null }
1116
- usageController?.abort()
1661
+ usagePollers.stop()
1662
+ for (const t of enqueueTimers.values()) clearTimeout(t)
1663
+ enqueueTimers.clear()
1117
1664
  sse.stop()
1118
1665
  if (sched) sched.stop()
1119
1666
  if (loopbackCompanion) await new Promise((r) => { loopbackCompanion.closeAllConnections?.(); loopbackCompanion.close(() => r()) })