@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/attach.mjs CHANGED
@@ -14,16 +14,19 @@ import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
14
14
  import { join, dirname, resolve, relative } from 'node:path'
15
15
  import { fileURLToPath } from 'node:url'
16
16
  import { sanitizeEnv } from './env.mjs'
17
- import { home } from './store.mjs'
17
+ import { home, readCard, listCards } from './store.mjs'
18
+ import { loadResume } from './handoff.mjs'
19
+ import { worktreePath } from './worktree.mjs'
18
20
  import { get as getAdapter } from './adapters/index.mjs'
19
21
  import { SUPERVISED_AGENTS, HANDOFF_ORDER_CAPABILITY, newSessionId, createSession, readSession, updateSession, appendEvent, takeControl, sessionDir, listSessions, reapLost, isActive, workRoot } from './sessions.mjs'
20
22
  import { ensure as ensureWorktree, remove as removeWorktree } from './worktree.mjs'
21
23
  import { canonPath, realPath } from './fsx.mjs'
22
24
  import { whoami, readShare, isOn as shareIsOn } from './share.mjs'
23
25
  import { readAccounts, envFor, refreshAccount } from './accounts.mjs'
24
- import { recordUsage, markLimited, chooseNext, candidates, fmtReset, WARN_PCT, readUsage, isAvailable } from './usage.mjs'
26
+ import { recordUsage, markLimited, chooseNext, candidates, fmtReset, WARN_PCT, readUsage, isAvailable, wallActive, rungLabel, skipLine } from './usage.mjs'
25
27
  import { entitlement, allows, describe as describeLicense } from './license.mjs'
26
- import { writeSettings, userStatusLine, transcriptTail as claudeTail } from './taps/claude.mjs'
28
+ import { writeSettings, userStatusLine, transcriptTail as claudeTail, modelAlias, modelFromTranscript, printable } from './taps/claude.mjs'
29
+ import { modelFlagFor, isDownshift } from './buckets.mjs'
27
30
  import { ensureTrust, trustLine } from './trust.mjs'
28
31
  import { findRollout, createTail, parseLines, readCodexUsage, transcriptTail as codexTail } from './taps/codex.mjs'
29
32
  import { scanLog, promptsSince, logSize } from './taps/agy.mjs'
@@ -35,7 +38,7 @@ import { openBoard, pidfile } from './launcher.mjs'
35
38
  import { LAYOUT } from './accounts.mjs'
36
39
  import { captureLive } from './live-capture.mjs'
37
40
  import { waitForReset, fmtCountdown } from './wait.mjs'
38
- import { readPreferences, normalizeHandoffOrder, resolveAutoApprove } from './preferences.mjs'
41
+ import { readPreferences, normalizeHandoffOrder, ladderFor, resolveAutoApprove } from './preferences.mjs'
39
42
  import { prepareHarnessForHandoff, harnessLine } from './harness/index.mjs'
40
43
  import { insideKnownStore } from './history/index.mjs'
41
44
 
@@ -58,7 +61,7 @@ const say = (line) => process.stderr.write(`[leg] ${line}\n`)
58
61
  async function refreshCodexUsage(account, codexHome, { timeoutMs = 8000, signal = null } = {}) {
59
62
  const r = await readCodexUsage({ codexHome, timeoutMs, signal })
60
63
  if (!r.ok) return r
61
- const u = recordUsage('codex', account, r.limits, 'codex app-server account/rateLimits/read', { observed_at: r.observed_at, available: r.available })
64
+ const u = recordUsage('codex', account, { ...r.limits, facts: r.facts }, 'codex app-server account/rateLimits/read', { observed_at: r.observed_at, available: r.available })
62
65
  return { ...r, usage: u }
63
66
  }
64
67
 
@@ -156,6 +159,20 @@ export function gitInfo(cwd) {
156
159
  }
157
160
  }
158
161
 
162
+ // How many commits this checkout is ahead of where the work started: the
163
+ // upstream branch when the checkout tracks one, else the commit HEAD was at
164
+ // when the session began. null when this is not a repo, when there is neither
165
+ // an upstream nor a recorded start, or when git cannot answer. A count that
166
+ // could not be taken is never printed as a zero (redesign A.4 row 8).
167
+ export function aheadCount(cwd, fallbackBase = null) {
168
+ const upstream = git(cwd, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])
169
+ const base = (upstream && upstream.trim()) || fallbackBase
170
+ if (!base) return null
171
+ const n = git(cwd, ['rev-list', '--count', `${base}..HEAD`])
172
+ const parsed = parseInt(String(n ?? '').trim(), 10)
173
+ return Number.isFinite(parsed) ? parsed : null
174
+ }
175
+
159
176
  // ---- collisions ----
160
177
  // Two agents in one working tree write over each other's files. When another
161
178
  // live session already works in this checkout, this one gets its own:
@@ -226,8 +243,126 @@ function restoreTerminal() {
226
243
  try { process.stdout.write(TERMINAL_RESET) } catch {}
227
244
  }
228
245
 
