@ucsandman/legcli 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +158 -67
  3. package/bin/leg.mjs +168 -18
  4. package/docs/DECISIONS.md +10 -0
  5. package/docs/DEMO.md +20 -14
  6. package/docs/DEVIATIONS.md +1 -0
  7. package/docs/ERRORS.md +94 -0
  8. package/docs/ROADMAP-v2.md +69 -11
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/adapters.md +93 -11
  11. package/docs/board-guide.md +401 -66
  12. package/docs/cli-contracts.md +235 -22
  13. package/docs/concepts.md +167 -19
  14. package/docs/configuration.md +113 -5
  15. package/docs/faq.md +21 -5
  16. package/docs/getting-started.md +15 -11
  17. package/docs/redesign-2026-09-17.md +477 -0
  18. package/docs/screenshots/background-1280.png +0 -0
  19. package/docs/screenshots/board-400px.png +0 -0
  20. package/docs/screenshots/board-details-open.png +0 -0
  21. package/docs/screenshots/board-drawer.png +0 -0
  22. package/docs/screenshots/board-handoff.png +0 -0
  23. package/docs/screenshots/board-running.png +0 -0
  24. package/docs/screenshots/capacity-drawer-1280.png +0 -0
  25. package/docs/screenshots/settings-ladder-1280.png +0 -0
  26. package/docs/screenshots/terminals-1280.png +0 -0
  27. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  28. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  29. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  30. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  31. package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
  32. package/fixtures/live/claude/resume-model-probe.json +20 -0
  33. package/fixtures/live/claude/usage-oauth.json +87 -0
  34. package/fixtures/live/grok/cmd.txt +1 -1
  35. package/fixtures/live/grok/parsed.json +6 -3
  36. package/fixtures/live/grok/run.json +22 -10
  37. package/fixtures/verified.json +8 -1
  38. package/package.json +3 -2
  39. package/scripts/build-docs-site.mjs +4 -4
  40. package/scripts/probe.mjs +2 -1
  41. package/scripts/seed-fake-cards.mjs +59 -6
  42. package/scripts/seed-wes-board.mjs +81 -12
  43. package/src/accounts.mjs +6 -1
  44. package/src/adapters/cli.mjs +130 -0
  45. package/src/adapters/custom.mjs +271 -0
  46. package/src/adapters/grok.mjs +51 -10
  47. package/src/adapters/index.mjs +34 -7
  48. package/src/attach.mjs +350 -42
  49. package/src/audit.mjs +118 -0
  50. package/src/board/audit.js +123 -0
  51. package/src/board/board.css +134 -9
  52. package/src/board/board.js +482 -106
  53. package/src/board/index.html +89 -7
  54. package/src/board/sessions.js +1371 -113
  55. package/src/buckets.mjs +101 -0
  56. package/src/cards.mjs +9 -1
  57. package/src/chain.mjs +13 -0
  58. package/src/hook.mjs +7 -1
  59. package/src/ledger.mjs +10 -2
  60. package/src/orchestrator.mjs +13 -4
  61. package/src/preferences.mjs +214 -5
  62. package/src/scheduler.mjs +24 -1
  63. package/src/server.mjs +615 -50
  64. package/src/sessions.mjs +17 -1
  65. package/src/share.mjs +66 -6
  66. package/src/taps/claude-usage.mjs +91 -2
  67. package/src/taps/claude.mjs +144 -5
  68. package/src/taps/codex.mjs +23 -3
  69. package/src/taps/grok.mjs +4 -0
  70. package/src/usage.mjs +424 -13
package/src/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 }
@@ -263,8 +407,11 @@ export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd,
263
407
  }
264
408
 
265
409
  // ---- one agent leg ----
