@ucsandman/legcli 0.10.0 → 0.12.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 (70) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +158 -67
  3. package/bin/leg.mjs +168 -18
  4. package/docs/DECISIONS.md +10 -0
  5. package/docs/DEMO.md +20 -14
  6. package/docs/DEVIATIONS.md +1 -0
  7. package/docs/ERRORS.md +94 -0
  8. package/docs/ROADMAP-v2.md +69 -11
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/adapters.md +93 -11
  11. package/docs/board-guide.md +401 -66
  12. package/docs/cli-contracts.md +235 -22
  13. package/docs/concepts.md +167 -19
  14. package/docs/configuration.md +113 -5
  15. package/docs/faq.md +21 -5
  16. package/docs/getting-started.md +15 -11
  17. package/docs/redesign-2026-09-17.md +477 -0
  18. package/docs/screenshots/background-1280.png +0 -0
  19. package/docs/screenshots/board-400px.png +0 -0
  20. package/docs/screenshots/board-details-open.png +0 -0
  21. package/docs/screenshots/board-drawer.png +0 -0
  22. package/docs/screenshots/board-handoff.png +0 -0
  23. package/docs/screenshots/board-running.png +0 -0
  24. package/docs/screenshots/capacity-drawer-1280.png +0 -0
  25. package/docs/screenshots/settings-ladder-1280.png +0 -0
  26. package/docs/screenshots/terminals-1280.png +0 -0
  27. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  28. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  29. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  30. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  31. package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
  32. package/fixtures/live/claude/resume-model-probe.json +20 -0
  33. package/fixtures/live/claude/usage-oauth.json +87 -0
  34. package/fixtures/live/grok/cmd.txt +1 -1
  35. package/fixtures/live/grok/parsed.json +6 -3
  36. package/fixtures/live/grok/run.json +22 -10
  37. package/fixtures/verified.json +8 -1
  38. package/package.json +3 -2
  39. package/scripts/build-docs-site.mjs +4 -4
  40. package/scripts/probe.mjs +2 -1
  41. package/scripts/seed-fake-cards.mjs +59 -6
  42. package/scripts/seed-wes-board.mjs +81 -12
  43. package/src/accounts.mjs +6 -1
  44. package/src/adapters/cli.mjs +130 -0
  45. package/src/adapters/custom.mjs +271 -0
  46. package/src/adapters/grok.mjs +51 -10
  47. package/src/adapters/index.mjs +34 -7
  48. package/src/attach.mjs +350 -42
  49. package/src/audit.mjs +118 -0
  50. package/src/board/audit.js +123 -0
  51. package/src/board/board.css +134 -9
  52. package/src/board/board.js +482 -106
  53. package/src/board/index.html +89 -7
  54. package/src/board/sessions.js +1371 -113
  55. package/src/buckets.mjs +101 -0
  56. package/src/cards.mjs +9 -1
  57. package/src/chain.mjs +13 -0
  58. package/src/hook.mjs +7 -1
  59. package/src/ledger.mjs +10 -2
  60. package/src/orchestrator.mjs +13 -4
  61. package/src/preferences.mjs +214 -5
  62. package/src/scheduler.mjs +24 -1
  63. package/src/server.mjs +615 -50
  64. package/src/sessions.mjs +17 -1
  65. package/src/share.mjs +66 -6
  66. package/src/taps/claude-usage.mjs +91 -2
  67. package/src/taps/claude.mjs +144 -5
  68. package/src/taps/codex.mjs +23 -3
  69. package/src/taps/grok.mjs +4 -0
  70. package/src/usage.mjs +424 -13
package/src/server.mjs CHANGED
@@ -5,34 +5,39 @@
5
5
  // LEG_BIND (127.0.0.1) + LEG_PORT (4747) + LEG_TOKEN are the
6
6
  // multiplayer seams (src/auth.mjs). BATON_* names still work as fallback.
7
7
  import http from 'node:http'
8
+ import https from 'node:https'
8
9
  import { spawnSync, execFile } from 'node:child_process'
9
- 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'
10
11
  import { join, dirname, resolve, extname, sep } from 'node:path'
11
12
  import { fileURLToPath } from 'node:url'
12
13
  import { checkBind, authorize, remoteAddress, presentedToken, isLoopback, isLoopbackRequest, tokenMatches } from './auth.mjs'
13
- import { readShare, isOn as shareIsOn, sharePath, identify, personNamed } from './share.mjs'
14
+ import { readShare, isOn as shareIsOn, sharePath, identify, personNamed, mayUseCards, mayUseMachine, readTls } from './share.mjs'
15
+ import { auditTrail, ACTOR_KINDS } from './audit.mjs'
14
16
  import { createLimiter } from './ratelimit.mjs'
15
17
  import { realPath, canonPath } from './fsx.mjs'
16
- 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'
17
21
  import { humanAction } from './orchestrator.mjs'
18
22
  import { createCard, CardInputError } from './cards.mjs'
19
- import { IllegalTransition, availableActions } from './chain.mjs'
23
+ import { IllegalTransition, availableActions, NON_TERMINAL, TERMINAL } from './chain.mjs'
20
24
  import { held } from './leases.mjs'
21
25
  import { PRESETS } from './presets.mjs'
22
26
  import { names as adapterNames, get as getAdapter, isFake } from './adapters/index.mjs'
23
27
  import { createScheduler, schedulerStatus, MAX_CONCURRENT } from './scheduler.mjs'
24
- import { remove as removeWorktree, worktreeDirty } from './worktree.mjs'
28
+ import { ensure as ensureWorktree, remove as removeWorktree, worktreeDirty } from './worktree.mjs'
25
29
  import { scrub } from './runner.mjs'
26
30
  import { resolveChb } from './handoff.mjs'
27
- 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'
28
32
  import { sessionDetail, sessionDiff, DiffInputError } from './session-detail.mjs'
29
33
  import { hasRecentSynthesis } from './synthesis.mjs'
30
34
  import { refreshPointers } from './resume.mjs'
31
35
  import { landSession, landBlocker, landingNow, pruneSessionWorktree, canLand, prepareLanding, applyLandFix } from './land.mjs'
32
- import { readUsage, recordUsage, usageIsStale, candidates, isAvailable } from './usage.mjs'
36
+ import { readUsage, recordUsage, usageIsStale, candidates, isAvailable, fmtReset, binding, evaluateLadder, rungLabel, wallActive } from './usage.mjs'
33
37
  import { readAccounts, envFor, LAYOUT } from './accounts.mjs'
34
- import { readCodexUsage } from './taps/codex.mjs'
35
- import { readPreferences, writePreferences, normalizeHandoffOrder, requireHandoffOrder } from './preferences.mjs'
38
+ import { readCodexUsage, transcriptTail as codexTranscriptTail } from './taps/codex.mjs'
39
+ import { readPreferences, writePreferences, normalizeHandoffOrder, requireHandoffOrder, ladderFor, requireHandoffLadder, requireClimbBack, requireReserve, orderFromLadder } from './preferences.mjs'
40
+ import { isDownshift } from './buckets.mjs'
36
41
  import { listHistory, findRecord, recordDetail, refreshIndex, readIndex, providerSupport, HistoryInputError, PROVIDER_NAMES } from './history/index.mjs'
37
42
  import { listWorktrees } from './history/worktrees.mjs'
38
43
 
@@ -73,16 +78,192 @@ export function columnOf(card) {
73
78
  return card.station
74
79
  }
75
80
 