246
+ // ---- model and terminal title ----
247
+ // The model this leg resolved to, from the argv the human actually passed:
248
+ // `--model x`, `-m x`, or the `--model=x` form. No default is invented — a
249
+ // model token on a row that nobody chose is a wrong number in disguise, so the
250
+ // answer for a bare `leg claude` is null until the transcript says otherwise.
251
+ export function modelFromArgs(agent, args = []) {
252
+ const flag = modelFlagFor(agent)
253
+ const names = flag === '-m' ? ['-m', '--model'] : [flag, '-m'].filter(Boolean)
254
+ for (let i = 0; i < args.length; i++) {
255
+ const a = String(args[i] ?? '')
256
+ for (const n of names) {
257
+ if (a === n && args[i + 1] && !String(args[i + 1]).startsWith('-')) return modelAlias(agent, args[i + 1])
258
+ if (a.startsWith(n + '=')) return modelAlias(agent, a.slice(n.length + 1))
259
+ }
260
+ }
261
+ return null
262
+ }
263
+
264
+ export const shortId = (sessionId) => String(sessionId ?? '').split('-').pop()
265
+
266
+ // ---- taking over a background card (redesign C.4) ----
267
+ // Leg's own flags never reach the agent's argv, so they are lifted out of the
268
+ // pass-through list before anything else looks at it. Both spellings, because
269
+ // a human copying `--resume-card=<id>` out of a shell history is not wrong.
270
+ export function takeFlagValue(args, flag) {
271
+ const out = []
272
+ let value = null
273
+ for (let i = 0; i < args.length; i++) {
274
+ const a = String(args[i] ?? '')
275
+ if (a === flag) {
276
+ const next = args[i + 1]
277
+ if (next !== undefined && !String(next).startsWith('-')) { value = String(next); i++ }
278
+ continue
279
+ }
280
+ if (a.startsWith(flag + '=')) { value = a.slice(flag.length + 1); continue }
281
+ out.push(args[i])
282
+ }
283
+ return { args: out, value }
284
+ }
285
+
286
+ // A card id in full, or the short tail the board prints. Ambiguity is an error
287
+ // rather than a pick: opening a terminal in the wrong worktree is the one
288
+ // outcome this whole feature exists to avoid.
289
+ export function resolveCardId(want, cards = null) {
290
+ const wanted = String(want ?? '').trim()
291
+ if (!wanted) return { id: null, matches: [] }
292
+ if (readCard(wanted)) return { id: wanted, matches: [wanted] }
293
+ const ids = (cards ?? listCards()).map((c) => c.card_id)
294
+ const matches = ids.filter((id) => id === wanted || id.endsWith(`-${wanted}`) || id.includes(wanted))
295
+ return { id: matches.length === 1 ? matches[0] : null, matches }
296
+ }
297
+
298
+ // Where that card's work is: the worktree it recorded, else the one its id
299
+ // names, else the repository itself. Never a path that is not there.
300
+ export function cardWorkRoot(card) {
301
+ if (card?.worktree && existsSync(card.worktree)) return card.worktree
302
+ if (card?.repo) {
303
+ try { const p = worktreePath(card.repo, card.card_id); if (existsSync(p)) return p } catch { /* not a repo any more */ }
304
+ }
305
+ // never the main checkout: a card's work belongs in its own worktree, and a
306
+ // terminal opened in `card.repo` would edit the human's checkout under the
307
+ // card's name. The take-over route cuts a worktree before it hands out the
308
+ // command, so this is only reached for a card made by an older Leg.
309
+ return null
310
+ }
311
+
312
+ // The first prompt of a terminal that took a card over: the card's own bundle,
313
+ // loaded through the same CLI every hand-off uses, with the card's task above
314
+ // it. A bundle that will not load is said out loud and the terminal still opens.
315
+ export function takeOverPrompt(card, cwd) {
316
+ let loaded = ''
317
+ try { loaded = loadResume(cwd, card.last_bundle ?? 'latest') } catch { loaded = '' }
318
+ const head = `# Taking over a background card\n\nCard ${card.card_id}, station ${card.station}, status ${card.status}. It is paused, so nothing else is running in this worktree.\n\nThe task: ${card.task ?? '(none recorded)'}\n\n`
319
+ return loaded
320
+ ? `${head}${loaded}`
321
+ : `${head}No bundle could be loaded. Read .leg/CONTRACT.md and .leg/PROGRESS.md in this directory, then check git status and git diff before continuing.`
322
+ }
323
+
324
+ // `leg#7f3a leg/main`: which terminal this window is, and where it is working.
325
+ // Degrades to the id alone rather than printing a place that is not a repo.
326
+ export function terminalTitle(session) {
327
+ const where = session?.repo_name ? `${session.repo_name}${session.branch ? '/' + session.branch : ''}` : null
328
+ return `leg#${shortId(session?.session_id)}${where ? ' ' + where : ''}`
329
+ }
330
+
331
+ // OSC 2 (set window title) for the CLIs with no title flag of their own.
332
+ // ASSUMED: a VT terminal keeps the title once the child starts drawing. codex,
333
+ // agy or grok may overwrite it with their own; nothing observed either way yet,
334
+ // and the probe is to start each one and read the tab (redesign E, row 4).
335
+ export function osc2(title) { return `\x1b]2;${printable(title)}\x07` }
336
+
337
+ // Claude Code can wait at the usage limit itself when the human's own settings
338
+ // re-enable autoContinueAtUsageLimit. Leg sets it false, but it does not own
339
+ // that file, so when the `quota_auto_resume_fired` Notification arrives the
340
+ // automatic hand-off stands down for that terminal: two waiters on one terminal
341
+ // is the failure to avoid (redesign B.5). A human pressing Hand off > is not
342
+ // affected — standing down is about what Leg does unasked.
343
+ export function handoffStoodDown(session) {
344
+ return session?.waiting?.type === 'quota_auto_resume' ? (session.waiting.message ?? 'Claude Code is waiting at the limit itself') : null
345
+ }
346
+
347
+ // The model flag for a rung, or nothing. Nothing when the rung names no model,
348
+ // when Leg does not know how that CLI spells one, or when the human already
349
+ // passed a model themselves: an argv the human wrote is never overwritten.
350
+ export function modelFlags(agent, args = [], model = null) {
351
+ if (!model) return []
352
+ const flag = modelFlagFor(agent)
353
+ if (!flag) return []
354
+ if (modelFromArgs(agent, args)) return []
355
+ return [flag, model]
356
+ }
357
+
229
358
  // ---- spawn spec per agent ----