266
- // Returns { reason: 'exit'|'limit'|'handoff', code }
267
- async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApprove = resolveAutoApprove() }) {
410
+ // Returns { reason: 'exit'|'limit'|'handoff', code, target } — `target` is the
411
+ // destination a human picked on the board ("Hand off now to codex"), carried
412
+ // out to the loop below, which is what chooses the next leg.
413
+
414
+ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApprove = resolveAutoApprove(), model = null, resume = null }) {
268
415
  const sid = session.session_id
269
416
  refreshAccount(agent, account)
270
417
  // A handoff happens when the limit hits, which is usually when nobody is
@@ -274,8 +421,8 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
274
421
  const trust = ensureTrust(agent, session.cwd, { cwd: session.cwd })
275
422
  const trusted = trustLine(trust)
276
423
  if (trusted) { say(trusted); appendEvent(sid, { type: 'trust', summary: trusted }) }
277
- const spec = await spawnSpec(agent, { account, args, sessionId: sid, prompt, cwd: session.cwd, autoApprove })
278
- 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' : ''}` })
279
426
  const startedMs = Date.now()
280
427
  const turnsAtLegStart = session.turns ?? 0
281
428
  // agy appends to one log for the whole session: a second agy leg reads from
@@ -284,6 +431,12 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
284
431
  const agyTail = agyLog ? createTail(agyLog, { from: logSize(agyLog) }) : null
285
432
  const grokLog = agent === 'grok' ? join(sessionDir(sid), 'grok.log') : null
286
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
+ }
287
440
  let child
288
441
  try {
289
442
  child = spawn(spec.bin, spec.args, { cwd: spec.cwd, env: spec.env, stdio: 'inherit', windowsHide: false })
@@ -293,7 +446,9 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
293
446
  }
294
447
  // every leg starts on its own card: the agent that just left takes its
295
448
  // percentages, its warning and its usage source with it
296
- 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 })
297
452
 
298
453
  // taps
299
454
  let rollout = null; let tail = null
@@ -304,7 +459,7 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
304
459
  rollout = { path: session.transcript_path, meta: { id: session.agent_session_id } }
305
460
  tail = createTail(rollout.path, { from: logSize(rollout.path) })
306
461
  }
307
- let polls = 0; let warned = false
462
+ let polls = 0; let warned = false; let stoodDown = false
308
463
  let stop = null
309
464
  const done = new Promise((res) => { stop = res })
310
465
  child.on('error', (err) => { appendEvent(sid, { type: 'error', summary: `${agent} spawn error: ${err.message}` }); stop({ reason: 'exit', code: 127 }) })
@@ -322,10 +477,20 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
322
477
  if (!isCurrentLeg(s, { pid: child.pid, agent, account })) return
323
478
  // a 404, a body that is not JSON, or a shape with no window at all: the
324
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
+ }
325
488
  const usable = r.ok && r.limits && (r.limits.five_hour || r.limits.seven_day)
326
489
  if (usable) {
327
490
  recordUsage('claude', account, r.limits, 'claude usage endpoint')
328
- 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 })
329
494
  } else if (!s.usage_error) {
330
495
  const why = r.error ?? 'the usage endpoint answered with no window'
331
496
  updateSession(sid, { usage_error: why }, { event: { type: 'status', summary: `claude usage unavailable: ${why}` } })
@@ -382,7 +547,7 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
382
547
  // git: which files this session is touching, where trunk is
383
548
  if (polls % GIT_EVERY === 1) {
384
549
  const g = gitInfo(s.cwd)
385
- 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) }
386
551
  }
387
552
  // codex: find + tail the rollout
388
553
  if (agent === 'codex') {
@@ -394,7 +559,7 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
394
559
  if (tail) {
395
560
  const r = parseLines(tail.read())
396
561
  if (r.limits) {
397
- 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 })
398
563
  if (u.usage_applied) { patch.limits = r.limits; patch.last_activity = new Date().toISOString() }
399
564
  }
400
565
  const firstUser = r.messages.find((m) => m.role === 'user')
