@ucsandman/legcli 0.11.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 (56) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +95 -65
  3. package/bin/leg.mjs +123 -14
  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 +41 -0
  8. package/docs/ROADMAP-v2.md +50 -5
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/board-guide.md +401 -66
  11. package/docs/cli-contracts.md +199 -5
  12. package/docs/concepts.md +167 -19
  13. package/docs/configuration.md +58 -0
  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/settings-ladder-1280.png +0 -0
  25. package/docs/screenshots/terminals-1280.png +0 -0
  26. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  27. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  28. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  29. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  30. package/fixtures/live/claude/resume-model-probe.json +20 -0
  31. package/fixtures/live/claude/usage-oauth.json +87 -0
  32. package/fixtures/verified.json +1 -1
  33. package/package.json +3 -2
  34. package/scripts/seed-fake-cards.mjs +59 -6
  35. package/scripts/seed-wes-board.mjs +81 -12
  36. package/src/accounts.mjs +6 -1
  37. package/src/attach.mjs +328 -46
  38. package/src/audit.mjs +1 -1
  39. package/src/board/board.css +119 -9
  40. package/src/board/board.js +482 -106
  41. package/src/board/index.html +67 -7
  42. package/src/board/sessions.js +1347 -117
  43. package/src/buckets.mjs +101 -0
  44. package/src/cards.mjs +9 -1
  45. package/src/chain.mjs +13 -0
  46. package/src/hook.mjs +7 -1
  47. package/src/ledger.mjs +10 -2
  48. package/src/orchestrator.mjs +13 -4
  49. package/src/preferences.mjs +214 -5
  50. package/src/scheduler.mjs +24 -1
  51. package/src/server.mjs +546 -47
  52. package/src/sessions.mjs +17 -1
  53. package/src/taps/claude-usage.mjs +91 -2
  54. package/src/taps/claude.mjs +144 -5
  55. package/src/taps/codex.mjs +23 -3
  56. package/src/usage.mjs +407 -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,29 @@ 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'
36
+ import { readUsage, recordUsage, usageIsStale, candidates, isAvailable, fmtReset, binding, evaluateLadder, rungLabel, wallActive } from './usage.mjs'
35
37
  import { readAccounts, envFor, LAYOUT } from './accounts.mjs'
36
- import { readCodexUsage } from './taps/codex.mjs'
37
- 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'
38
41
  import { listHistory, findRecord, recordDetail, refreshIndex, readIndex, providerSupport, HistoryInputError, PROVIDER_NAMES } from './history/index.mjs'
39
42
  import { listWorktrees } from './history/worktrees.mjs'
40
43
 
@@ -75,16 +78,192 @@ export function columnOf(card) {
75
78
  return card.station
76
79
  }
77
80
 
78
- 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) {
79
255
  const st = (card.pipeline ?? []).find((s) => s.name === card.station) ?? null
80
- 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
81
260
  const runs = readRuns(card.card_id)
82
261
  const activeRun = runs.find((r) => ['launching', 'running'].includes(r.status)) ?? null
83
262
  // Once a station is over (done/failed) card.leg is reset, so the rail is
84
263
  // rebuilt from the legs that actually started at this station.
85
264
  const terminal = ['done', 'failed', 'killed'].includes(card.status)
86
265
  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)
266
+ ? evs.filter((ev) => ev.type === 'leg_started' && ev.station === card.station).map((ev) => ev.leg)
88
267
  : []
89
268
  const lastLeg = startedLegs.length ? Math.max(...startedLegs) : card.leg
90
269
  // card.leg is reset when the station ends, so a finished card read its adapter