230
- export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd, autoApprove = resolveAutoApprove() }) {
359
+ // `model` is the rung's model (B.3); `resume` is the agent's own session id for
360
+ // the one case where a hand-off keeps the conversation instead of the bundle:
361
+ // a claude downshift (`--resume <id> --model <alias>`, settled by
362
+ // fixtures/live/claude/resume-model-probe.json). codex has a `resume`
363
+ // subcommand too, but composing it with `-m` is ASSUMED, not observed (codex
364
+ // is walled on this machine until Saturday), so codex ships bundle-primed.
365
+ export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd, autoApprove = resolveAutoApprove(), model = null, resume = null }) {
231
366
  const adapter = await loadAdapter(agent)
232
367
  const { bin, viaNode, entry } = adapter.resolve()
233
368
  const argv = []
@@ -239,23 +374,32 @@ export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd,
239
374
  if (agent === 'claude') {
240
375
  const settings = writeSettings(sessionId, { statusLine: userStatusLine(process.env.CLAUDE_CONFIG_DIR || (account !== 'default' ? envFor('claude', account).CLAUDE_CONFIG_DIR : undefined)) })
241
376
  const autoFlags = autoApprove && !args.includes('--dangerously-skip-permissions') ? ['--dangerously-skip-permissions'] : [] // auto-approve: not forbidden for interactive sessions
242
- argv.push(...args, ...autoFlags, '--settings', settings)
377
+ // `-n, --name <name>`: "Set a display name for this session (shown in the
378
+ // prompt box, /resume picker, and terminal title)" — fixtures/help/claude.txt
379
+ // line 132, in the general Options section, not one of the flags marked
380
+ // "only works with --print", and two of its three surfaces (prompt box,
381
+ // /resume picker) exist only in interactive mode. A name the human passed
382
+ // themselves is never overwritten.
383
+ const named = args.some((a) => a === '-n' || a === '--name' || String(a).startsWith('--name='))
384
+ const nameFlags = named ? [] : ['-n', terminalTitle(readSession(sessionId) ?? { session_id: sessionId })]
385
+ const resumeFlags = resume ? ['--resume', String(resume)] : []
386
+ argv.push(...resumeFlags, ...args, ...modelFlags(agent, args, model), ...autoFlags, ...nameFlags, '--settings', settings)
243
387
  if (prompt) argv.push(prompt)
244
388
  } else if (agent === 'codex') {
245
389
  const hasApproval = args.includes('--ask-for-approval') || args.includes('-a') || args.some((x) => typeof x === 'string' && x.startsWith('--ask-for-approval='))
246
390
  const autoFlags = autoApprove && !hasApproval ? ['--ask-for-approval', 'never'] : []
247
- argv.push(...args, ...autoFlags)
391
+ argv.push(...args, ...modelFlags(agent, args, model), ...autoFlags)
248
392
  if (prompt) argv.push(prompt)
249
393
  } else if (agent === 'agy') {
250
394
  const log = join(sessionDir(sessionId), 'agy.log')
251
395
  const autoFlags = autoApprove && !args.includes('--dangerously-skip-permissions') ? ['--dangerously-skip-permissions'] : [] // auto-approve: not forbidden for interactive sessions
252
- argv.push(...args, ...autoFlags, '--log-file', log)
396
+ argv.push(...args, ...modelFlags(agent, args, model), ...autoFlags, '--log-file', log)
253
397
  if (prompt) argv.push('-i', prompt)
254
398
  } else if (agent === 'grok') {
255
399
  const log = join(sessionDir(sessionId), 'grok.log')
256
400
  const hasApprove = args.includes('--always-approve') || args.includes('--yolo') || args.includes('--approval-mode=yolo') // auto-approve check: not forbidden for interactive sessions
257
401
  const autoFlags = autoApprove && !hasApprove ? ['--always-approve'] : [] // auto-approve: not forbidden for interactive sessions
258
- argv.push(...args, ...autoFlags, '--debug-file', log)
402
+ argv.push(...args, ...modelFlags(agent, args, model), ...autoFlags, '--debug-file', log)
259
403
  if (prompt) argv.push(prompt)
260
404
  }
261
405
  const env = { ...sanitizeEnv(process.env, { interactive: true }), ...envFor(agent, account), LEG_SESSION: sessionId, BATON_SESSION: sessionId }
@@ -267,7 +411,7 @@ export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd,
267
411
  // destination a human picked on the board ("Hand off now to codex"), carried
268
412
  // out to the loop below, which is what chooses the next leg.
269
413
 
270
- async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApprove = resolveAutoApprove() }) {
414
+ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApprove = resolveAutoApprove(), model = null, resume = null }) {
271
415
  const sid = session.session_id
272
416
  refreshAccount(agent, account)
273
417
  // A handoff happens when the limit hits, which is usually when nobody is
@@ -277,8 +421,8 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
277
421
  const trust = ensureTrust(agent, session.cwd, { cwd: session.cwd })
278
422
  const trusted = trustLine(trust)
279
423
  if (trusted) { say(trusted); appendEvent(sid, { type: 'trust', summary: trusted }) }
280
- const spec = await spawnSpec(agent, { account, args, sessionId: sid, prompt, cwd: session.cwd, autoApprove })
281
- appendEvent(sid, { type: 'leg', summary: `${agent} (${account}) starting${prompt ? ' from the handoff bundle' : ''}` })
424
+ const spec = await spawnSpec(agent, { account, args, sessionId: sid, prompt, cwd: session.cwd, autoApprove, model, resume })
425
+ appendEvent(sid, { type: 'leg', summary: `${agent}${model ? '/' + model : ''} (${account}) starting${resume ? ' with the conversation it already had' : prompt ? ' from the handoff bundle' : ''}` })
282
426
  const startedMs = Date.now()
283
427
  const turnsAtLegStart = session.turns ?? 0
284
428
  // agy appends to one log for the whole session: a second agy leg reads from
@@ -287,6 +431,12 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
287
431
  const agyTail = agyLog ? createTail(agyLog, { from: logSize(agyLog) }) : null
288
432
  const grokLog = agent === 'grok' ? join(sessionDir(sid), 'grok.log') : null
289
433
  const grokTail = grokLog ? createTail(grokLog, { from: logSize(grokLog) }) : null
434
+ // claude takes `-n` (spawnSpec). The other three have no title flag, so Leg
435
+ // writes the title itself, once, before the child owns the terminal. Only on
436
+ // a TTY: into a pipe or a log this would be four stray control bytes.
437
+ if (agent !== 'claude' && process.stdout.isTTY) {
438
+ try { process.stdout.write(osc2(terminalTitle(session))) } catch {}
439
+ }
290
440
  let child
291
441
  try {
292
442
  child = spawn(spec.bin, spec.args, { cwd: spec.cwd, env: spec.env, stdio: 'inherit', windowsHide: false })
@@ -296,7 +446,9 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
296
446
  }
297
447
  // every leg starts on its own card: the agent that just left takes its
298
448
  // percentages, its warning and its usage source with it
299
- updateSession(sid, { pid: child.pid, agent, account, status: agent === 'claude' ? 'starting' : 'running', limit: null, warning: null, limits: null, usage_source: null, usage_error: null })
449
+ // the model goes with it: the next leg's argv is the only thing Leg knows
450
+ // about the model until that agent's own transcript says otherwise
451
+ updateSession(sid, { pid: child.pid, agent, account, model: modelFromArgs(agent, args) ?? model ?? null, status: agent === 'claude' ? 'starting' : 'running', limit: null, warning: null, limits: null, usage_source: null, usage_error: null })
300
452
 
301
453
  // taps
302
454
  let rollout = null; let tail = null
@@ -307,7 +459,7 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
307
459
  rollout = { path: session.transcript_path, meta: { id: session.agent_session_id } }
308
460
  tail = createTail(rollout.path, { from: logSize(rollout.path) })
309
461
  }