@@ -474,12 +639,24 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
474
639
  // record can carry both; End (stop entirely) is the stronger, latest intent
475
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 }
476
641
  if (ctl?.handoff) {
477
- updateSession(sid, { status: 'handing_off', handoff: { reason: `requested from the board${ctl.by ? ` by ${ctl.by}` : ''}`, at: new Date().toISOString(), by: ctl.by ?? null } }, { event: { type: 'handoff_requested', by: ctl.by ?? null, summary: `hand off requested from the board${ctl.by ? ` by ${ctl.by}` : ''}` } })
478
- clearInterval(timer); killTree(child.pid); restoreTerminal(); stop({ reason: 'handoff', code: null }); return
642
+ const picked = ctl.target && typeof ctl.target === 'object' && ctl.target.agent
643
+ ? { agent: String(ctl.target.agent), account: String(ctl.target.account ?? 'default'), ...(ctl.target.model ? { model: String(ctl.target.model) } : {}) }
644
+ : null
645
+ const toWhom = picked ? ` to ${rungLabel(picked)}` : ''
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}` : ''}` } })
647
+ clearInterval(timer); killTree(child.pid); restoreTerminal(); stop({ reason: 'handoff', code: null, target: picked }); return
479
648
  }
480
649
  // a stale warning patch can overwrite status:'limit' from the hook, but the
481
650
  // limit OBJECT survives the clobber — hand off on either signal
482
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
+ }
483
660
  clearInterval(timer); killTree(child.pid); restoreTerminal(); stop({ reason: 'limit', code: null })
484
661
  }