76
- export function summarize(card) {
81
+ // ---- the work stat on a live card's row (redesign C.3) --------------------
82
+ // `4 files, +212 -18`. Parsed from git's own one-line summary, never counted
83
+ // here: a part the line does not carry is left off the object, so the row can
84
+ // print only what was measured and never estimate the rest.
85
+ export function parseShortstat(line) {
86
+ const text = String(line ?? '')
87
+ const files = /(\d+)\s+files?\s+changed/.exec(text)
88
+ const ins = /(\d+)\s+insertions?\(\+\)/.exec(text)
89
+ const del = /(\d+)\s+deletions?\(-\)/.exec(text)
90
+ if (!files && !ins && !del) return null
91
+ const out = {}
92
+ if (files) out.files = parseInt(files[1], 10)
93
+ if (ins) out.insertions = parseInt(ins[1], 10)
94
+ if (del) out.deletions = parseInt(del[1], 10)
95
+ return out
96
+ }
97
+
98
+ function measureWork(card) {
99
+ if (!card.worktree || !existsSync(card.worktree)) return null
100
+ const base = card.trunk || 'main'
101
+ const r = spawnSync('git', ['diff', '--shortstat', `${base}..HEAD`], { cwd: card.worktree, windowsHide: true, encoding: 'utf8', timeout: 8000, env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
102
+ if (r.status !== 0) return null
103
+ return parseShortstat(r.stdout)
104
+ }
105
+
106
+ // One `git diff` per live card per push would put a subprocess per card inside
107
+ // the board's one event loop, which is the stall canLandFor already exists to
108
+ // avoid. Keyed on the card's own revision, with the expiries spread across the
109
+ // window so twenty cards never re-read together.
110
+ const WORK_TTL = 15000
111
+ const workCache = new Map()
112
+ function workFor(card) {
113
+ const key = `${card.card_id}|${card.updated_at ?? ''}`
114
+ const hit = workCache.get(key)
115
+ if (hit && Date.now() < hit.until) return hit.data
116
+ let data = null
117
+ try { data = measureWork(card) } catch { data = null }
118
+ if (workCache.size > 300) workCache.clear()
119
+ workCache.set(key, { until: Date.now() + WORK_TTL / 2 + Math.random() * WORK_TTL, data })
120
+ return data
121
+ }
122
+
123
+ // The test and land verdicts a live card has already earned. Both are read from
124
+ // the card's own ledger, because that is where each station records its result:
125
+ // a test station writes no run.json (src/stations/test.mjs), and a landing is a
126
+ // `landed` / `bounced` / `failed` event (src/chain.mjs). Last one wins; a card
127
+ // that has run neither carries neither key.
128
+ export function cardOutcomes(events) {
129
+ const out = {}
130
+ for (const ev of events ?? []) {
131
+ const summary = String(ev.summary ?? '')
132
+ const m = /^test (green|red)\b/.exec(summary)
133
+ if (m) out.tests = { state: m[1], at: ev.ts }
134
+ if (ev.type === 'landed') {
135
+ const sha = /\b([0-9a-f]{7,40})\b/.exec(summary)
136
+ out.land = { state: 'landed', reason: null, sha: sha ? sha[1] : null }
137
+ } else if (ev.type === 'bounced' && /^land bounced/.test(summary)) {
138
+ out.land = { state: 'bounced', reason: ev.body ? String(ev.body).slice(0, 200) : summary, sha: null }
139
+ } else if (ev.type === 'failed' && /\bland\b/.test(summary)) {
140
+ out.land = { state: 'failed', reason: ev.body ? String(ev.body).slice(0, 200) : summary, sha: null }
141
+ }
142
+ }
143
+ return out
144
+ }
145
+
146
+ // The last messages a hand-off bundle quotes, from the agent's own transcript.
147
+ // An agent Leg cannot read a transcript for contributes none, and the bundle is
148
+ // written from the record alone rather than with invented text.
149
+ function sessionMessages(s) {
150
+ try {
151
+ if (s.agent === 'claude') return claudeTranscriptTail(s.transcript_path)
152
+ if (s.agent === 'codex') return codexTranscriptTail(s.transcript_path)
153
+ } catch { /* an unreadable transcript is not a reason to refuse the bundle */ }
154
+ return []
155
+ }
156
+
157
+ // This terminal's ladder from the rung it is standing on, downward. A card made
158
+ // from a terminal starts where the terminal is, not at the top: the rungs above
159
+ // it are the ones this work has already used up (redesign C.4).
160
+ export function ladderFromCurrentRung(session) {
161
+ const ladder = ladderFor(session).filter((r) => r && r.agent)
162
+ const exact = ladder.findIndex((r) => r.agent === session.agent && (r.account ?? 'default') === (session.account ?? 'default') && (r.model ?? null) === (session.model ?? null))
163
+ const byAgent = exact >= 0 ? exact : ladder.findIndex((r) => r.agent === session.agent)
164
+ return byAgent >= 0 ? ladder.slice(byAgent) : ladder
165
+ }
166
+
167
+ // ---- carrying a checkout's uncommitted work into another one --------------
168
+ // "End, and keep going as a card" on a lone terminal (the ordinary case: a
169
+ // terminal only cuts a worktree of its own when a second live session shares
170
+ // the checkout, src/attach.mjs isolate()) has to move the work the human was
171
+ // looking at, not just the branch it sits on. Captured in two halves, because
172
+ // git keeps them apart: a patch of everything it tracks, staged and unstaged
173
+ // (`git diff HEAD --binary`), and the bytes of the files it does not
174
+ // (`git ls-files --others`). The second half is the one `git stash create`
175
+ // cannot carry, and it is where a terminal's newest file always is.
176
+ const CARRY_SKIP = ['.git', '.leg-worktrees', '.baton-worktrees', '.context-handoffs', '.leg', '.baton', 'node_modules']
177
+ const CARRY_MAX_BYTES = 8 * 1024 * 1024
178
+ function gitIn(dir, args, opts = {}) {
179
+ 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 })
180
+ if (r.error) throw r.error
181
+ if (r.status !== 0) throw new Error(`git ${args[0]} failed: ${String(r.stderr ?? '').trim().slice(0, 200)}`)
182
+ return r.stdout
183
+ }
184
+
185
+ export function captureUncommitted(dir) {
186
+ const status = gitIn(dir, ['status', '--porcelain'])
187
+ if (!status.trim()) return null
188
+ // --binary keeps a changed image or lockfile intact; the output is ASCII
189
+ const patch = gitIn(dir, ['diff', 'HEAD', '--binary'])
190
+ const files = []
191
+ for (const rel of gitIn(dir, ['ls-files', '--others', '--exclude-standard']).split('\n').map((s) => s.trim()).filter(Boolean)) {
192
+ if (CARRY_SKIP.includes(rel.split('/')[0])) continue
193
+ const from = join(dir, rel)
194
+ let st
195
+ try { st = statSync(from) } catch { continue }
196
+ if (!st.isFile()) continue
197
+ // refuse loudly rather than carry half the work: the caller turns this
198
+ // into a 409 and the terminal is left alone
199
+ 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`)
200
+ files.push({ rel, data: readFileSync(from) })
201
+ }
202
+ if (!patch.trim() && !files.length) return null
203
+ const names = new Set(status.trim().split('\n').map((l) => l.slice(3).trim()).filter(Boolean))
204
+ for (const f of files) names.add(f.rel)
205
+ return { patch, files, names: [...names] }
206
+ }
207
+
208
+ export function carryUncommitted(dir, carried) {
209
+ if (!carried) return 0
210
+ if (carried.patch.trim()) {
211
+ 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' } })
212
+ if (r.status !== 0) throw new Error(`the tracked changes did not apply: ${String(r.stderr ?? '').trim().slice(0, 200)}`)
213
+ }
214
+ for (const f of carried.files) {
215
+ const to = join(dir, f.rel)
216
+ mkdirSync(dirname(to), { recursive: true })
217
+ writeFileSync(to, f.data)
218
+ }
219
+ return (carried.names ?? []).length
220
+ }
221
+
222
+ // The cards waiting on a human, for the terminals verdict to read (redesign
223
+ // C.5): `card 3e1c has waited on you for 12 minutes.` needs the id, the title,
224
+ // the station and the moment it stopped, so a bare count cannot write the
225
+ // sentence the spec asks for. The oldest one is `first`, because that is the
226
+ // one the sentence names.
227
+ // Cached for a beat: listCards() reads one file per card, and this is computed
228
+ // on every sessions push.
229
+ const CARDS_WAITING_TTL = 5000
230
+ const WAITING_STATUSES = ['needs_approval', 'waiting_human']
231
+ let cardsWaitingCache = { at: 0, data: null }
232
+ function cardsWaiting() {
233
+ if (cardsWaitingCache.data && Date.now() - cardsWaitingCache.at < CARDS_WAITING_TTL) return cardsWaitingCache.data
234
+ let waiting = []
235
+ try { waiting = listCards().filter((c) => WAITING_STATUSES.includes(c.status)) } catch { waiting = [] }
236
+ // `updated_at` is when the card reached this state, which is what "has
237
+ // waited on you for 12 minutes" measures from
238
+ waiting.sort((a, b) => (String(a.updated_at ?? '') < String(b.updated_at ?? '') ? -1 : 1))
239
+ const first = waiting[0] ?? null
240
+ const data = {
241
+ count: waiting.length,
242
+ first: first ? { id: first.card_id, title: first.title ?? null, station: first.station ?? null, since: first.updated_at ?? null } : null,
243
+ }
244
+ cardsWaitingCache = { at: Date.now(), data }
245
+ return data
246
+ }
247
+
248
+ // `events` is the card's ledger, already read by the caller. readEvents() does
249
+ // a readdir, a full readFileSync, a JSON.parse per line and a sort every call,
250
+ // and this function needed it three times per push (the last event, the legs
251
+ // that started at a finished station, and the test/land outcomes of a live
252
+ // card) on the one event loop the board serves every request from. Read once,
253
+ // reused; a caller that has no events passes none and pays for one read.
254
+ export function summarize(card, events = null) {
77
255
  const st = (card.pipeline ?? []).find((s) => s.name === card.station) ?? null
78
- const last = lastEventOf(card.card_id)
256
+ // `cards.map(summarize)` would hand this the array index, so the type is
257
+ // checked rather than the emptiness
258
+ const evs = Array.isArray(events) ? events : readEvents(card.card_id)
259
+ const last = evs.length ? evs[evs.length - 1] : null
79
260
  const runs = readRuns(card.card_id)
80
261
  const activeRun = runs.find((r) => ['launching', 'running'].includes(r.status)) ?? null
81
262
  // Once a station is over (done/failed) card.leg is reset, so the rail is
82
263
  // rebuilt from the legs that actually started at this station.
83
264
  const terminal = ['done', 'failed', 'killed'].includes(card.status)
84
265
  const startedLegs = terminal && st?.kind === 'agent'
85
- ? readEvents(card.card_id).filter((ev) => ev.type === 'leg_started' && ev.station === card.station).map((ev) => ev.leg)
266
+ ? evs.filter((ev) => ev.type === 'leg_started' && ev.station === card.station).map((ev) => ev.leg)
86
267
  : []
87
268
  const lastLeg = startedLegs.length ? Math.max(...startedLegs) : card.leg
88
269
  // card.leg is reset when the station ends, so a finished card read its adapter
@@ -99,12 +280,23 @@ export function summarize(card) {
99
280
  if (i > card.leg) return 'pending'
100
281
  return ['running', 'handing_off'].includes(card.status) ? 'active' : 'pending'
101
282
  }
283
+ // What a live card is carrying right now: the diff it has built, the last
284
+ // test verdict, the last land verdict, and the rung it is on. Measured only
285
+ // for a card that is still going: a finished one is a single line in the
286
+ // ledger, and a git subprocess for each of ten of those buys nothing (C.1).
287
+ const live = NON_TERMINAL.includes(card.status)
288
+ const work = live ? workFor(card) : null
289
+ const outcomes = live ? cardOutcomes(evs) : {}
102
290
  return {
103
291
  ...card,
104
292
  column: columnOf(card),
105
293
  station_kind: st?.kind ?? null,
106
294
  active_adapter: entry?.adapter ?? null,
107
295
  active_mode: entry?.mode ?? null,
296
+ ...(work ? { work } : {}),
297
+ ...(outcomes.tests ? { tests: outcomes.tests } : {}),
298
+ ...(outcomes.land ? { land: outcomes.land } : {}),
299
+ ...(live && entry ? { agent_model: { agent: entry.adapter ?? null, model: entry.model ?? null } } : {}),
108
300
  chain_view: st?.kind === 'agent' ? st.chain.map((e, i) => ({
109
301
  adapter: e.adapter, mode: e.mode ?? null, approve: Boolean(e.approve),
110
302
  // a leg before the last one ended in a handoff (only the last leg can complete a station)
@@ -291,8 +483,18 @@ function trunkFor(repo) {
291
483
  const hit = trunkCache.get(repo)
292
484
  if (hit && Date.now() - hit.at < 15000) return hit.data
293
485
  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 }
486
+ // The branch this repo actually calls its trunk: origin's default if there is
487
+ // one, then the usual names, then whatever this checkout is on. A repo whose
488
+ // default is `develop` used to read as "main" here, and the board's one-line
489
+ // entry then posted a card against a branch that does not exist.
294
490
  let branch = null
295
- for (const b of ['main', 'master', 'trunk']) if (g(['rev-parse', '--verify', '--quiet', b]) !== null) { branch = b; break }
491
+ const originHead = g(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
492
+ if (originHead) {
493
+ const name = originHead.replace(/^origin\//, '')
494
+ if (name && g(['rev-parse', '--verify', '--quiet', `refs/heads/${name}`]) !== null) branch = name
495
+ }
496
+ if (!branch) for (const b of ['main', 'master', 'trunk']) if (g(['rev-parse', '--verify', '--quiet', b]) !== null) { branch = b; break }
497
+ if (!branch) branch = g(['symbolic-ref', '--short', 'HEAD']) || null
296
498
  const log = branch ? g(['log', '--format=%h%x1f%s%x1f%cr%x1f%an', '-6', branch]) : null
297
499
  const data = {
298
500
  repo, repo_name: repo.split(/[\\/]/).filter(Boolean).pop(), branch,
@@ -317,14 +519,28 @@ function withLandings(t, landings) {
317
519
  // What another human sees of a terminal that is not theirs: that it is there,
318
520
  // nothing it has said, read or written. No task, no paths, no file names, no
319
521
  // limit text, no bundle, no events.
522
+ // No usage either. `limits` is the five-hour and seven-day percentage of this
523
+ // machine's login, written onto the record by the poller and by every claude
524
+ // status line, and `warning` carries the same percentage with the clock it
525
+ // resets on. Both are the number the capacity drawer, the accounts payload and
526
+ // guestReason all withhold, so neither may ride out on a row instead
527
+ // (.design/BOARD-DESIGN.md 6.13). The band survives, the figure does not.
320
528
  function redactSession(s) {
321
529
  return {
322
530
  session_id: s.session_id, agent: s.agent, account: s.account, status: s.status, active: s.active,
323
531
  started_at: s.started_at, elapsed_ms: s.elapsed_ms, turns: s.turns, repo_name: s.repo_name, branch: s.branch,
324
- owner: s.owner ?? null, limits: s.limits ?? null, lineage: s.lineage ?? null,
325
- warning: s.warning ? { window: s.warning.window, pct: s.warning.pct, resets_at: s.warning.resets_at } : null,
326
- limit: s.limit ? { reason: s.limit.reason, resets_at: s.limit.resets_at ?? null } : null,
327
- waiting: s.waiting ?? null,
532
+ owner: s.owner ?? null, lineage: s.lineage ?? null,
533
+ warning: s.warning ? { window: s.warning.window } : null,
534
+ limit: s.limit ? { reason: s.limit.reason } : null,
535
+ // `ahead` is owner-only for the same reason `files` is: how far someone
536
+ // else's branch has moved is a fact about their work, and the register
537
+ // prints it beside the dirty count that is already withheld here.
538
+ // `waiting` and `model` are owner-only, and a guest keeps both on their own
539
+ // terminal (that row is not redacted at all). On someone else's row they are
540
+ // dropped: `waiting` carries either the verbatim question an agent asked —
541
+ // the prompt text this function exists to hide — or a reset time, which is
542
+ // this machine's usage data (.design/BOARD-DESIGN.md 6.13); and `model` is
543
+ // which of this machine's model buckets someone else's work is spending.
328
544
  worktree: s.worktree ? { branch: s.worktree.branch, base: s.worktree.base } : null,
329
545
  // the branch is already on the worktree chip: naming it again costs nothing
330
546
  // and is what the board's land line reads
@@ -336,6 +552,40 @@ function redactSession(s) {
336
552
  }
337
553
  }
338
554
 
555
+ // A guest's OWN terminal is not redacted: it is their work. The login it runs
556
+ // on is still this machine's, though, and every figure on the record that was
557
+ // measured from the owner's accounts is the same secret `capacity`, `buckets`
558
+ // and the accounts payload already withhold: the two window percentages
559
+ // (`limits`), the near-wall warning, the reset clock on a limit or an all-out
560
+ // wait, and the name of the reading source. The row keeps every fact about the
561
+ // work and loses every figure about the login (.design/BOARD-DESIGN.md 6.13).
562
+ function scrubOwnerUsage(s) {
563
+ const out = { ...s }
564
+ delete out.limits
565
+ delete out.all_out
566
+ delete out.usage_source
567
+ delete out.usage_error
568
+ // the band survives, the figure and the clock do not: their own row may say
569
+ // it is near a wall, the same way a redacted row does
570
+ if (out.warning) out.warning = { window: out.warning.window }
571
+ if (out.limit) out.limit = { ...out.limit, resets_at: null }
572
+ // a 'reset' wait is a reset time with a sentence around it; the guest still
573
+ // learns that their terminal is waiting for one
574
+ if (out.waiting && out.waiting.type === 'reset') out.waiting = { type: 'reset', since: out.waiting.since ?? null }
575
+ return out
576
+ }
577
+
578
+ // A guest owns their own terminal, so its picker rows are theirs to read, but
579
+ // a rung's reason can quote this machine's usage ("at 63%, not below 80%",
580
+ // "past your 10% reserve"), and a percentage of this machine's login belongs to
581
+ // nobody else (.design/BOARD-DESIGN.md 6.13). Only the reasons that say nothing
582
+ // about how much is left survive the crossing.
583
+ 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'])
584
+ function guestReason(reason) {
585
+ if (!reason) return null
586
+ return GUEST_REASONS.has(reason) ? reason : 'not available right now'
587
+ }
588
+
339
589
  function visibleSessionFile(file) {
340
590
  const value = String(file ?? '')
341
591
  return !value.includes('*** Begin Patch') && !value.includes('*** End Patch')
@@ -343,16 +593,32 @@ function visibleSessionFile(file) {
343
593
 
344
594
  export function sessionsView({ viewer = null, share = null } = {}) {
345
595
  const shared = Boolean(share && shareIsOn(share))
596
+ // Decided before the map below, because the per-session payload has to know
597
+ // it: a guest owns their own terminal and may hand it off, so they get its
598
+ // list of destinations — but a reset time is this machine's usage data and
599
+ // belongs to nobody else, even on a terminal that is theirs.
600
+ const guest = shared && viewer && viewer.role !== 'owner'
346
601
  const list = reapLost(listSessions())
347
602
  const ov = overlaps(list)
348
603
  const configuredAccounts = readAccounts()
604
+ // the spending rules the picker has to print, read once for the whole view
605
+ const prefs = readPreferences()
349
606
  const sessions = list.map((s) => {
350
607
  const land = readLand(s.session_id)
351
608
  const handoffOrder = normalizeHandoffOrder(s.handoff_order)
352
- const chain = candidates({ agent: s.agent, account: s.account, accounts: configuredAccounts, order: handoffOrder })
609
+ // this terminal's own ladder, else the long-hand form of its order
610
+ const handoffLadder = ladderFor(s)
611
+ const from = { agent: s.agent, account: s.account, model: s.model ?? null }
612
+ const chain = candidates({ agent: s.agent, account: s.account, model: s.model ?? null, accounts: configuredAccounts, order: handoffOrder, ladder: handoffLadder })
353
613
  const preferredNext = chain[0] ?? null
354
614
  const availabilityKnown = Boolean(s.installed)
355
- const eligibleNext = availabilityKnown ? (chain.find((next) => s.installed[next.agent] !== false && isAvailable(readUsage(next.agent, next.account))) ?? null) : null
615
+ // one pass, the same one the chooser makes, so a greyed row in the picker
616
+ // and the rung an automatic hand-off would take can never disagree. The
617
+ // picker is a human pressing a button, so the reserve is a note here, not
618
+ // a refusal (B.3).
619
+ 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 })
620
+ const open = availabilityKnown ? rungs.find((r) => r.ok) : null
621
+ const eligibleNext = open ? { agent: open.agent, account: open.account, ...(open.model ? { model: open.model } : {}) } : null
356
622
  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 }] }
357
623
  return {
358
624
  ...s,
@@ -360,7 +626,41 @@ export function sessionsView({ viewer = null, share = null } = {}) {
360
626
  chain,
361
627
  preferred_next: preferredNext,
362
628
  eligible_next: eligibleNext,
629
+ // every destination this terminal could be handed to, each with the
630
+ // reason it cannot be picked right now. The board's picker renders this
631
+ // list directly, so a greyed option always carries its own explanation.
632
+ // one row per RUNG now: `claude/opus` and `claude/sonnet` are separate
633
+ // destinations, each with what it costs, whether it keeps the
634
+ // conversation, and the reason it cannot (or should not) be picked.
635
+ handoff_targets: rungs.map((r) => ({
636
+ agent: r.agent,
637
+ account: r.account,
638
+ model: r.model ?? null,
639
+ available: r.ok,
640
+ // A row that CAN be picked has nothing to explain: the reserve and the
641
+ // `below:N` rules come back as a note on an ok row (usage.mjs), and
642
+ // generalising that note reads as a refusal beside a button that works.
643
+ // A row that is blocked keeps a reason a guest may read.
644
+ reason: guest ? (r.ok ? null : guestReason(r.reason)) : r.reason,
645
+ resets_at: !guest ? (r.resets_at ?? null) : null,
646
+ // the probe in fixtures/live/claude/resume-model-probe.json: a claude
647
+ // downshift resumes the same conversation; everything else is primed
648
+ // from the bundle, codex included until its own resume is observed
649
+ keeps_conversation: Boolean(isDownshift(from, r) && r.agent === 'claude' && s.agent_session_id),
650
+ // the cost word is not static: `credits` on a claude/fable rung means
651
+ // this machine's login has usage credits switched on (preferences.mjs
652
+ // rungCost reads extra_usage.enabled), which is a fact about the
653
+ // owner's account that the accounts payload drops on purpose. A guest
654
+ // gets the row and not the word.
655
+ ...(guest ? {} : { cost: r.cost }),
656
+ })),
363
657
  handoff_availability_known: availabilityKnown,
658
+ handoff_ladder: handoffLadder,
659
+ // the bucket that will actually stop this terminal, computed per request
660
+ // and never persisted: it depends on the model the row is running, and
661
+ // the record only knows the login. A guest never gets it: it is a
662
+ // percentage of this machine's usage (.design/BOARD-DESIGN.md 6.13).
663
+ ...(guest ? {} : { capacity: binding(readUsage(s.agent, s.account), s.model ?? null) }),
364
664
  can_edit_handoff_order: s.runtime_capabilities?.includes(HANDOFF_ORDER_CAPABILITY) ?? false,
365
665
  active: isActive(s),
366
666
  has_synthesis: hasRecentSynthesis(s),
@@ -379,7 +679,11 @@ export function sessionsView({ viewer = null, share = null } = {}) {
379
679
  const accounts = []
380
680
  for (const agent of Object.keys(configuredAccounts)) for (const account of configuredAccounts[agent]) {
381
681
  const u = readUsage(agent, account)
382
- 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 })
682
+ // buckets, walls, extra_usage and facts are owner-only for the same reason
683
+ // the percentages are: they say how much of this machine's login is gone.
684
+ // The guest branch at the bottom of this function drops the slot to
685
+ // {agent, account, live, shared}, so nothing here reaches them.
686
+ 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 })
383
687
  }
384
688
  const repos = new Map()
385
689
  for (const s of sessions) if (s.repo && (s.active || s.worktree) && !repos.has(canonPath(s.repo))) repos.set(canonPath(s.repo), s.repo)
@@ -387,9 +691,12 @@ export function sessionsView({ viewer = null, share = null } = {}) {
387
691
  const canon = new Map()
388
692
  const landingsFor = (key) => landings.filter((l) => { if (!canon.has(l.repo)) canon.set(l.repo, canonPath(l.repo)); return canon.get(l.repo) === key })
389
693
  const trunk = [...repos].map(([key, r]) => { try { return withLandings(trunkFor(r), landingsFor(key)) } catch { return { repo: r, commits: [] } } })
390
- const guest = shared && viewer && viewer.role !== 'owner'
391
694
  const mine = (s) => !shared || !viewer || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
392
- const shown = sessions.map((s) => (mine(s) ? { ...s, requests: readRequests(s.session_id).filter((r) => r.state === 'pending') } : redactSession(s)))
695
+ const shown = sessions.map((s) => {
696
+ if (!mine(s)) return redactSession(s)
697
+ const row = { ...s, requests: readRequests(s.session_id).filter((r) => r.state === 'pending') }
698
+ return guest ? scrubOwnerUsage(row) : row
699
+ })
393
700
  return {
394
701
  sessions: shown,
395
702
  // a guest sees which accounts exist and which are busy, never how much of
@@ -400,6 +707,12 @@ export function sessionsView({ viewer = null, share = null } = {}) {
400
707
  // a guest sees what landed, not where the repo lives on this machine
401
708
  trunk: guest ? trunk.map((t) => ({ repo_name: t.repo_name, branch: t.branch, commits: t.commits ?? [] })) : trunk,
402
709
  you: viewer,
710
+ // the terminals verdict has to be able to say "card 3e1c has waited on you
711
+ // for 12 minutes" without reading the whole pipeline board (redesign C.5).
712
+ // A guest has no cards at all; an operator runs them, approves them and is
713
+ // exactly the human one can be waiting on, so the gate is the cards
714
+ // permission the /api/cards routes use, not the owner flag.
715
+ ...(!shared || mayUseCards(viewer?.role ?? 'owner') ? { cards_waiting: cardsWaiting() } : {}),
403
716
  share: { on: shared, bind: shared ? share.bind : null, people: shared ? share.people.length : 0 },
404
717
  preferences: guest ? null : readPreferences(),
405
718
  ts: new Date().toISOString(),
@@ -461,14 +774,15 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, sessionsDebounce
461
774
  // resets the count the first is reading from.
462
775
  const refreshCard = (id) => {
463
776
  const card = readCard(id)
464
- // pipeline cards and their events are the owner's: a guest never gets them
465
- const forOwner = (payload) => (viewer) => (viewer && viewer.role !== 'owner' ? null : payload)
777
+ // pipeline cards and their events belong to the people who may run them:
778
+ // the owner and any operator. A guest never gets them.
779
+ const forOwner = (payload) => (viewer) => (viewer && !mayUseCards(viewer.role) ? null : payload)
466
780
  if (!card) { for (const c of clients) c.sig.delete(id); broadcast('removed', forOwner({ card_id: id })); return }
467
781
  const events = readEvents(id)
468
782
  // broadcast() refreshes each client's viewer (and drops revoked ones) first
469
- broadcast('card', forOwner(summarize(card)))
783
+ broadcast('card', forOwner(summarize(card, events)))
470
784
  for (const c of [...clients]) {
471
- if (!c.viewer || c.viewer.role !== 'owner') { c.sig.set(id, events.length); continue }
785
+ if (!c.viewer || !mayUseCards(c.viewer.role)) { c.sig.set(id, events.length); continue }
472
786
  const from = c.sig.get(id) ?? 0
473
787
  c.sig.set(id, events.length)
474
788
  for (const e of events.slice(from)) { try { c.res.write(`event: event\ndata: ${JSON.stringify(e)}\n\n`) } catch {} }
@@ -613,7 +927,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
613
927
  }
614
928
  const limiter = createLimiter()
615
929
  const viewFor = (viewer, sh) => sessionsView({ viewer, share: sh ?? currentShare() })
616
- const forOwner = (payload) => (viewer) => (viewer && viewer.role !== 'owner' ? null : payload)
930
+ const forOwner = (payload) => (viewer) => (viewer && !mayUseCards(viewer.role) ? null : payload)
617
931
  // SSE re-identifies each client from the live roster on every push
618
932
  const reauthClient = (c) => {
619
933
  const sh = currentShare()
@@ -633,6 +947,49 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
633
947
  return null
634
948
  }
635
949
  const sse = createSse({ viewFor, reauth: reauthClient })
950
+ // A card that adopted a live terminal's checkout waits in the backlog until
951
+ // that terminal has really stopped. `end` is a REQUEST: src/attach.mjs reads
952
+ // control.json on its own poll (2s by default) and kills the child at the
953
+ // next tick, while the scheduler ticks every second, so a card queued here
954
+ // would put a headless agent in the same working tree as the interactive one
955
+ // for at least a tick, and longer if the agent is mid-turn. The session
956
+ // record is how the board learns a terminal ended (attach writes `ended`,
957
+ // sessions.mjs reaps a lost one), so that is what this waits on.
958
+ 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))
959
+ 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))
960
+ const enqueueTimers = new Map()
961
+ function enqueueWhenSessionEnds(cardId, sessionId, who) {
962
+ const from = Date.now()
963
+ const arm = () => {
964
+ const t = setTimeout(tick, ENQUEUE_POLL_MS)
965
+ t.unref?.()
966
+ enqueueTimers.set(cardId, t)
967
+ }
968
+ const tick = () => {
969
+ enqueueTimers.delete(cardId)
970
+ const card = readCard(cardId)
971
+ // killed, removed, or started by hand: it is not this timer's any more
972
+ if (!card || card.status !== 'backlog') return
973
+ const s = readSession(sessionId)
974
+ if (!s || !isActive(s)) {
975
+ try {
976
+ const next = humanAction(cardId, 'enqueue', {}, who)
977
+ log(`end-as-card ${cardId}: terminal ${sessionId} has stopped, the card is queued`)
978
+ sse.broadcast('card', forOwner(summarize(next)))
979
+ } catch (err) { log(`end-as-card ${cardId}: ${err.message}`) }
980
+ return
981
+ }
982
+ if (Date.now() - from > ENQUEUE_WAIT_MS) {
983
+ // never start it behind the human's back after a long wait: say why it
984
+ // is sitting there and leave Run to them
985
+ 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 */ }
986
+ log(`end-as-card ${cardId}: terminal ${sessionId} still active after ${Math.round(ENQUEUE_WAIT_MS / 1000)}s, left in the backlog`)
987
+ return
988
+ }
989
+ arm()
990
+ }
991
+ arm()
992
+ }
636
993
  let sched = null
637
994
  let usageTimer = null
638
995
  let usageController = null
@@ -688,30 +1045,35 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
688
1045
  const rl = limiter.request(auth.person ? viewer.name : ip)
689
1046
  if (!rl.ok) return send(res, 429, { error: `rate limit: more than ${limiter.max} requests a minute` }, { 'Retry-After': String(rl.retry_after) })
690
1047
  const actor = { type: 'human', id: viewer.name }
691
- // a guest sees the terminals lane, read-only; the pipeline side is the owner's
692
- const guest = shared && viewer.role !== 'owner'
1048
+ // What this viewer may reach, decided once from their role (src/share.mjs).
1049
+ // `canCards` is the pipeline board: cards, the floor, the adapters and the
1050
+ // leases, which an operator runs. `canMachine` is everything that describes
1051
+ // this computer rather than the work — the settings, the trunk's repo
1052
+ // paths, the history index, the worktree map — and stays the owner's.
1053
+ const canCards = !shared || mayUseCards(viewer.role)
1054
+ const canMachine = !shared || mayUseMachine(viewer.role)
693
1055
  const ownsSession = (s) => !shared || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
694
1056
  const parts = path.split('/').filter(Boolean) // ['api', ...]
695
- // history and worktrees are the whole machine's project map (every path,
696
- // every conversation on it): the owner's, never a guest's, as a group
697
- if (guest && ['cards', 'floor', 'presets', 'adapters', 'leases', 'trunk', 'history', 'worktrees'].includes(parts[1])) return send(res, 403, { error: 'the pipeline board belongs to the owner of this machine' })
1057
+ 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' })
1058
+ if (!canMachine && ['trunk', 'history', 'worktrees', 'audit'].includes(parts[1])) return send(res, 403, { error: 'this is the map of the machine itself: every repository path and every conversation on it. It belongs to the owner of this machine.' })
698
1059
  try {
699
1060
  if (req.method === 'GET' && path === '/api/health') {
700
1061
  const you = { ...viewer, share: { on: shared, people: shared ? share.people.length : 0 } }
701
- if (guest) return send(res, 200, { ok: true, version: VERSION, you })
1062
+ if (!canCards) return send(res, 200, { ok: true, version: VERSION, you })
702
1063
  const cards = listCards()
703
- return send(res, 200, { ok: true, pid: process.pid, version: VERSION, bind, port, home: home(), you, scheduler: { ...schedulerStatus(), in_process: Boolean(sched), max_concurrent: MAX_CONCURRENT }, tools: await detectTools(), columns: columnsFor(cards), cards: cards.length })
1064
+ return send(res, 200, { ok: true, pid: process.pid, version: VERSION, bind, port, home: canMachine ? home() : null, you, scheduler: { ...schedulerStatus(), in_process: Boolean(sched), max_concurrent: MAX_CONCURRENT }, tools: await detectTools(), columns: columnsFor(cards), cards: cards.length })
704
1065
  }
705
1066
  if (req.method === 'GET' && path === '/api/adapters') return send(res, 200, { adapters: await adaptersInfo() })
706
1067
  if (req.method === 'GET' && path === '/api/presets') return send(res, 200, { presets: PRESETS })
707
1068
  if (req.method === 'GET' && path === '/api/cards') {
708
1069
  const cards = listCards()
709
- return send(res, 200, { columns: columnsFor(cards), cards: cards.map(summarize) })
1070
+ return send(res, 200, { columns: columnsFor(cards), cards: cards.map((c) => summarize(c)) })
710
1071
  }
711
1072
  if (req.method === 'POST' && path === '/api/cards') {
712
1073
  const body = await readBody(req)
713
1074
  try {
714
- const card = await createCard(body, actor)
1075
+ // a pipeline that names a file is a CLI flag, never a request body
1076
+ const card = await createCard(body, actor, { allowPipelineFile: false })
715
1077
  sse.broadcast('card', forOwner(summarize(card)))
716
1078
  return send(res, 201, { card: summarize(card) })
717
1079
  } catch (err) {
@@ -721,20 +1083,29 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
721
1083
  }
722
1084
  if (req.method === 'GET' && path === '/api/events') {
723
1085
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' })
724
- const cards = guest ? [] : listCards()
725
- res.write(`event: hello\ndata: ${JSON.stringify({ columns: columnsFor(cards), cards: cards.map(summarize), sessions: viewFor(viewer), ts: new Date().toISOString() })}\n\n`)
1086
+ const cards = canCards ? listCards() : []
1087
+ 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`)
726
1088
  sse.add(res, cards, viewer, { token: presentedToken(req, url), loopback: isLoopbackRequest(req) })
727
1089
  return
728
1090
  }
729
1091
  if (req.method === 'GET' && path === '/api/sessions') return send(res, 200, viewFor(viewer))
730
1092
  if (path === '/api/settings') {
731
- if (guest) return send(res, 403, { error: 'the machine settings belong to the owner of this board' })
1093
+ if (!canMachine) return send(res, 403, { error: 'the machine settings belong to the owner of this board' })
732
1094
  if (req.method === 'GET') return send(res, 200, { preferences: readPreferences() })
733
1095
  if (req.method === 'POST' || req.method === 'PATCH') {
734
1096
  const body = await readBody(req)
735
1097
  try {
736
1098
  const patch = {}
737
1099
  if (body.handoff_order !== undefined) patch.handoff_order = requireHandoffOrder(body.handoff_order)
1100
+ // the ladder and the rules around it (B.3). `handoff_order` is
1101
+ // rewritten from the ladder inside writePreferences, so the two
1102
+ // keys on disk can never disagree.
1103
+ if (body.handoff_ladder !== undefined) patch.handoff_ladder = requireHandoffLadder(body.handoff_ladder)
1104
+ if (body.climb_back !== undefined) patch.climb_back = requireClimbBack(body.climb_back)
1105
+ if (body.may_spend !== undefined) patch.may_spend = Boolean(body.may_spend)
1106
+ if (body.reserve !== undefined) patch.reserve = requireReserve(body.reserve)
1107
+ if (body.notify_terminal !== undefined) patch.notify_terminal = Boolean(body.notify_terminal)
1108
+ if (body.notify_board !== undefined) patch.notify_board = Boolean(body.notify_board)
738
1109
  if (body.harness !== undefined) {
739
1110
  // the board may narrow the policy or turn the feature off; turning
740
1111
  // it on is the first-run consent flow, which shows what will be
@@ -745,7 +1116,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
745
1116
  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}` })
746
1117
  patch.harness = { policy: body.harness?.policy, enabled: body.harness?.enabled === false ? false : undefined }
747
1118
  }
748
- if (!Object.keys(patch).length) return send(res, 400, { error: 'nothing to change: send handoff_order or harness' })
1119
+ 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' })
749
1120
  const preferences = writePreferences(patch)
750
1121
  sse.broadcast('sessions', (v) => viewFor(v))
751
1122
  return send(res, 200, { preferences })
@@ -816,7 +1187,13 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
816
1187
  }
817
1188
  const body = await readBody(req)
818
1189
  try {
819
- const order = requireHandoffOrder(body.handoff_order)
1190
+ // one route, two shapes: the old list of agents, and the ladder of
1191
+ // rungs that replaced it. Sending either rewrites the other, the
1192
+ // same way preferences.json keeps them in step.
1193
+ const ladder = body.handoff_ladder !== undefined ? requireHandoffLadder(body.handoff_ladder) : null
1194
+ const order = ladder ? orderFromLadder(ladder) : requireHandoffOrder(body.handoff_order)
1195
+ const rungs = ladder ?? ladderFor({ handoff_order: order })
1196
+ const summary = ladder ? `handoff ladder changed to ${rungs.map((r) => rungLabel(r)).join(' → ')}` : `handoff order changed to ${order.join(' → ')}`
820
1197
  const next = updateSession(id, (current) => {
821
1198
  if (!['starting', 'running', 'warning', 'limit', 'waiting'].includes(current.status)) {
822
1199
  const conflict = new Error(`handoff order cannot change while this terminal is ${current.status}`)
@@ -825,9 +1202,10 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
825
1202
  }
826
1203
  return {
827
1204
  handoff_order: order,
828
- chain: candidates({ agent: current.agent, account: current.account, accounts: readAccounts(), order }),
1205
+ handoff_ladder: rungs,
1206
+ chain: candidates({ agent: current.agent, account: current.account, model: current.model ?? null, accounts: readAccounts(), order, ladder: rungs }),
829
1207
  }
830
- }, { event: { type: 'status', by: actor.id, summary: `handoff order changed to ${order.join(' → ')}` } })
1208
+ }, { event: { type: 'status', by: actor.id, summary } })
831
1209
  sse.broadcast('sessions', (v) => viewFor(v))
832
1210
  return send(res, 200, { session: next })
833
1211
  } catch (err) {
@@ -863,11 +1241,135 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
863
1241
  log(`land requested for ${id} by ${actor.id}`)
864
1242
  return send(res, 202, { ok: true, requested: 'land' })
865
1243
  }
1244
+ // "I have to leave, keep going." The terminal's context becomes a card
1245
+ // that continues from the SAME rung, and the terminal then ends exactly
1246
+ // the way End ends it. Where the card works depends on what the
1247
+ // terminal had:
1248
+ // - its own worktree: the card adopts it (redesign G4; two worktrees
1249
+ // on one branch is the conflict machine the roadmap rejects) and
1250
+ // waits in the BACKLOG until the terminal has really stopped, since
1251
+ // `end` is a request the runner reads on its own poll and the
1252
+ // scheduler ticks once a second.
1253
+ // - no worktree of its own (the ordinary case): a checkout of its own
1254
+ // is cut from that branch and the uncommitted work is carried into
1255
+ // it, so the card continues from what the human was looking at
1256
+ // rather than from the last commit.
1257
+ if (req.method === 'POST' && parts[3] === 'end-as-card') {
1258
+ // a card is the pipeline board, which belongs to the owner and the
1259
+ // operators of this machine even when the terminal is the caller's
1260
+ if (!canCards) return send(res, 403, { error: 'the pipeline board belongs to the owner and the operators of this machine' })
1261
+ if (!isActive(sess)) return send(res, 409, { error: `session ${id} is not active` })
1262
+ 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' })
1263
+ let bundle
1264
+ try {
1265
+ bundle = saveSessionBundle(sess, { messages: sessionMessages(sess), why: 'ended as a card' })
1266
+ } catch (err) {
1267
+ // nothing has been ended yet: refuse rather than end a terminal
1268
+ // whose context was never written down
1269
+ 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)}` })
1270
+ }
1271
+ const rungs = ladderFromCurrentRung(sess)
1272
+ // one model per adapter is all a card's chain can carry, so a ladder
1273
+ // with claude/fable and claude/opus in it keeps the FIRST, which is
1274
+ // the rung this terminal is on
1275
+ const models = {}
1276
+ for (const r of rungs) if (r.model && !models[r.agent]) models[r.agent] = r.model
1277
+ const task = `${sess.task ?? 'Continue the work already under way in this checkout.'}\n\nContinue from the bundle at ${bundle.path}.`
1278
+ const adopted = sess.worktree?.path && existsSync(sess.worktree.path) ? sess.worktree.path : null
1279
+ const trunkBranch = sess.worktree?.base || sess.branch || 'main'
1280
+ // The checkout the human has been working in. Read BEFORE anything is
1281
+ // created or ended: if the work cannot be read, nothing has happened
1282
+ // yet and the terminal is left exactly as it was.
1283
+ // the repository root, which is what `git status --porcelain`,
1284
+ // `git diff` and `git ls-files` all report paths against, so the
1285
+ // patch and the file list line up with the new checkout's root
1286
+ const workRoot = adopted ?? ((sess.repo && existsSync(sess.repo)) ? sess.repo : sess.cwd)
1287
+ let carried = null
1288
+ if (!adopted) {
1289
+ try { carried = captureUncommitted(workRoot) } catch (err) {
1290
+ 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)}` })
1291
+ }
1292
+ }
1293
+ let card
1294
+ try {
1295
+ card = await createCard({
1296
+ repo: sess.repo, task,
1297
+ chain: rungs.map((r) => r.agent).join(',') || sess.agent,
1298
+ model: models,
1299
+ trunk: trunkBranch,
1300
+ title: sess.task ? String(sess.task).slice(0, 60) : `continued from ${id}`,
1301
+ // never queued here: the card is enqueued below, once its
1302
+ // checkout is its own and nothing else is writing to it
1303
+ queue: false,
1304
+ }, actor)
1305
+ } catch (err) {
1306
+ if (err instanceof CardInputError) return send(res, 400, { error: `the card could not be created, so this terminal was left alone: ${err.message}` })
1307
+ throw err
1308
+ }
1309
+ let carriedFiles = 0
1310
+ if (adopted) {
1311
+ ledgerUpdate(card.card_id, { patch: { lineage: { from: id }, worktree: adopted, worktree_adopted: true, worktree_branch: sess.worktree?.branch ?? null } })
1312
+ } else {
1313
+ try {
1314
+ const wt = ensureWorktree(sess.repo, card.card_id, { trunk: trunkBranch })
1315
+ carriedFiles = carryUncommitted(wt.path, carried)
1316
+ ledgerUpdate(card.card_id, { patch: { lineage: { from: id }, worktree: wt.path, worktree_branch: wt.branch } })
1317
+ } catch (err) {
1318
+ // nothing has been ended and nothing has been queued: take the
1319
+ // half-made card off the board rather than leave it there
1320
+ try { removeWorktree(sess.repo, card.card_id, { force: true }) } catch { /* it may never have been cut */ }
1321
+ try { rmSync(cardDir(card.card_id), { recursive: true, force: true }) } catch { /* the board never saw it */ }
1322
+ 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)}` })
1323
+ }
1324
+ }
1325
+ // `handoff_written` is the ledger's word for "a bundle was written and
1326
+ // the work moved on", which is exactly what happened here. The audited
1327
+ // line with the actor is on the terminal's side, below.
1328
+ const where = adopted
1329
+ ? ', in the terminal\'s own worktree, once that terminal has stopped'
1330
+ : `, in a worktree of its own cut from ${trunkBranch}${carriedFiles ? `, carrying ${carriedFiles} uncommitted file(s) over` : ''}`
1331
+ ledgerAppend(card.card_id, { actor, type: 'handoff_written', summary: `continued from terminal ${id}${where}` })
1332
+ requestControl(id, { end: true, by: actor.id })
1333
+ appendSessionEvent(id, { type: 'handed_off', by: actor.id, summary: `${actor.id} ended this terminal and kept it going as card ${card.card_id}` })
1334
+ updateSession(id, (cur) => ({ lineage: { ...(cur.lineage ?? {}), to: card.card_id } }))
1335
+ if (adopted) enqueueWhenSessionEnds(card.card_id, id, actor)
1336
+ else {
1337
+ try { humanAction(card.card_id, 'enqueue', {}, actor) } catch (err) { log(`end-as-card ${card.card_id}: ${err.message}`) }
1338
+ }
1339
+ 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` : ''}`}`)
1340
+ const next = summarize(readCard(card.card_id))
1341
+ sse.broadcast('card', forOwner(next))
1342
+ sse.broadcast('sessions', (v) => viewFor(v))
1343
+ return send(res, 201, { card: next, bundle: { id: bundle.id, path: bundle.path }, carried: { files: carriedFiles, adopted: Boolean(adopted) } })
1344
+ }
866
1345
  if (req.method === 'POST' && (parts[3] === 'handoff' || parts[3] === 'end')) {
867
1346
  if (!isActive(sess)) return send(res, 409, { error: `session ${id} is not active` })
868
- requestControl(id, parts[3] === 'handoff' ? { handoff: true, by: actor.id } : { end: true, by: actor.id })
869
- log(`${parts[3]} requested for ${id} by ${actor.id}`)
870
- return send(res, 200, { ok: true, requested: parts[3] })
1347
+ if (parts[3] === 'end') {
1348
+ requestControl(id, { end: true, by: actor.id })
1349
+ log(`end requested for ${id} by ${actor.id}`)
1350
+ return send(res, 200, { ok: true, requested: 'end' })
1351
+ }
1352
+ // Hand off now, optionally to a named destination. With no body the
1353
+ // chain decides, exactly as it did before the picker existed.
1354
+ const body = await readBody(req)
1355
+ let target = null
1356
+ if (body && body.agent !== undefined && body.agent !== null && body.agent !== '') {
1357
+ const want = { agent: String(body.agent), account: String(body.account ?? 'default'), model: body.model ? String(body.model).toLowerCase() : null }
1358
+ const order = normalizeHandoffOrder(sess.handoff_order)
1359
+ const ladder = ladderFor(sess)
1360
+ const chain = candidates({ agent: sess.agent, account: sess.account, model: sess.model ?? null, accounts: readAccounts(), order, ladder })
1361
+ const hit = chain.find((c) => c.agent === want.agent && c.account === want.account && (want.model ? (c.model ?? null) === want.model : true))
1362
+ const label = rungLabel(want)
1363
+ if (!hit) return send(res, 400, { error: `${label} is not a destination for this terminal (${chain.map((c) => rungLabel(c)).join(', ') || 'none'})` })
1364
+ if (sess.installed && sess.installed[want.agent] === false) return send(res, 409, { error: `${label} is not installed on this machine` })
1365
+ const u = readUsage(want.agent, want.account)
1366
+ 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` })
1367
+ 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` })
1368
+ target = { agent: hit.agent, account: hit.account, ...(hit.model ? { model: hit.model } : {}) }
1369
+ }
1370
+ requestControl(id, target ? { handoff: true, target, by: actor.id } : { handoff: true, by: actor.id })
1371
+ log(`handoff requested for ${id} by ${actor.id}${target ? ` to ${rungLabel(target)}` : ''}`)
1372
+ return send(res, 200, { ok: true, requested: 'handoff', target })
871
1373
  }
872
1374
  if (req.method === 'DELETE' && parts.length === 3) {
873
1375
  if (isActive(sess)) return send(res, 409, { error: 'end the session before removing it' })
@@ -944,6 +1446,18 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
944
1446
  const q = url.searchParams
945
1447
  return send(res, 200, worktreesFor({ repo: q.get('repo') || null, dirty: q.get('dirty') !== '0' }))
946
1448
  }
1449
+ if (req.method === 'GET' && path === '/api/audit') {
1450
+ const q = url.searchParams
1451
+ const kind = q.get('kind')
1452
+ if (kind && !ACTOR_KINDS.includes(kind)) return send(res, 400, { error: `kind is one of ${ACTOR_KINDS.join(', ')}` })
1453
+ const limit = parseInt(q.get('limit') ?? '200', 10)
1454
+ return send(res, 200, auditTrail({
1455
+ limit: Number.isFinite(limit) ? limit : 200,
1456
+ since: q.get('since'),
1457
+ who: q.get('who'),
1458
+ kind,
1459
+ }))
1460
+ }
947
1461
  if (req.method === 'GET' && path === '/api/floor') return send(res, 200, floor(listCards()))
948
1462
  if (req.method === 'GET' && path === '/api/trunk') return send(res, 200, trunk(listCards(), parseSince(url.searchParams.get('since'))))
949
1463
  if (req.method === 'GET' && path === '/api/leases') return send(res, 200, { leases: held(listCards()) })
@@ -980,6 +1494,48 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
980
1494
  sse.broadcast('removed', forOwner({ card_id: id }))
981
1495
  return send(res, 200, { removed: id })
982
1496
  }
1497
+ // Take over: sit down in the card's worktree yourself. The card is
1498
+ // paused first (its child is killed and its bundle written by the
1499
+ // existing transition), then Leg hands back the one command that opens
1500
+ // a terminal there. This is the only command the board ever hands a
1501
+ // human, because a browser tab cannot open one (redesign C.4).
1502
+ if (req.method === 'POST' && parts[3] === 'take-over') {
1503
+ 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.` })
1504
+ const raw = String(summarize(card).active_adapter ?? '')
1505
+ const agent = [raw, raw.replace(/^fake-/, '')].find((n) => SUPERVISED_AGENTS.includes(n)) ?? null
1506
+ 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(', ')})` })
1507
+ // Every non-terminal status moves to `paused` before the command is
1508
+ // handed back, not just `running`. A `queued` or `handing_off` card
1509
+ // is in the set the scheduler starts from, and it ticks once a
1510
+ // second: the human would paste this command into a worktree an
1511
+ // agent had just been launched in, which is the collision Take over
1512
+ // exists to prevent. The transition also writes the actor's own
1513
+ // `taken_over` line, so the audit trail names who has the checkout.
1514
+ let next
1515
+ try { next = humanAction(id, 'take_over', {}, actor) } catch (err) {
1516
+ if (err instanceof IllegalTransition) return send(res, 409, { error: err.message })
1517
+ throw err
1518
+ }
1519
+ // A card that never ran has no checkout of its own, and the terminal
1520
+ // that takes it over must never open in the human's main checkout
1521
+ // (src/attach.mjs cardWorkRoot refuses that). Cut its worktree now, on
1522
+ // the trunk the card would have used, so the command below has a
1523
+ // place to open.
1524
+ const cur = readCard(id) ?? next
1525
+ if (!(cur.worktree && existsSync(cur.worktree)) && cur.repo) {
1526
+ try {
1527
+ const wt = ensureWorktree(cur.repo, id, { trunk: cur.trunk || trunkFor(cur.repo).branch || 'main' })
1528
+ ledgerUpdate(id, { patch: { worktree: wt.path, worktree_branch: wt.branch } })
1529
+ } catch (err) {
1530
+ return send(res, 409, { error: `could not cut a checkout for ${id}: ${err.message}` })
1531
+ }
1532
+ }
1533
+ const command = `leg ${agent} --resume-card ${id}`
1534
+ log(`take-over for ${id} by ${actor.id}: ${command}`)
1535
+ const view = summarize(readCard(id) ?? next)
1536
+ sse.broadcast('card', forOwner(view))
1537
+ return send(res, 200, { card: view, command })
1538
+ }
983
1539
  if (req.method === 'POST' && parts[3]) {
984
1540
  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' }
985
1541
  const action = map[parts[3]]
@@ -1004,21 +1560,28 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
1004
1560
  }
1005
1561
 
1006
1562
  const onReq = (req, res) => { handle(req, res).catch((err) => { try { send(res, 500, { error: scrub(err.message) }) } catch {} }) }
1007
- const server = http.createServer(onReq)
1563
+ // TLS when a certificate pair is configured (leg share on --tls-cert/--tls-key,
1564
+ // or LEG_TLS_CERT/LEG_TLS_KEY). readTls throws rather than fall back to
1565
+ // plaintext: a board told to use TLS and quietly serving http would be the
1566
+ // worst outcome of the three.
1567
+ const tls = readTls(initialShare)
1568
+ const server = tls ? https.createServer({ cert: tls.cert, key: tls.key }, onReq) : http.createServer(onReq)
1008
1569
  // When the board is bound to a non-loopback address (share on), also listen on
1009
1570
  // 127.0.0.1 so the machine's own browser has a tokenless owner URL — a real
1010
- // remote peer's address is never loopback, so it still needs a token.
1571
+ // remote peer's address is never loopback, so it still needs a token. That one
1572
+ // stays plain http even under TLS: the certificate is for the shared name, and
1573
+ // loopback traffic never leaves this machine.
1011
1574
  const loopbackCompanion = !isLoopback(bind) ? http.createServer(onReq) : null
1012
1575
 
1013
1576
  return {
1014
1577
  server,
1015
- bind, port,
1578
+ bind, port, tls: tls ? { cert_path: tls.cert_path, key_path: tls.key_path } : null,
1016
1579
  start() {
1017
1580
  return new Promise((resolvePromise, reject) => {
1018
1581
  server.once('error', reject)
1019
1582
  server.listen(port, bind, () => {
1020
1583
  const addr = server.address()
1021
- log(`listening on http://${bind}:${addr.port} (home ${home()}${token ? ', token required' : ', loopback open'})`)
1584
+ log(`listening on ${tls ? 'https' : 'http'}://${bind}:${addr.port} (home ${home()}${token ? ', token required' : ', loopback open'}${tls ? `, TLS from ${tls.cert_path}` : ''})`)
1022
1585
  // A terminal that crashed instead of exiting left its hand-off in
1023
1586
  // .leg/RESUME.md looking live. The board is the thing that starts
1024
1587
  // after a crash, so it is where that gets corrected.
@@ -1048,6 +1611,8 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
1048
1611
  async stop() {
1049
1612
  if (usageTimer) { clearInterval(usageTimer); usageTimer = null }
1050
1613
  usageController?.abort()
1614
+ for (const t of enqueueTimers.values()) clearTimeout(t)
1615
+ enqueueTimers.clear()
1051
1616
  sse.stop()
1052
1617
  if (sched) sched.stop()
1053
1618
  if (loopbackCompanion) await new Promise((r) => { loopbackCompanion.closeAllConnections?.(); loopbackCompanion.close(() => r()) })