310
- let polls = 0; let warned = false
462
+ let polls = 0; let warned = false; let stoodDown = false
311
463
  let stop = null
312
464
  const done = new Promise((res) => { stop = res })
313
465
  child.on('error', (err) => { appendEvent(sid, { type: 'error', summary: `${agent} spawn error: ${err.message}` }); stop({ reason: 'exit', code: 127 }) })
@@ -325,10 +477,20 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
325
477
  if (!isCurrentLeg(s, { pid: child.pid, agent, account })) return
326
478
  // a 404, a body that is not JSON, or a shape with no window at all: the
327
479
  // card says usage unknown and the StopFailure hook still owns the limit
480
+ // which model actually answered. The transcript path arrives on the
481
+ // SessionStart hook payload (src/taps/claude.mjs `handleHook`, `base`),
482
+ // so this only reads once Claude Code has told Leg where its jsonl is.
483
+ // A fallback off fable shows up here and nowhere else.
484
+ const seen = modelFromTranscript(s.transcript_path, { agent: 'claude' })
485
+ if (seen && seen !== s.model) {
486
+ updateSession(sid, { model: seen }, { event: { type: 'status', summary: `claude is answering on ${seen}${s.model ? ` (was ${s.model})` : ''}` } })
487
+ }
328
488
  const usable = r.ok && r.limits && (r.limits.five_hour || r.limits.seven_day)