485
662
  } catch (err) {
@@ -529,14 +706,43 @@ function messagesFor(agent, s) {
529
706
  // an order save is consumed here, or the editor sees handing_off and refuses.
530
707
  // No eligible choice leaves the session unclaimed so all-out waiting can keep
531
708
  // accepting order edits.
532
- export function claimHandoffChoice({ sid, agent, account, installed, bundle = null, reason = 'limit', nowS = Math.floor(Date.now() / 1000), exclude = [] }) {
533
- 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: [] }
534
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()
535
724
  const session = updateSession(sid, (current) => {
536
725
  const accounts = readAccounts()
537
726
  const order = normalizeHandoffOrder(current.handoff_order)
538
- choice = chooseNext({ agent, account, accounts, installed, order, nowS, exclude })
539
- if (!choice.next && isAvailable(readUsage(agent, account), nowS) && !exclude.some((x) => x.agent === agent && x.account === account)) choice = { next: { agent, account }, out: [] }
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
+ }
540
746
  if (!choice.next) return {}
541
747
  claimed = true
542
748
  return {
@@ -548,6 +754,10 @@ export function claimHandoffChoice({ sid, agent, account, installed, bundle = nu
548
754
  to: choice.next,
549
755
  bundle_id: bundle?.id ?? null,
550
756
  reason: reason === 'limit' ? 'usage limit' : 'requested',
757
+ // what was asked for, beside what was chosen: when a picked
758
+ // destination walled between the click and the hand-off, the card
759
+ // must say so rather than look like the pick was ignored
760
+ requested_to: prefer ?? null,
551
761
  at: new Date().toISOString(),
552
762
  },
553
763
  }
@@ -555,6 +765,25 @@ export function claimHandoffChoice({ sid, agent, account, installed, bundle = nu
555
765
  return { choice, claimed, session }
556
766
  }
557
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
+
558
787
  // The portable harness, decided before a leg starts (src/harness/index.mjs).
559
788
  // Off by default: then this records nothing and changes nothing. On, it
560
789
  // carries the source agent's working environment to the agent about to run,
@@ -581,8 +810,27 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
581
810
  // affected; only what Leg adds is licensed.
582
811
  const ent = entitlement()
583
812
  if (!allows(ent, 'run')) { say(describeLicense(ent)); return 4 }
584
- // --no-worktree is Leg's flag, not the agent's: it never passes through
585
- 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)
586
834
  args = args.filter((a) => a !== '--no-worktree')
587
835
  let autoApproveCli = null
588
836
  if (args.includes('--no-auto-approve')) {
@@ -593,11 +841,16 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
593
841
  args = args.filter((a) => a !== '--auto-approve')
594
842
  }
595
843
  const autoApprove = resolveAutoApprove({ cliFlag: autoApproveCli })
596
- const cwd = cwdOpt ? realPath(cwdOpt) : process.cwd()
844
+ const cwd = card ? realPath(cardWorkRoot(card)) : (cwdOpt ? realPath(cwdOpt) : process.cwd())
597
845
  const board = await ensureBoard({ open })
598
846
  let accounts = readAccounts()
599
847
  const installed = await installedAgents()
600
- 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
601
854
  let account = process.env.LEG_ACCOUNT || process.env.BATON_ACCOUNT || 'default'
602
855
  if (!(accounts[agent] ?? ['default']).includes(account)) { say(`no ${agent} account "${account}"; using default`); account = 'default' }
603
856
  // A persisted wall is only a cache. Ask Codex's read-only account endpoint
@@ -612,20 +865,27 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
612
865
  await fetchGrokUsage({ configDir: grokHome }).catch(() => {})
613
866
  }
614
867
  // Start on an account that is not at its wall, if we already know one is.
868
+ let startModel = null
615
869
  const nowS = Math.floor(Date.now() / 1000)
616
870
  const u0 = readUsage(agent, account)
617
871
  if (u0.limited_until && u0.limited_until > nowS) {
618
- const alt = chooseNext({ agent, account, accounts, installed, order: handoffOrder, nowS })
619
- 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 }
620
877
  else say(`${agent} (${account}) is at its limit until ${fmtReset(u0.limited_until)}; starting anyway (every option is out)`)
621
878
  }
622
879
  const g = gitInfo(cwd)
623
880
  const sid = newSessionId(agent)
624
- const chain = candidates({ agent, account, accounts, order: handoffOrder })
881
+ const chain = candidates({ agent, account, accounts, order: handoffOrder, ladder: handoffLadder, model: startModel ?? modelFromArgs(agent, args) })
625
882
  // record the session BEFORE cutting a worktree, so a crash or Ctrl-C during
626
883
  // `git worktree add` still leaves a card (with a Remove button), never a
627
884
  // silent orphan under .baton-worktrees with no record and no button
628
- 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 })
629
889
  if (continued) {
630
890
  // the agent's own id and transcript are known before the first turn, so
631
891
  // history dedups this leg against the conversation it continues at once
@@ -647,7 +907,17 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
647
907
  }
648
908
 
649
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
+ }
650
918
  let legArgs = args
919
+ let legModel = startModel
920
+ let legResume = null
651
921
  let exit = 0
652
922
  // the first leg is the agent the human chose: its harness is prepared per
653
923
  // policy and recorded, never refused (strict applies to hand-offs)
@@ -656,7 +926,7 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
656
926
  // bounds a wait; a normal session runs one leg and exits
657
927
  for (let leg = 0; ; leg++) {
658
928
  const s = readSession(sid)
659
- 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 })
660
930
  if (r.reason === 'exit') { exit = r.code ?? 0; break }
661
931
  // limit or handoff: bundle, choose next, go again in this terminal
662
932
  const cur = readSession(sid)
@@ -669,7 +939,9 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
669
939
  const notesFile = join(workRoot(cur) ?? cur.cwd, '.leg', `session-${sid}.md`)
670
940
  // destinations the strict harness policy refused during this hand-off
671
941
  const excluded = []
672
- let claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded })
942
+ // the destination a human picked on the board, if they picked one
943
+ const prefer = r.target ?? null
944
+ let claim = claimHandoffChoice({ sid, agent, account, model: cur.model ?? null, installed, bundle, reason: r.reason, exclude: excluded, prefer, automatic: r.reason !== 'handoff' })
673
945
  let choice = claim.choice