@@ -101,12 +280,23 @@ export function summarize(card) {
101
280
  if (i > card.leg) return 'pending'
102
281
  return ['running', 'handing_off'].includes(card.status) ? 'active' : 'pending'
103
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) : {}
104
290
  return {
105
291
  ...card,
106
292
  column: columnOf(card),
107
293
  station_kind: st?.kind ?? null,
108
294
  active_adapter: entry?.adapter ?? null,
109
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 } } : {}),
110
300
  chain_view: st?.kind === 'agent' ? st.chain.map((e, i) => ({
111
301
  adapter: e.adapter, mode: e.mode ?? null, approve: Boolean(e.approve),
112
302
  // a leg before the last one ended in a handoff (only the last leg can complete a station)
@@ -293,8 +483,18 @@ function trunkFor(repo) {
293
483
  const hit = trunkCache.get(repo)
294
484
  if (hit && Date.now() - hit.at < 15000) return hit.data
295
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.
296
490
  let branch = null
297
- 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
298
498
  const log = branch ? g(['log', '--format=%h%x1f%s%x1f%cr%x1f%an', '-6', branch]) : null
299
499
  const data = {
300
500
  repo, repo_name: repo.split(/[\\/]/).filter(Boolean).pop(), branch,
@@ -319,14 +519,28 @@ function withLandings(t, landings) {
319
519
  // What another human sees of a terminal that is not theirs: that it is there,
320
520
  // nothing it has said, read or written. No task, no paths, no file names, no
321
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.
322
528
  function redactSession(s) {
323
529
  return {
324
530
  session_id: s.session_id, agent: s.agent, account: s.account, status: s.status, active: s.active,
325
531
  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,
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.
330
544
  worktree: s.worktree ? { branch: s.worktree.branch, base: s.worktree.base } : null,
331
545
  // the branch is already on the worktree chip: naming it again costs nothing
332
546
  // and is what the board's land line reads
@@ -338,6 +552,40 @@ function redactSession(s) {
338
552
  }
339
553
  }
340
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
+
341
589
  function visibleSessionFile(file) {
342
590
  const value = String(file ?? '')
343
591
  return !value.includes('*** Begin Patch') && !value.includes('*** End Patch')
@@ -353,13 +601,24 @@ export function sessionsView({ viewer = null, share = null } = {}) {
353
601
  const list = reapLost(listSessions())
354
602
  const ov = overlaps(list)
355
603
  const configuredAccounts = readAccounts()
604
+ // the spending rules the picker has to print, read once for the whole view
605
+ const prefs = readPreferences()
356
606
  const sessions = list.map((s) => {
357
607
  const land = readLand(s.session_id)
358
608
  const handoffOrder = normalizeHandoffOrder(s.handoff_order)
359
- 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 })
360
613
  const preferredNext = chain[0] ?? null
361
614
  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
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
363
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 }] }
364
623
  return {
365
624
  ...s,
@@ -370,19 +629,38 @@ export function sessionsView({ viewer = null, share = null } = {}) {
370
629
  // every destination this terminal could be handed to, each with the
371
630
  // reason it cannot be picked right now. The board's picker renders this
372
631
  // 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
- }),
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
+ })),
385
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) }),
386
664
  can_edit_handoff_order: s.runtime_capabilities?.includes(HANDOFF_ORDER_CAPABILITY) ?? false,
387
665
  active: isActive(s),
388
666
  has_synthesis: hasRecentSynthesis(s),
@@ -401,7 +679,11 @@ export function sessionsView({ viewer = null, share = null } = {}) {
401
679
  const accounts = []
402
680
  for (const agent of Object.keys(configuredAccounts)) for (const account of configuredAccounts[agent]) {
403
681
  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 })
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 })
405
687
  }
406
688
  const repos = new Map()
407
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)
@@ -410,7 +692,11 @@ export function sessionsView({ viewer = null, share = null } = {}) {
410
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 })
411
693
  const trunk = [...repos].map(([key, r]) => { try { return withLandings(trunkFor(r), landingsFor(key)) } catch { return { repo: r, commits: [] } } })
412
694
  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)))
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
+ })
414
700
  return {
415
701
  sessions: shown,
416
702
  // a guest sees which accounts exist and which are busy, never how much of
@@ -421,6 +707,12 @@ export function sessionsView({ viewer = null, share = null } = {}) {
421
707
  // a guest sees what landed, not where the repo lives on this machine
422
708
  trunk: guest ? trunk.map((t) => ({ repo_name: t.repo_name, branch: t.branch, commits: t.commits ?? [] })) : trunk,
423
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() } : {}),
424
716
  share: { on: shared, bind: shared ? share.bind : null, people: shared ? share.people.length : 0 },
425
717
  preferences: guest ? null : readPreferences(),
426
718
  ts: new Date().toISOString(),
@@ -488,7 +780,7 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, sessionsDebounce
488
780
  if (!card) { for (const c of clients) c.sig.delete(id); broadcast('removed', forOwner({ card_id: id })); return }
489
781
  const events = readEvents(id)
490
782
  // broadcast() refreshes each client's viewer (and drops revoked ones) first
491
- broadcast('card', forOwner(summarize(card)))
783
+ broadcast('card', forOwner(summarize(card, events)))
492
784
  for (const c of [...clients]) {
493
785
  if (!c.viewer || !mayUseCards(c.viewer.role)) { c.sig.set(id, events.length); continue }
494
786
  const from = c.sig.get(id) ?? 0
@@ -655,6 +947,49 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
655
947
  return null
656
948
  }
657
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
+ }
658
993
  let sched = null
659
994
  let usageTimer = null
660
995
  let usageController = null
@@ -732,12 +1067,13 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
732
1067
  if (req.method === 'GET' && path === '/api/presets') return send(res, 200, { presets: PRESETS })
733
1068
  if (req.method === 'GET' && path === '/api/cards') {
734
1069
  const cards = listCards()
735
- 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)) })
736
1071
  }