329
489
  if (usable) {
330
490
  recordUsage('claude', account, r.limits, 'claude usage endpoint')
331
- updateSession(sid, { limits: r.limits, usage_source: 'claude usage endpoint', usage_error: null })
491
+ // the session record keeps the two windows it always had: the buckets
492
+ // live on the usage record, which is per login and not per terminal
493
+ updateSession(sid, { limits: { five_hour: r.limits.five_hour, seven_day: r.limits.seven_day }, usage_source: 'claude usage endpoint', usage_error: null })
332
494
  } else if (!s.usage_error) {
333
495
  const why = r.error ?? 'the usage endpoint answered with no window'
334
496
  updateSession(sid, { usage_error: why }, { event: { type: 'status', summary: `claude usage unavailable: ${why}` } })
@@ -385,7 +547,7 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
385
547
  // git: which files this session is touching, where trunk is
386
548
  if (polls % GIT_EVERY === 1) {
387
549
  const g = gitInfo(s.cwd)
388
- if (g.repo) { patch.files_dirty = g.dirty; patch.head = g.head; patch.branch = g.branch }
550
+ if (g.repo) { patch.files_dirty = g.dirty; patch.head = g.head; patch.branch = g.branch; patch.ahead = aheadCount(s.cwd, s.head_at_start) }
389
551
  }
390
552
  // codex: find + tail the rollout
391
553
  if (agent === 'codex') {
@@ -397,7 +559,7 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
397
559
  if (tail) {
398
560
  const r = parseLines(tail.read())
399
561
  if (r.limits) {
400
- const u = recordUsage('codex', account, r.limits, 'codex rollout token_count', { observed_at: r.limits_at })
562
+ const u = recordUsage('codex', account, { ...r.limits, facts: r.facts }, 'codex rollout token_count', { observed_at: r.limits_at })
401
563
  if (u.usage_applied) { patch.limits = r.limits; patch.last_activity = new Date().toISOString() }
402
564
  }
403
565
  const firstUser = r.messages.find((m) => m.role === 'user')
@@ -478,15 +640,23 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
478
640
  if (ctl?.end) { if (ctl.by) appendEvent(sid, { type: 'status', by: ctl.by, summary: `end requested from the board by ${ctl.by}` }); clearInterval(timer); killTree(child.pid); restoreTerminal(); stop({ reason: 'exit', code: null, ended: true }); return }
479
641
  if (ctl?.handoff) {
480
642
  const picked = ctl.target && typeof ctl.target === 'object' && ctl.target.agent
481
- ? { agent: String(ctl.target.agent), account: String(ctl.target.account ?? 'default') }
643
+ ? { agent: String(ctl.target.agent), account: String(ctl.target.account ?? 'default'), ...(ctl.target.model ? { model: String(ctl.target.model) } : {}) }
482
644
  : null
483
- const toWhom = picked ? ` to ${picked.agent}${picked.account !== 'default' ? '/' + picked.account : ''}` : ''
645
+ const toWhom = picked ? ` to ${rungLabel(picked)}` : ''
484
646
  updateSession(sid, { status: 'handing_off', handoff: { reason: `requested from the board${ctl.by ? ` by ${ctl.by}` : ''}`, at: new Date().toISOString(), by: ctl.by ?? null, requested_to: picked } }, { event: { type: 'handoff_requested', by: ctl.by ?? null, summary: `hand off${toWhom} requested from the board${ctl.by ? ` by ${ctl.by}` : ''}` } })
485
647
  clearInterval(timer); killTree(child.pid); restoreTerminal(); stop({ reason: 'handoff', code: null, target: picked }); return
486
648
  }
487
649
  // a stale warning patch can overwrite status:'limit' from the hook, but the
488
650
  // limit OBJECT survives the clobber — hand off on either signal
489
651
  if ((next.status === 'limit' || next.limit) && (process.env.LEG_NO_HANDOFF || process.env.BATON_NO_HANDOFF) !== '1') {
652
+ // Claude Code is waiting at the limit itself: stand down rather than
653
+ // kill a child that is about to resume on its own. Said once, with the
654
+ // reason, so the terminal that did not hand off is never a mystery.
655
+ const standDown = handoffStoodDown(next)
656
+ if (standDown) {
657
+ if (!stoodDown) { stoodDown = true; say(standDown); appendEvent(sid, { type: 'status', summary: `hand-off stood down: ${standDown}` }) }
658
+ return
659
+ }
490
660
  clearInterval(timer); killTree(child.pid); restoreTerminal(); stop({ reason: 'limit', code: null })
491
661
  }
492
662
  } catch (err) {
@@ -536,14 +706,43 @@ function messagesFor(agent, s) {
536
706
  // an order save is consumed here, or the editor sees handing_off and refuses.
537
707
  // No eligible choice leaves the session unclaimed so all-out waiting can keep
538
708
  // accepting order edits.
539
- export function claimHandoffChoice({ sid, agent, account, installed, bundle = null, reason = 'limit', nowS = Math.floor(Date.now() / 1000), exclude = [], prefer = null }) {
540
- let choice = { next: null, out: [] }
709
+ // `automatic` is the fact, not a guess from the shape of the call: only the
710
+ // usage limit (and the scheduler behind a card) hands off unasked. A human
711
+ // pressing Hand off now sends no destination at all when they take the default
712
+ // option, and inferring "automatic" from that missing target applied the
713
+ // reserve, the cost gate and climb-back to a hand-off the human asked for, on
714
+ // rungs the picker had just shown them as available (B.3).
715
+ export function claimHandoffChoice({ sid, agent, account, model = null, installed, bundle = null, reason = 'limit', nowS = Math.floor(Date.now() / 1000), exclude = [], prefer = null, preferences = null, automatic = null }) {
716
+ let choice = { next: null, out: [], reasons: [] }
541
717
  let claimed = false
718
+ const auto = automatic === null ? reason === 'limit' : Boolean(automatic)
719
+ // The machine's spending rules are read once, here: a rung that costs credits
720
+ // is skipped unless the human allowed it, the reserve applies to automatic
721
+ // hand-offs only, and climb-back decides whether an automatic hand-off may
722
+ // walk back UP the ladder (B.3, B.7).
723
+ const prefs = preferences ?? readPreferences()
542
724
  const session = updateSession(sid, (current) => {
543
725
  const accounts = readAccounts()
544
726
  const order = normalizeHandoffOrder(current.handoff_order)
545
- choice = chooseNext({ agent, account, accounts, installed, order, nowS, exclude, prefer })
546
- if (!choice.next && isAvailable(readUsage(agent, account), nowS) && !exclude.some((x) => x.agent === agent && x.account === account)) choice = { next: { agent, account }, out: [], preferred_taken: false }
727
+ // the terminal's own ladder, else the long-hand form of its order: a
728
+ // terminal started before ladders existed behaves exactly as it did.
729
+ const ladder = ladderFor(current)
730
+ const legModel = model ?? current.model ?? null
731
+ choice = chooseNext({
732
+ agent, account, model: legModel, accounts, installed, order, ladder, nowS, exclude, prefer,
733
+ maySpend: prefs.may_spend, reserve: prefs.reserve, climbBack: prefs.climb_back, automatic: auto,
734
+ })
735
+ // Nothing on the ladder: keep the login the terminal is already on, but
736
+ // only when it can really run the next leg. A model wall leaves the account
737
+ // open by design, so claiming {agent, account} with no model here respawned
738
+ // the CLI on the model that had just walled, walled again, and burned all
739
+ // twelve legs. The model rides the claimed rung for the same reason, and
740
+ // `out` is kept so the all-out wait below still has its reset clocks.
741
+ const own = readUsage(agent, account)
742
+ const ownOpen = isAvailable(own, nowS) && !(legModel && wallActive(own.walls?.[legModel], nowS))
743
+ if (!choice.next && ownOpen && !exclude.some((x) => x.agent === agent && x.account === account)) {
744
+ choice = { next: { agent, account, ...(legModel ? { model: legModel } : {}) }, out: choice.out ?? [], reasons: choice.reasons ?? [], preferred_taken: false }
745
+ }
547
746
  if (!choice.next) return {}
548
747
  claimed = true
549
748
  return {
@@ -566,6 +765,25 @@ export function claimHandoffChoice({ sid, agent, account, installed, bundle = nu
566
765
  return { choice, claimed, session }
567
766
  }
568
767
 
768
+ // A pick that could not be taken, said in the words the ladder already used.
769
+ // `choice.reasons` carries {agent, account, model, reason} for every rung the
770
+ // walk passed over, so the cost gate, the reserve and a `below:N` rule all have
771
+ // their own sentence sitting there; re-deriving the explanation from the
772
+ // account's `limited_until` printed "at its limit until unknown" for a rung
773
+ // that was never walled at all. The limit sentence stays as the fallback for a
774
+ // rung the walk never reached. Both names carry their model (rungLabel), or a
775
+ // downshift reads as "claude was picked but ...; handing off to claude".
776
+ // → { asked, got, why }
777
+ export function pickedAside({ prefer, next, choice, excluded = [], read = readUsage }) {
778
+ const want = { agent: prefer.agent, account: prefer.account ?? 'default', model: prefer.model ?? null }
779
+ const asked = rungLabel(want)
780
+ const got = rungLabel(next)
781
+ if (excluded.some((x) => x.agent === want.agent && x.account === want.account)) return { asked, got, why: 'the strict harness policy refused it' }
782
+ const hit = (choice?.reasons ?? []).find((r) => r.agent === want.agent && r.account === want.account && (want.model ? (r.model ?? null) === want.model : true))
783
+ if (hit?.reason) return { asked, got, why: hit.reason }
784
+ return { asked, got, why: `it is at its limit until ${fmtReset(read(want.agent, want.account).limited_until)}` }
785
+ }
786
+
569
787
  // The portable harness, decided before a leg starts (src/harness/index.mjs).
570
788
  // Off by default: then this records nothing and changes nothing. On, it
571
789
  // carries the source agent's working environment to the agent about to run,
@@ -592,8 +810,27 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
592
810
  // affected; only what Leg adds is licensed.
593
811
  const ent = entitlement()
594
812
  if (!allows(ent, 'run')) { say(describeLicense(ent)); return 4 }
595
- // --no-worktree is Leg's flag, not the agent's: it never passes through
596
- const shareCheckout = args.includes('--no-worktree') || Boolean(continued)
813
+ // `--resume-card <id>`: Take over on the board paused a card and handed the
814
+ // human this command. The terminal opens in that card's own worktree, primed
815
+ // from its bundle (redesign C.4). Leg's flag, never the agent's.
816
+ const lifted = takeFlagValue(args, '--resume-card')
817
+ args = lifted.args
818
+ let card = null
819
+ if (lifted.value !== null) {
820
+ const hit = resolveCardId(lifted.value)
821
+ if (!hit.id) {
822
+ say(hit.matches.length
823
+ ? `"${lifted.value}" matches ${hit.matches.length} cards (${hit.matches.slice(0, 5).join(', ')}); use the full id`
824
+ : `no card matches "${lifted.value}" (leg card ls lists them)`)
825
+ return 3
826
+ }
827
+ card = readCard(hit.id)
828
+ if (!cardWorkRoot(card)) { say(`card ${hit.id} has no checkout on this machine yet; run it once, or open ${card.repo} yourself`); return 3 }
829
+ }
830
+ // --no-worktree is Leg's flag, not the agent's: it never passes through.
831
+ // A card take-over shares the card's checkout for the same reason: cutting a
832
+ // second worktree on its branch is what Take over exists to avoid.
833
+ const shareCheckout = args.includes('--no-worktree') || Boolean(continued) || Boolean(card)
597
834
  args = args.filter((a) => a !== '--no-worktree')
598
835
  let autoApproveCli = null
599
836
  if (args.includes('--no-auto-approve')) {
@@ -604,11 +841,16 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
604
841
  args = args.filter((a) => a !== '--auto-approve')
605
842
  }
606
843
  const autoApprove = resolveAutoApprove({ cliFlag: autoApproveCli })
607
- const cwd = cwdOpt ? realPath(cwdOpt) : process.cwd()
844
+ const cwd = card ? realPath(cardWorkRoot(card)) : (cwdOpt ? realPath(cwdOpt) : process.cwd())
608
845
  const board = await ensureBoard({ open })
609
846
  let accounts = readAccounts()
610
847
  const installed = await installedAgents()
611
- const handoffOrder = readPreferences().handoff_order
848
+ // The machine's preferences are copied into this terminal at start: the
849
+ // ladder it walks, and the spending rules it walks it under. Later edits
850
+ // reach a running terminal only through the board's per-terminal ladder.
851
+ const prefs = readPreferences()
852
+ const handoffOrder = prefs.handoff_order
853
+ const handoffLadder = prefs.handoff_ladder
612
854
  let account = process.env.LEG_ACCOUNT || process.env.BATON_ACCOUNT || 'default'
613
855
  if (!(accounts[agent] ?? ['default']).includes(account)) { say(`no ${agent} account "${account}"; using default`); account = 'default' }
614
856
  // A persisted wall is only a cache. Ask Codex's read-only account endpoint
@@ -623,20 +865,27 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
623
865
  await fetchGrokUsage({ configDir: grokHome }).catch(() => {})
624
866
  }
625
867
  // Start on an account that is not at its wall, if we already know one is.
868
+ let startModel = null
626
869
  const nowS = Math.floor(Date.now() / 1000)
627
870
  const u0 = readUsage(agent, account)
628
871
  if (u0.limited_until && u0.limited_until > nowS) {
629
- const alt = chooseNext({ agent, account, accounts, installed, order: handoffOrder, nowS })
630
- if (alt.next) { say(`${agent} (${account}) is at its limit until ${fmtReset(u0.limited_until)}; starting ${alt.next.agent} (${alt.next.account}) instead`); agent = alt.next.agent; account = alt.next.account }
872
+ // automatic: the human asked for this agent, not for this destination, so
873
+ // the machine's own floors (the reserve, the spending gate) still apply to
874
+ // the rung Leg substitutes for it.
875
+ const alt = chooseNext({ agent, account, accounts, installed, order: handoffOrder, ladder: handoffLadder, nowS, maySpend: prefs.may_spend, reserve: prefs.reserve, climbBack: prefs.climb_back, automatic: true })
876
+ if (alt.next) { say(`${agent} (${account}) is at its limit until ${fmtReset(u0.limited_until)}; starting ${rungLabel(alt.next)} instead`); agent = alt.next.agent; account = alt.next.account; startModel = alt.next.model ?? null }
631
877
  else say(`${agent} (${account}) is at its limit until ${fmtReset(u0.limited_until)}; starting anyway (every option is out)`)
632
878
  }
633
879
  const g = gitInfo(cwd)
634
880
  const sid = newSessionId(agent)
635
- const chain = candidates({ agent, account, accounts, order: handoffOrder })
881
+ const chain = candidates({ agent, account, accounts, order: handoffOrder, ladder: handoffLadder, model: startModel ?? modelFromArgs(agent, args) })
636
882
  // record the session BEFORE cutting a worktree, so a crash or Ctrl-C during
637
883
  // `git worktree add` still leaves a card (with a Remove button), never a
638
884
  // silent orphan under .baton-worktrees with no record and no button
639
- createSession({ id: sid, agent, account, cwd, repo: g.repo, branch: g.branch, argv: args, chain, worktree: null, owner: whoami(), handoffOrder, installed, runtimeCapabilities: [HANDOFF_ORDER_CAPABILITY] })
885
+ createSession({ id: sid, agent, account, cwd, repo: g.repo, branch: g.branch, argv: args, chain, worktree: null, owner: whoami(), handoffOrder, installed, runtimeCapabilities: [HANDOFF_ORDER_CAPABILITY], model: modelFromArgs(agent, args) ?? startModel })
886
+ // the ladder is a copy too, so the board can edit this terminal's rungs
887
+ // without changing the machine default under every other terminal
888
+ updateSession(sid, { handoff_ladder: handoffLadder })
640
889
  if (continued) {
641
890
  // the agent's own id and transcript are known before the first turn, so
642
891
  // history dedups this leg against the conversation it continues at once
@@ -658,7 +907,17 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
658
907
  }
659
908
 
660
909
  let prompt = null
910
+ if (card) {
911
+ // the card's bundle is this terminal's first prompt, and the record says
912
+ // where this terminal came from, so the board can draw the line back
913
+ updateSession(sid, { lineage: { from: card.card_id, to: null }, task: card.task ?? null },
914
+ { event: { type: 'continued', summary: `taking over card ${card.card_id} (${card.status} at ${card.station}) in ${cwd}` } })
915
+ prompt = takeOverPrompt(card, cwd)
916
+ say(`taking over card ${card.card_id} in ${cwd}${card.last_bundle ? ` from bundle ${card.last_bundle}` : ''}`)
917
+ }
661
918
  let legArgs = args
919
+ let legModel = startModel
920
+ let legResume = null
662
921
  let exit = 0
663
922
  // the first leg is the agent the human chose: its harness is prepared per
664
923
  // policy and recorded, never refused (strict applies to hand-offs)
@@ -667,7 +926,7 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
667
926
  // bounds a wait; a normal session runs one leg and exits
668
927
  for (let leg = 0; ; leg++) {
669
928
  const s = readSession(sid)
670
- const r = await runLeg({ agent, account, args: legArgs, session: s, prompt, boardUrl: board.url, autoApprove })
929
+ const r = await runLeg({ agent, account, args: legArgs, session: s, prompt, boardUrl: board.url, autoApprove, model: legModel, resume: legResume })
671
930
  if (r.reason === 'exit') { exit = r.code ?? 0; break }
672
931
  // limit or handoff: bundle, choose next, go again in this terminal
673
932
  const cur = readSession(sid)
@@ -682,7 +941,7 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
682
941
  const excluded = []
683
942
  // the destination a human picked on the board, if they picked one
684
943
  const prefer = r.target ?? null
685
- let claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded, prefer })
944
+ let claim = claimHandoffChoice({ sid, agent, account, model: cur.model ?? null, installed, bundle, reason: r.reason, exclude: excluded, prefer, automatic: r.reason !== 'handoff' })
686
945
  let choice = claim.choice
687
946
  let cancelled = false
688
947
  let blocked = false
@@ -700,10 +959,13 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
700
959
  say(`every option is out. First back: ${label} at ${first ? fmtReset(first.resets_at) : 'unknown'}`)
701
960
  for (const o of all) say(` ${o.agent}${o.account !== 'default' ? '/' + o.account : ''}: resets ${fmtReset(o.resets_at)}`)
702
961
  say(`waiting for ${label}; Ctrl-C to quit`)
703
- updateSession(sid, { status: 'waiting', all_out: all, waiting: first ? { agent: first.agent, account: first.account, resets_at: first.resets_at, since: new Date().toISOString() } : null }, { event: { type: 'all_out', summary: `every option is out; waiting for ${label} at ${first ? fmtReset(first.resets_at) : 'unknown'}` } })
962
+ // `type: 'reset'` tells this apart from the Notification hook's
963
+ // `waiting` (a human being waited on). Same key, two shapes, one
964
+ // discriminator; see the field comment in src/sessions.mjs.
965
+ updateSession(sid, { status: 'waiting', all_out: all, waiting: first ? { type: 'reset', agent: first.agent, account: first.account, resets_at: first.resets_at, since: new Date().toISOString() } : null }, { event: { type: 'all_out', summary: `every option is out; waiting for ${label} at ${first ? fmtReset(first.resets_at) : 'unknown'}` } })
704
966
  const r2 = await waitInTerminal({ sid, label, resetsAt: first?.resets_at ?? null })
705
967
  if (r2 === 'cancelled') { cancelled = true; break }
706
- claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded, prefer })
968
+ claim = claimHandoffChoice({ sid, agent, account, model: cur.model ?? null, installed, bundle, reason: r.reason, exclude: excluded, prefer, automatic: r.reason !== 'handoff' })
707
969
  choice = claim.choice
708
970
  }
709
971
  if (cancelled) {
@@ -719,7 +981,7 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
719
981
  if (prepared.proceed) break
720
982
  excluded.push(choice.next)
721
983
  say(`${choice.next.agent} refused by the strict harness policy: ${prepared.reason ?? prepared.state}`)
722
- claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded, prefer })
984
+ claim = claimHandoffChoice({ sid, agent, account, model: cur.model ?? null, installed, bundle, reason: r.reason, exclude: excluded, prefer, automatic: r.reason !== 'handoff' })
723
985
  choice = claim.choice
724
986
  if (!choice.next) blocked = true
725
987
  }
@@ -735,11 +997,7 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
735
997
  // refuse it, and a terminal that quietly went somewhere else is the kind
736
998
  // of surprise this board exists to remove.
737
999
  if (prefer && !choice.preferred_taken) {
738
- const asked = `${prefer.agent}${prefer.account !== 'default' ? '/' + prefer.account : ''}`
739
- const got = `${next.agent}${next.account !== 'default' ? '/' + next.account : ''}`
740
- const why = excluded.some((x) => x.agent === prefer.agent && x.account === prefer.account)
741
- ? 'the strict harness policy refused it'
742
- : `it is at its limit until ${fmtReset(readUsage(prefer.agent, prefer.account).limited_until)}`
1000
+ const { asked, got, why } = pickedAside({ prefer, next, choice, excluded })
743
1001
  say(`${asked} was picked but ${why}; handing off to ${got} instead`)
744
1002
  appendEvent(sid, { type: 'status', summary: `${asked} was picked for this hand-off but ${why}; ${got} took it instead` })
745
1003
  }
@@ -751,8 +1009,29 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
751
1009
  exit = 3
752
1010
  break
753
1011
  }
754
- appendEvent(sid, { type: 'handoff', summary: `${agent}${account !== 'default' ? '/' + account : ''} → ${next.agent}${next.account !== 'default' ? '/' + next.account : ''}${bundle ? ` (bundle ${bundle.id})` : ''}` })
755
- if (bundle) {
1012
+ // Every rung the ladder walked past, in the ledger, with the reason: a
1013
+ // terminal that skipped claude/fable because credits are off must say so
1014
+ // (B.3). "not installed" is left out: that one is about this machine, not
1015
+ // about this hand-off, and it would repeat on every leg.
1016
+ for (const why of choice.reasons ?? []) {
1017
+ if (why.reason === 'not installed on this machine') continue
1018
+ const line = skipLine(why)
1019
+ say(line)
1020
+ appendEvent(sid, { type: 'status', summary: line })
1021
+ }
1022
+ // The one hand-off that keeps the conversation: a claude downshift with the
1023
+ // agent's own session id on the record. `--resume <id> --model <alias>`
1024
+ // starts the next leg inside the same conversation, so the bundle is not
1025
+ // written into a prompt and nothing is re-explained. Every other rung takes
1026
+ // the bundle: an upshift back to fable (which would re-read the whole
1027
+ // context at fable's rate), a second account, and codex, whose `resume`
1028
+ // subcommand exists but has never been seen composing with `-m` here.
1029
+ const fromRung = { agent, account, model: cur.model ?? null }
1030
+ const keepsConversation = Boolean(next.agent === 'claude' && isDownshift(fromRung, next) && cur.agent_session_id)
1031
+ appendEvent(sid, { type: 'handoff', summary: `${rungLabel(fromRung)} → ${rungLabel(next)}${keepsConversation ? ' (kept the conversation)' : bundle ? ` (bundle ${bundle.id})` : ''}` })
1032
+ if (keepsConversation) {
1033
+ prompt = null
1034
+ } else if (bundle) {
756
1035
  prompt = resumePrompt(cur, bundle, next)
757
1036
  } else {
758
1037
  const delta = sessionCommitDelta(workRoot(cur) ?? cur.cwd, cur)
@@ -761,13 +1040,16 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
761
1040
  : 'Check git status and git diff, then continue the work.'
762
1041
  prompt = `You are taking over an interactive coding session from ${agent}.${existsSync(notesFile) ? ` Read ${notesFile} in this directory first (the previous agent's notes: task, last messages, dirty files).` : ''} ${fallbackAction} The task: ${cur.task ?? 'see the recent changes'}`
763
1042
  }
764
- say(`starting ${next.agent}${next.account !== 'default' ? '/' + next.account : ''} in this terminal from the bundle`)
1043
+ say(`starting ${rungLabel(next)} in this terminal ${keepsConversation ? 'with --resume: kept the conversation' : 'from the bundle'}`)
1044
+ legResume = keepsConversation ? cur.agent_session_id : null
1045
+ legModel = next.model ?? null
765
1046
  agent = next.agent; account = next.account; legArgs = []
766
1047
  // the chain is what comes after the agent now taking over, not after the
767
1048
  // one that started the session: the card's "next" names a live option
768
1049
  updateSession(sid, (fresh) => {
769
1050
  const freshOrder = normalizeHandoffOrder(fresh.handoff_order)
770
- return { lineage: { from: cur.agent, to: next.agent }, chain: candidates({ agent: next.agent, account: next.account, accounts: readAccounts(), order: freshOrder }) }
1051
+ const freshLadder = ladderFor(fresh)
1052
+ return { lineage: { from: cur.agent, to: next.agent }, chain: candidates({ agent: next.agent, account: next.account, model: next.model ?? null, accounts: readAccounts(), order: freshOrder, ladder: freshLadder }) }
771
1053
  })
772
1054
  }
773
1055
  const fin = readSession(sid)