674
946
  let cancelled = false
675
947
  let blocked = false
@@ -687,10 +959,13 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
687
959
  say(`every option is out. First back: ${label} at ${first ? fmtReset(first.resets_at) : 'unknown'}`)
688
960
  for (const o of all) say(` ${o.agent}${o.account !== 'default' ? '/' + o.account : ''}: resets ${fmtReset(o.resets_at)}`)
689
961
  say(`waiting for ${label}; Ctrl-C to quit`)
690
- 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'}` } })
691
966
  const r2 = await waitInTerminal({ sid, label, resetsAt: first?.resets_at ?? null })
692
967
  if (r2 === 'cancelled') { cancelled = true; break }
693
- claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded })
968
+ claim = claimHandoffChoice({ sid, agent, account, model: cur.model ?? null, installed, bundle, reason: r.reason, exclude: excluded, prefer, automatic: r.reason !== 'handoff' })
694
969
  choice = claim.choice
695
970
  }
696
971
  if (cancelled) {
@@ -706,7 +981,7 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
706
981
  if (prepared.proceed) break
707
982
  excluded.push(choice.next)
708
983
  say(`${choice.next.agent} refused by the strict harness policy: ${prepared.reason ?? prepared.state}`)
709
- claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded })
984
+ claim = claimHandoffChoice({ sid, agent, account, model: cur.model ?? null, installed, bundle, reason: r.reason, exclude: excluded, prefer, automatic: r.reason !== 'handoff' })
710
985
  choice = claim.choice
711
986
  if (!choice.next) blocked = true
712
987
  }
@@ -717,6 +992,15 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
717
992
  break
718
993
  }
719
994
  const next = choice.next
995
+ // A pick that could not be taken is never silent: between the click and
996
+ // this moment that account can wall, or the strict harness policy can
997
+ // refuse it, and a terminal that quietly went somewhere else is the kind
998
+ // of surprise this board exists to remove.
999
+ if (prefer && !choice.preferred_taken) {
1000
+ const { asked, got, why } = pickedAside({ prefer, next, choice, excluded })
1001
+ say(`${asked} was picked but ${why}; handing off to ${got} instead`)
1002
+ appendEvent(sid, { type: 'status', summary: `${asked} was picked for this hand-off but ${why}; ${got} took it instead` })
1003
+ }
720
1004
  // bound the number of hand-offs in one terminal so a chain that limits
721
1005
  // instantly can never loop forever; stopping is explicit, not a silent exit 0
722
1006
  if (leg >= 11) {
@@ -725,8 +1009,29 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
725
1009
  exit = 3
726
1010
  break
727
1011
  }
728
- appendEvent(sid, { type: 'handoff', summary: `${agent}${account !== 'default' ? '/' + account : ''} → ${next.agent}${next.account !== 'default' ? '/' + next.account : ''}${bundle ? ` (bundle ${bundle.id})` : ''}` })
729
- 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) {
730
1035
  prompt = resumePrompt(cur, bundle, next)
731
1036
  } else {
732
1037
  const delta = sessionCommitDelta(workRoot(cur) ?? cur.cwd, cur)
@@ -735,13 +1040,16 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
735
1040
  : 'Check git status and git diff, then continue the work.'
736
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'}`
737
1042
  }
738
- 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
739
1046
  agent = next.agent; account = next.account; legArgs = []
740
1047
  // the chain is what comes after the agent now taking over, not after the
741
1048
  // one that started the session: the card's "next" names a live option
742
1049
  updateSession(sid, (fresh) => {
743
1050
  const freshOrder = normalizeHandoffOrder(fresh.handoff_order)
744
- 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 }) }
745
1053
  })
746
1054
  }
747
1055
  const fin = readSession(sid)