737
1072
  if (req.method === 'POST' && path === '/api/cards') {
738
1073
  const body = await readBody(req)
739
1074
  try {
740
- 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 })
741
1077
  sse.broadcast('card', forOwner(summarize(card)))
742
1078
  return send(res, 201, { card: summarize(card) })
743
1079
  } catch (err) {
@@ -748,7 +1084,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
748
1084
  if (req.method === 'GET' && path === '/api/events') {
749
1085
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' })
750
1086
  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`)
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`)
752
1088
  sse.add(res, cards, viewer, { token: presentedToken(req, url), loopback: isLoopbackRequest(req) })
753
1089
  return
754
1090
  }
@@ -761,6 +1097,15 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
761
1097
  try {
762
1098
  const patch = {}
763
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)
764
1109
  if (body.harness !== undefined) {
765
1110
  // the board may narrow the policy or turn the feature off; turning
766
1111
  // it on is the first-run consent flow, which shows what will be
@@ -771,7 +1116,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
771
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}` })
772
1117
  patch.harness = { policy: body.harness?.policy, enabled: body.harness?.enabled === false ? false : undefined }
773
1118
  }
774
- 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' })
775
1120
  const preferences = writePreferences(patch)
776
1121
  sse.broadcast('sessions', (v) => viewFor(v))
777
1122
  return send(res, 200, { preferences })
@@ -842,7 +1187,13 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
842
1187
  }
843
1188
  const body = await readBody(req)
844
1189
  try {
845
- 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(' → ')}`
846
1197
  const next = updateSession(id, (current) => {
847
1198
  if (!['starting', 'running', 'warning', 'limit', 'waiting'].includes(current.status)) {
848
1199
  const conflict = new Error(`handoff order cannot change while this terminal is ${current.status}`)
@@ -851,9 +1202,10 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
851
1202
  }
852
1203
  return {
853
1204
  handoff_order: order,
854
- 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 }),
855
1207
  }
856
- }, { event: { type: 'status', by: actor.id, summary: `handoff order changed to ${order.join(' → ')}` } })
1208
+ }, { event: { type: 'status', by: actor.id, summary } })
857
1209
  sse.broadcast('sessions', (v) => viewFor(v))
858
1210
  return send(res, 200, { session: next })
859
1211
  } catch (err) {
@@ -889,6 +1241,107 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
889
1241
  log(`land requested for ${id} by ${actor.id}`)
890
1242
  return send(res, 202, { ok: true, requested: 'land' })
891
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
+ }
892
1345
  if (req.method === 'POST' && (parts[3] === 'handoff' || parts[3] === 'end')) {
893
1346
  if (!isActive(sess)) return send(res, 409, { error: `session ${id} is not active` })
894
1347
  if (parts[3] === 'end') {
@@ -901,19 +1354,21 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
901
1354
  const body = await readBody(req)
902
1355
  let target = null
903
1356
  if (body && body.agent !== undefined && body.agent !== null && body.agent !== '') {
904
- const want = { agent: String(body.agent), account: String(body.account ?? 'default') }
1357
+ const want = { agent: String(body.agent), account: String(body.account ?? 'default'), model: body.model ? String(body.model).toLowerCase() : null }
905
1358
  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'})` })
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'})` })
910
1364
  if (sess.installed && sess.installed[want.agent] === false) return send(res, 409, { error: `${label} is not installed on this machine` })
911
1365
  const u = readUsage(want.agent, want.account)
912
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` })
913
- target = hit
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 } : {}) }
914
1369
  }
915
1370
  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}` : ''}`)
1371
+ log(`handoff requested for ${id} by ${actor.id}${target ? ` to ${rungLabel(target)}` : ''}`)
917
1372
  return send(res, 200, { ok: true, requested: 'handoff', target })
918
1373
  }
919
1374
  if (req.method === 'DELETE' && parts.length === 3) {
@@ -1039,6 +1494,48 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
1039
1494
  sse.broadcast('removed', forOwner({ card_id: id }))
1040
1495
  return send(res, 200, { removed: id })
1041
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
+ }
1042
1539
  if (req.method === 'POST' && parts[3]) {
1043
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' }
1044
1541
  const action = map[parts[3]]
@@ -1114,6 +1611,8 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
1114
1611
  async stop() {
1115
1612
  if (usageTimer) { clearInterval(usageTimer); usageTimer = null }
1116
1613
  usageController?.abort()
1614
+ for (const t of enqueueTimers.values()) clearTimeout(t)
1615
+ enqueueTimers.clear()
1117
1616
  sse.stop()
1118
1617
  if (sched) sched.stop()
1119
1618
  if (loopbackCompanion) await new Promise((r) => { loopbackCompanion.closeAllConnections?.(); loopbackCompanion.close(() => r()) })