@ucsandman/legcli 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/README.md +95 -65
  3. package/bin/leg.mjs +123 -14
  4. package/docs/DECISIONS.md +18 -0
  5. package/docs/DEMO.md +20 -14
  6. package/docs/DEVIATIONS.md +1 -0
  7. package/docs/ERRORS.md +68 -0
  8. package/docs/ROADMAP-v2.md +50 -5
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/board-guide.md +529 -96
  11. package/docs/cli-contracts.md +241 -5
  12. package/docs/concepts.md +167 -19
  13. package/docs/configuration.md +65 -1
  14. package/docs/faq.md +21 -5
  15. package/docs/getting-started.md +15 -11
  16. package/docs/redesign-2026-09-17.md +477 -0
  17. package/docs/screenshots/background-1280.png +0 -0
  18. package/docs/screenshots/board-400px.png +0 -0
  19. package/docs/screenshots/board-details-open.png +0 -0
  20. package/docs/screenshots/board-drawer.png +0 -0
  21. package/docs/screenshots/board-handoff.png +0 -0
  22. package/docs/screenshots/board-running.png +0 -0
  23. package/docs/screenshots/capacity-drawer-1280.png +0 -0
  24. package/docs/screenshots/floor.png +0 -0
  25. package/docs/screenshots/new-card-dialog.png +0 -0
  26. package/docs/screenshots/settings-ladder-1280.png +0 -0
  27. package/docs/screenshots/terminals-1280.png +0 -0
  28. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  29. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  30. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  31. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  32. package/fixtures/live/claude/resume-model-probe.json +20 -0
  33. package/fixtures/live/claude/usage-oauth.json +87 -0
  34. package/fixtures/verified.json +1 -1
  35. package/package.json +3 -2
  36. package/scripts/board-jump-probe.mjs +335 -0
  37. package/scripts/seed-fake-cards.mjs +59 -6
  38. package/scripts/seed-wes-board.mjs +81 -12
  39. package/src/accounts.mjs +6 -1
  40. package/src/attach.mjs +378 -93
  41. package/src/audit.mjs +1 -1
  42. package/src/board/board.css +203 -11
  43. package/src/board/board.js +664 -200
  44. package/src/board/entry.js +343 -0
  45. package/src/board/floor.html +51 -39
  46. package/src/board/floor.js +585 -73
  47. package/src/board/index.html +122 -45
  48. package/src/board/sessions.js +1569 -141
  49. package/src/board/strip.js +163 -0
  50. package/src/buckets.mjs +101 -0
  51. package/src/cards.mjs +9 -1
  52. package/src/chain.mjs +13 -0
  53. package/src/hook.mjs +7 -1
  54. package/src/ledger.mjs +10 -2
  55. package/src/models.mjs +265 -0
  56. package/src/orchestrator.mjs +13 -4
  57. package/src/preferences.mjs +278 -5
  58. package/src/scheduler.mjs +24 -1
  59. package/src/server.mjs +625 -78
  60. package/src/sessions.mjs +17 -1
  61. package/src/taps/claude-usage.mjs +107 -3
  62. package/src/taps/claude.mjs +144 -5
  63. package/src/taps/codex.mjs +23 -3
  64. package/src/usage-poll.mjs +260 -0
  65. package/src/usage.mjs +439 -12
package/src/attach.mjs CHANGED
@@ -14,28 +14,31 @@ 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, usageIsStale, 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'
32
+ import { fetchClaudeUsage } from './taps/claude-usage.mjs'
29
33
  import { scanLog, promptsSince, logSize } from './taps/agy.mjs'
30
34
  import { fetchGrokUsage, scanLog as scanGrokLog, promptsSince as grokPromptsSince } from './taps/grok.mjs'
31
- import { fetchClaudeUsage } from './taps/claude-usage.mjs'
32
35
  import { saveSessionBundle, resumePrompt, sessionCommitDelta } from './bundle.mjs'
33
36
  import { endSessionPointer } from './resume.mjs'
34
37
  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
 
@@ -53,12 +56,18 @@ const SERVER = resolveServer()
53
56
  const POLL_MS = Number(process.env.LEG_ATTACH_POLL_MS || process.env.BATON_ATTACH_POLL_MS || 2000)
54
57
  const GIT_EVERY = 3 // polls
55
58
  const USAGE_MS = Number(process.env.LEG_USAGE_POLL_MS || process.env.BATON_USAGE_POLL_MS || 60000)
59
+ // One switch for every usage read in a leg's process tree, the board's poller
60
+ // and this terminal's fallback alike (src/server.mjs usageAgentsFor). The
61
+ // suites set it so no test asks a real endpoint about this machine's logins;
62
+ // the *_BIN stubs used to do that by accident, and switched polling off for
63
+ // real users who had simply moved their claude.
64
+ const NO_USAGE_POLL = (process.env.LEG_NO_USAGE_POLL || process.env.BATON_NO_USAGE_POLL) === '1'
56
65
  const say = (line) => process.stderr.write(`[leg] ${line}\n`)
57
66
 
58
67
  async function refreshCodexUsage(account, codexHome, { timeoutMs = 8000, signal = null } = {}) {
59
68
  const r = await readCodexUsage({ codexHome, timeoutMs, signal })
60
69
  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 })
70
+ 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
71
  return { ...r, usage: u }
63
72
  }
64
73
 
@@ -156,6 +165,20 @@ export function gitInfo(cwd) {
156
165
  }
157
166
  }
158
167
 
168
+ // How many commits this checkout is ahead of where the work started: the
169
+ // upstream branch when the checkout tracks one, else the commit HEAD was at
170
+ // when the session began. null when this is not a repo, when there is neither
171
+ // an upstream nor a recorded start, or when git cannot answer. A count that
172
+ // could not be taken is never printed as a zero (redesign A.4 row 8).
173
+ export function aheadCount(cwd, fallbackBase = null) {
174
+ const upstream = git(cwd, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])
175
+ const base = (upstream && upstream.trim()) || fallbackBase
176
+ if (!base) return null
177
+ const n = git(cwd, ['rev-list', '--count', `${base}..HEAD`])
178
+ const parsed = parseInt(String(n ?? '').trim(), 10)
179
+ return Number.isFinite(parsed) ? parsed : null
180
+ }
181
+
159
182
  // ---- collisions ----
160
183
  // Two agents in one working tree write over each other's files. When another
161
184
  // live session already works in this checkout, this one gets its own:
@@ -226,8 +249,126 @@ function restoreTerminal() {
226
249
  try { process.stdout.write(TERMINAL_RESET) } catch {}
227
250
  }
228
251
 
252
+ // ---- model and terminal title ----
253
+ // The model this leg resolved to, from the argv the human actually passed:
254
+ // `--model x`, `-m x`, or the `--model=x` form. No default is invented — a
255
+ // model token on a row that nobody chose is a wrong number in disguise, so the
256
+ // answer for a bare `leg claude` is null until the transcript says otherwise.
257
+ export function modelFromArgs(agent, args = []) {
258
+ const flag = modelFlagFor(agent)
259
+ const names = flag === '-m' ? ['-m', '--model'] : [flag, '-m'].filter(Boolean)
260
+ for (let i = 0; i < args.length; i++) {
261
+ const a = String(args[i] ?? '')
262
+ for (const n of names) {
263
+ if (a === n && args[i + 1] && !String(args[i + 1]).startsWith('-')) return modelAlias(agent, args[i + 1])
264
+ if (a.startsWith(n + '=')) return modelAlias(agent, a.slice(n.length + 1))
265
+ }
266
+ }
267
+ return null
268
+ }
269
+
270
+ export const shortId = (sessionId) => String(sessionId ?? '').split('-').pop()
271
+
272
+ // ---- taking over a background card (redesign C.4) ----
273
+ // Leg's own flags never reach the agent's argv, so they are lifted out of the
274
+ // pass-through list before anything else looks at it. Both spellings, because
275
+ // a human copying `--resume-card=<id>` out of a shell history is not wrong.
276
+ export function takeFlagValue(args, flag) {
277
+ const out = []
278
+ let value = null
279
+ for (let i = 0; i < args.length; i++) {
280
+ const a = String(args[i] ?? '')
281
+ if (a === flag) {
282
+ const next = args[i + 1]
283
+ if (next !== undefined && !String(next).startsWith('-')) { value = String(next); i++ }
284
+ continue
285
+ }
286
+ if (a.startsWith(flag + '=')) { value = a.slice(flag.length + 1); continue }
287
+ out.push(args[i])
288
+ }
289
+ return { args: out, value }
290
+ }
291
+
292
+ // A card id in full, or the short tail the board prints. Ambiguity is an error
293
+ // rather than a pick: opening a terminal in the wrong worktree is the one
294
+ // outcome this whole feature exists to avoid.
295
+ export function resolveCardId(want, cards = null) {
296
+ const wanted = String(want ?? '').trim()
297
+ if (!wanted) return { id: null, matches: [] }
298
+ if (readCard(wanted)) return { id: wanted, matches: [wanted] }
299
+ const ids = (cards ?? listCards()).map((c) => c.card_id)
300
+ const matches = ids.filter((id) => id === wanted || id.endsWith(`-${wanted}`) || id.includes(wanted))
301
+ return { id: matches.length === 1 ? matches[0] : null, matches }
302
+ }
303
+
304
+ // Where that card's work is: the worktree it recorded, else the one its id
305
+ // names, else the repository itself. Never a path that is not there.
306
+ export function cardWorkRoot(card) {
307
+ if (card?.worktree && existsSync(card.worktree)) return card.worktree
308
+ if (card?.repo) {
309
+ try { const p = worktreePath(card.repo, card.card_id); if (existsSync(p)) return p } catch { /* not a repo any more */ }
310
+ }
311
+ // never the main checkout: a card's work belongs in its own worktree, and a
312
+ // terminal opened in `card.repo` would edit the human's checkout under the
313
+ // card's name. The take-over route cuts a worktree before it hands out the
314
+ // command, so this is only reached for a card made by an older Leg.
315
+ return null
316
+ }
317
+
318
+ // The first prompt of a terminal that took a card over: the card's own bundle,
319
+ // loaded through the same CLI every hand-off uses, with the card's task above
320
+ // it. A bundle that will not load is said out loud and the terminal still opens.
321
+ export function takeOverPrompt(card, cwd) {
322
+ let loaded = ''
323
+ try { loaded = loadResume(cwd, card.last_bundle ?? 'latest') } catch { loaded = '' }
324
+ 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`
325
+ return loaded
326
+ ? `${head}${loaded}`
327
+ : `${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.`
328
+ }
329
+
330
+ // `leg#7f3a leg/main`: which terminal this window is, and where it is working.
331
+ // Degrades to the id alone rather than printing a place that is not a repo.
332
+ export function terminalTitle(session) {
333
+ const where = session?.repo_name ? `${session.repo_name}${session.branch ? '/' + session.branch : ''}` : null
334
+ return `leg#${shortId(session?.session_id)}${where ? ' ' + where : ''}`
335
+ }
336
+
337
+ // OSC 2 (set window title) for the CLIs with no title flag of their own.
338
+ // ASSUMED: a VT terminal keeps the title once the child starts drawing. codex,
339
+ // agy or grok may overwrite it with their own; nothing observed either way yet,
340
+ // and the probe is to start each one and read the tab (redesign E, row 4).
341
+ export function osc2(title) { return `\x1b]2;${printable(title)}\x07` }
342
+
343
+ // Claude Code can wait at the usage limit itself when the human's own settings
344
+ // re-enable autoContinueAtUsageLimit. Leg sets it false, but it does not own
345
+ // that file, so when the `quota_auto_resume_fired` Notification arrives the
346
+ // automatic hand-off stands down for that terminal: two waiters on one terminal
347
+ // is the failure to avoid (redesign B.5). A human pressing Hand off > is not
348
+ // affected — standing down is about what Leg does unasked.
349
+ export function handoffStoodDown(session) {
350
+ return session?.waiting?.type === 'quota_auto_resume' ? (session.waiting.message ?? 'Claude Code is waiting at the limit itself') : null
351
+ }
352
+
353
+ // The model flag for a rung, or nothing. Nothing when the rung names no model,
354
+ // when Leg does not know how that CLI spells one, or when the human already
355
+ // passed a model themselves: an argv the human wrote is never overwritten.
356
+ export function modelFlags(agent, args = [], model = null) {
357
+ if (!model) return []
358
+ const flag = modelFlagFor(agent)
359
+ if (!flag) return []
360
+ if (modelFromArgs(agent, args)) return []
361
+ return [flag, model]
362
+ }
363
+
229
364
  // ---- spawn spec per agent ----
230
- export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd, autoApprove = resolveAutoApprove() }) {
365
+ // `model` is the rung's model (B.3); `resume` is the agent's own session id for
366
+ // the one case where a hand-off keeps the conversation instead of the bundle:
367
+ // a claude downshift (`--resume <id> --model <alias>`, settled by
368
+ // fixtures/live/claude/resume-model-probe.json). codex has a `resume`
369
+ // subcommand too, but composing it with `-m` is ASSUMED, not observed (codex
370
+ // is walled on this machine until Saturday), so codex ships bundle-primed.
371
+ export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd, autoApprove = resolveAutoApprove(), model = null, resume = null }) {
231
372
  const adapter = await loadAdapter(agent)
232
373
  const { bin, viaNode, entry } = adapter.resolve()
233
374
  const argv = []
@@ -239,23 +380,32 @@ export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd,
239
380
  if (agent === 'claude') {
240
381
  const settings = writeSettings(sessionId, { statusLine: userStatusLine(process.env.CLAUDE_CONFIG_DIR || (account !== 'default' ? envFor('claude', account).CLAUDE_CONFIG_DIR : undefined)) })
241
382
  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)
383
+ // `-n, --name <name>`: "Set a display name for this session (shown in the
384
+ // prompt box, /resume picker, and terminal title)" — fixtures/help/claude.txt
385
+ // line 132, in the general Options section, not one of the flags marked
386
+ // "only works with --print", and two of its three surfaces (prompt box,
387
+ // /resume picker) exist only in interactive mode. A name the human passed
388
+ // themselves is never overwritten.
389
+ const named = args.some((a) => a === '-n' || a === '--name' || String(a).startsWith('--name='))
390
+ const nameFlags = named ? [] : ['-n', terminalTitle(readSession(sessionId) ?? { session_id: sessionId })]
391
+ const resumeFlags = resume ? ['--resume', String(resume)] : []
392
+ argv.push(...resumeFlags, ...args, ...modelFlags(agent, args, model), ...autoFlags, ...nameFlags, '--settings', settings)
243
393
  if (prompt) argv.push(prompt)
244
394
  } else if (agent === 'codex') {
245
395
  const hasApproval = args.includes('--ask-for-approval') || args.includes('-a') || args.some((x) => typeof x === 'string' && x.startsWith('--ask-for-approval='))
246
396
  const autoFlags = autoApprove && !hasApproval ? ['--ask-for-approval', 'never'] : []
247
- argv.push(...args, ...autoFlags)
397
+ argv.push(...args, ...modelFlags(agent, args, model), ...autoFlags)
248
398
  if (prompt) argv.push(prompt)
249
399
  } else if (agent === 'agy') {
250
400
  const log = join(sessionDir(sessionId), 'agy.log')
251
401
  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)
402
+ argv.push(...args, ...modelFlags(agent, args, model), ...autoFlags, '--log-file', log)
253
403
  if (prompt) argv.push('-i', prompt)
254
404
  } else if (agent === 'grok') {
255
405
  const log = join(sessionDir(sessionId), 'grok.log')
256
406
  const hasApprove = args.includes('--always-approve') || args.includes('--yolo') || args.includes('--approval-mode=yolo') // auto-approve check: not forbidden for interactive sessions
257
407
  const autoFlags = autoApprove && !hasApprove ? ['--always-approve'] : [] // auto-approve: not forbidden for interactive sessions
258
- argv.push(...args, ...autoFlags, '--debug-file', log)
408
+ argv.push(...args, ...modelFlags(agent, args, model), ...autoFlags, '--debug-file', log)
259
409
  if (prompt) argv.push(prompt)
260
410
  }
261
411
  const env = { ...sanitizeEnv(process.env, { interactive: true }), ...envFor(agent, account), LEG_SESSION: sessionId, BATON_SESSION: sessionId }
@@ -267,7 +417,7 @@ export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd,
267
417
  // destination a human picked on the board ("Hand off now to codex"), carried
268
418
  // out to the loop below, which is what chooses the next leg.
269
419
 
270
- async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApprove = resolveAutoApprove() }) {
420
+ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApprove = resolveAutoApprove(), model = null, resume = null }) {
271
421
  const sid = session.session_id
272
422
  refreshAccount(agent, account)
273
423
  // A handoff happens when the limit hits, which is usually when nobody is
@@ -277,8 +427,8 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
277
427
  const trust = ensureTrust(agent, session.cwd, { cwd: session.cwd })
278
428
  const trusted = trustLine(trust)
279
429
  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' : ''}` })
430
+ const spec = await spawnSpec(agent, { account, args, sessionId: sid, prompt, cwd: session.cwd, autoApprove, model, resume })
431
+ appendEvent(sid, { type: 'leg', summary: `${agent}${model ? '/' + model : ''} (${account}) starting${resume ? ' with the conversation it already had' : prompt ? ' from the handoff bundle' : ''}` })
282
432
  const startedMs = Date.now()
283
433
  const turnsAtLegStart = session.turns ?? 0
284
434
  // agy appends to one log for the whole session: a second agy leg reads from
@@ -287,6 +437,12 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
287
437
  const agyTail = agyLog ? createTail(agyLog, { from: logSize(agyLog) }) : null
288
438
  const grokLog = agent === 'grok' ? join(sessionDir(sid), 'grok.log') : null
289
439
  const grokTail = grokLog ? createTail(grokLog, { from: logSize(grokLog) }) : null
440
+ // claude takes `-n` (spawnSpec). The other three have no title flag, so Leg
441
+ // writes the title itself, once, before the child owns the terminal. Only on
442
+ // a TTY: into a pipe or a log this would be four stray control bytes.
443
+ if (agent !== 'claude' && process.stdout.isTTY) {
444
+ try { process.stdout.write(osc2(terminalTitle(session))) } catch {}
445
+ }
290
446
  let child
291
447
  try {
292
448
  child = spawn(spec.bin, spec.args, { cwd: spec.cwd, env: spec.env, stdio: 'inherit', windowsHide: false })
@@ -296,7 +452,9 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
296
452
  }
297
453
  // every leg starts on its own card: the agent that just left takes its
298
454
  // 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 })
455
+ // the model goes with it: the next leg's argv is the only thing Leg knows
456
+ // about the model until that agent's own transcript says otherwise
457
+ 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
458
 
301
459
  // taps
302
460
  let rollout = null; let tail = null
@@ -307,73 +465,81 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
307
465
  rollout = { path: session.transcript_path, meta: { id: session.agent_session_id } }
308
466
  tail = createTail(rollout.path, { from: logSize(rollout.path) })
309
467
  }
310
- let polls = 0; let warned = false
468
+ let polls = 0; let warned = false; let stoodDown = false
311
469
  let stop = null
312
470
  const done = new Promise((res) => { stop = res })
313
471
  child.on('error', (err) => { appendEvent(sid, { type: 'error', summary: `${agent} spawn error: ${err.message}` }); stop({ reason: 'exit', code: 127 }) })
314
472
  child.on('exit', (code) => stop({ reason: 'exit', code: code ?? -1 }))
315
473
 
316
- // claude: the 5h/7d percentages come from Claude Code's usage endpoint
317
- // (src/taps/claude-usage.mjs); the wall itself arrives through the
318
- // StopFailure hook.
474
+ // The percentages are NOT read here. One poller per login lives in the board
475
+ // (src/usage-poll.mjs) and writes `limits`, `usage_source` and `usage_error`
476
+ // onto this session: three terminals on one login used to ask the same
477
+ // endpoint three times a minute, draw a 429 every other minute, and print
478
+ // every one of them in this terminal's timeline.
479
+ //
480
+ // What is still this terminal's own: which model claude is actually
481
+ // answering on. The transcript path arrives on the SessionStart hook payload
482
+ // (src/taps/claude.mjs `handleHook`, `base`), so this only reads once Claude
483
+ // Code has told Leg where its jsonl is. A fallback off fable shows up here
484
+ // and nowhere else.
319
485
  let usageTimer = null
320
- const usageAbort = new AbortController()
321
486
  if (agent === 'claude') {
322
- const pollUsage = async () => {
323
- const r = await fetchClaudeUsage({ configDir: spec.env.CLAUDE_CONFIG_DIR || LAYOUT.claude.home() })
324
- const s = readSession(sid)
325
- if (!isCurrentLeg(s, { pid: child.pid, agent, account })) return
326
- // a 404, a body that is not JSON, or a shape with no window at all: the
327
- // card says usage unknown and the StopFailure hook still owns the limit
328
- const usable = r.ok && r.limits && (r.limits.five_hour || r.limits.seven_day)
329
- if (usable) {
330
- recordUsage('claude', account, r.limits, 'claude usage endpoint')
331
- updateSession(sid, { limits: r.limits, usage_source: 'claude usage endpoint', usage_error: null })
332
- } else if (!s.usage_error) {
333
- const why = r.error ?? 'the usage endpoint answered with no window'
334
- updateSession(sid, { usage_error: why }, { event: { type: 'status', summary: `claude usage unavailable: ${why}` } })
335
- }
336
- }
337
- pollUsage().catch(() => {})
338
- usageTimer = setInterval(() => pollUsage().catch(() => {}), USAGE_MS)
339
- usageTimer.unref?.()
340
- } else if (agent === 'codex' && !(process.env.LEG_CODEX_BIN || process.env.BATON_CODEX_BIN)) {
341
- const pollUsage = async () => {
342
- const r = await refreshCodexUsage(account, spec.env.CODEX_HOME || LAYOUT.codex.home(), { signal: usageAbort.signal })
487
+ const pollModel = () => {
343
488
  const s = readSession(sid)
344
489
  if (!isCurrentLeg(s, { pid: child.pid, agent, account })) return
345
- if (r.ok) {
346
- const patch = { limits: r.limits, usage_source: 'codex app-server account/rateLimits/read', usage_error: null }
347
- if (r.available === false) {
348
- patch.status = 'limit'
349
- patch.limit = { reason: 'usage_limit_exceeded', detail: 'Codex reports ordinary usage is unavailable', resets_at: r.usage.limited_until, at: r.observed_at }
350
- }
351
- updateSession(sid, patch)
352
- } else if (!s.usage_error) {
353
- updateSession(sid, { usage_error: r.error }, { event: { type: 'status', summary: `codex usage unavailable: ${r.error}` } })
490
+ const seen = modelFromTranscript(s.transcript_path, { agent: 'claude' })
491
+ if (seen && seen !== s.model) {
492
+ updateSession(sid, { model: seen }, { event: { type: 'status', summary: `claude is answering on ${seen}${s.model ? ` (was ${s.model})` : ''}` } })
354
493
  }
355
494
  }
356
- pollUsage().catch(() => {})
357
- usageTimer = setInterval(() => pollUsage().catch(() => {}), USAGE_MS)
495
+ const safely = () => { try { pollModel() } catch {} }
496
+ safely()
497
+ usageTimer = setInterval(safely, USAGE_MS)
358
498
  usageTimer.unref?.()
359
- } else if (agent === 'grok') {
499
+ }
500
+
501
+ // The near-wall warning below is computed from the percentages on this
502
+ // session, and for claude and grok those are written by the board's poller
503
+ // (src/usage-poll.mjs). With LEG_NO_BOARD=1, or a board that is down, nobody
504
+ // is reading that login at all, and the 85% warning never fired: no bell, no
505
+ // nudge, straight into the wall. So the terminal watches the login's own
506
+ // record and, when nothing has refreshed it for five minutes, reads the
507
+ // endpoint itself at the old once-a-minute cadence. A board that is polling
508
+ // keeps that record fresh, so with one up this costs a readUsage() a minute
509
+ // and no request. Said once, so a terminal doing its own reading is never a
510
+ // mystery.
511
+ let fallbackTimer = null
512
+ if ((agent === 'claude' || agent === 'grok') && !NO_USAGE_POLL) {
513
+ const source = agent === 'claude' ? 'claude usage endpoint' : 'grok billing proxy'
514
+ const readOwn = () => (agent === 'claude'
515
+ ? fetchClaudeUsage({ configDir: spec.env.CLAUDE_CONFIG_DIR || LAYOUT.claude.home() })
516
+ : fetchGrokUsage({ configDir: spec.env.GROK_HOME || LAYOUT.grok.home() }))
517
+ let announced = false
360
518
  const pollUsage = async () => {
361
- const configDir = spec.env.GROK_HOME || LAYOUT.grok.home()
362
- const r = await fetchGrokUsage({ configDir })
519
+ if (!isCurrentLeg(readSession(sid), { pid: child.pid, agent, account })) return
520
+ if (!usageIsStale(readUsage(agent, account))) return // a board is reading this login
521
+ if (!announced) { announced = true; say(`no board is reading ${agent} usage, so this terminal reads it itself once a minute`) }
522
+ const r = await readOwn()
363
523
  const s = readSession(sid)
364
524
  if (!isCurrentLeg(s, { pid: child.pid, agent, account })) return
365
525
  const usable = r.ok && r.limits && (r.limits.five_hour || r.limits.seven_day)
366
526
  if (usable) {
367
- recordUsage('grok', account, r.limits, 'grok billing proxy')
368
- updateSession(sid, { limits: r.limits, usage_source: 'grok billing proxy', usage_error: null })
527
+ recordUsage(agent, account, r.limits, source)
528
+ // the two windows the card has always carried; the buckets stay on the
529
+ // usage record, which is per login and not per terminal
530
+ const limits = agent === 'claude' ? { five_hour: r.limits.five_hour, seven_day: r.limits.seven_day } : r.limits
531
+ updateSession(sid, { limits, usage_source: source, usage_error: null })
369
532
  } else if (!s.usage_error) {
370
533
  const why = r.error ?? 'the usage endpoint answered with no window'
371
- updateSession(sid, { usage_error: why }, { event: { type: 'status', summary: `grok usage unavailable: ${why}` } })
534
+ updateSession(sid, { usage_error: why }, { event: { type: 'status', summary: `${agent} usage unavailable: ${why}` } })
372
535
  }
373
536
  }
374
- pollUsage().catch(() => {})
375
- usageTimer = setInterval(() => pollUsage().catch(() => {}), USAGE_MS)
376
- usageTimer.unref?.()
537
+ const safelyUsage = () => { pollUsage().catch(() => {}) }
538
+ // LEG_NO_BOARD=1 means nobody will ever poll this login, so the reading
539
+ // happens at once rather than leaving the terminal's first minute blind
540
+ if (!boardUrl) safelyUsage()
541
+ fallbackTimer = setInterval(safelyUsage, USAGE_MS)
542
+ fallbackTimer.unref?.()
377
543
  }
378
544
 
379
545
  const timer = setInterval(() => {
@@ -385,7 +551,7 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
385
551
  // git: which files this session is touching, where trunk is
386
552
  if (polls % GIT_EVERY === 1) {
387
553
  const g = gitInfo(s.cwd)
388
- if (g.repo) { patch.files_dirty = g.dirty; patch.head = g.head; patch.branch = g.branch }
554
+ 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
555
  }
390
556
  // codex: find + tail the rollout
391
557
  if (agent === 'codex') {
@@ -397,7 +563,7 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
397
563
  if (tail) {
398
564
  const r = parseLines(tail.read())
399
565
  if (r.limits) {
400
- const u = recordUsage('codex', account, r.limits, 'codex rollout token_count', { observed_at: r.limits_at })
566
+ const u = recordUsage('codex', account, { ...r.limits, facts: r.facts }, 'codex rollout token_count', { observed_at: r.limits_at })
401
567
  if (u.usage_applied) { patch.limits = r.limits; patch.last_activity = new Date().toISOString() }
402
568
  }
403
569
  const firstUser = r.messages.find((m) => m.role === 'user')
@@ -478,15 +644,23 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
478
644
  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
645
  if (ctl?.handoff) {
480
646
  const picked = ctl.target && typeof ctl.target === 'object' && ctl.target.agent
481
- ? { agent: String(ctl.target.agent), account: String(ctl.target.account ?? 'default') }
647
+ ? { agent: String(ctl.target.agent), account: String(ctl.target.account ?? 'default'), ...(ctl.target.model ? { model: String(ctl.target.model) } : {}) }
482
648
  : null
483
- const toWhom = picked ? ` to ${picked.agent}${picked.account !== 'default' ? '/' + picked.account : ''}` : ''
649
+ const toWhom = picked ? ` to ${rungLabel(picked)}` : ''
484
650
  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
651
  clearInterval(timer); killTree(child.pid); restoreTerminal(); stop({ reason: 'handoff', code: null, target: picked }); return
486
652
  }
487
653
  // a stale warning patch can overwrite status:'limit' from the hook, but the
488
654
  // limit OBJECT survives the clobber — hand off on either signal
489
655
  if ((next.status === 'limit' || next.limit) && (process.env.LEG_NO_HANDOFF || process.env.BATON_NO_HANDOFF) !== '1') {
656
+ // Claude Code is waiting at the limit itself: stand down rather than
657
+ // kill a child that is about to resume on its own. Said once, with the
658
+ // reason, so the terminal that did not hand off is never a mystery.
659
+ const standDown = handoffStoodDown(next)
660
+ if (standDown) {
661
+ if (!stoodDown) { stoodDown = true; say(standDown); appendEvent(sid, { type: 'status', summary: `hand-off stood down: ${standDown}` }) }
662
+ return
663
+ }
490
664
  clearInterval(timer); killTree(child.pid); restoreTerminal(); stop({ reason: 'limit', code: null })
491
665
  }
492
666
  } catch (err) {
@@ -496,9 +670,8 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
496
670
  timer.unref?.()
497
671
  const result = await done
498
672
  clearInterval(timer)
499
- usageAbort.abort()
500
673
  if (usageTimer) clearInterval(usageTimer)
501
- void boardUrl
674
+ if (fallbackTimer) clearInterval(fallbackTimer)
502
675
  return result
503
676
  }
504
677
 
@@ -536,14 +709,43 @@ function messagesFor(agent, s) {
536
709
  // an order save is consumed here, or the editor sees handing_off and refuses.
537
710
  // No eligible choice leaves the session unclaimed so all-out waiting can keep
538
711
  // 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: [] }
712
+ // `automatic` is the fact, not a guess from the shape of the call: only the
713
+ // usage limit (and the scheduler behind a card) hands off unasked. A human
714
+ // pressing Hand off now sends no destination at all when they take the default
715
+ // option, and inferring "automatic" from that missing target applied the
716
+ // reserve, the cost gate and climb-back to a hand-off the human asked for, on
717
+ // rungs the picker had just shown them as available (B.3).
718
+ 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 }) {
719
+ let choice = { next: null, out: [], reasons: [] }
541
720
  let claimed = false
721
+ const auto = automatic === null ? reason === 'limit' : Boolean(automatic)
722
+ // The machine's spending rules are read once, here: a rung that costs credits
723
+ // is skipped unless the human allowed it, the reserve applies to automatic
724
+ // hand-offs only, and climb-back decides whether an automatic hand-off may
725
+ // walk back UP the ladder (B.3, B.7).
726
+ const prefs = preferences ?? readPreferences()
542
727
  const session = updateSession(sid, (current) => {
543
728
  const accounts = readAccounts()
544
729
  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 }
730
+ // the terminal's own ladder, else the long-hand form of its order: a
731
+ // terminal started before ladders existed behaves exactly as it did.
732
+ const ladder = ladderFor(current)
733
+ const legModel = model ?? current.model ?? null
734
+ choice = chooseNext({
735
+ agent, account, model: legModel, accounts, installed, order, ladder, nowS, exclude, prefer,
736
+ maySpend: prefs.may_spend, reserve: prefs.reserve, climbBack: prefs.climb_back, automatic: auto,
737
+ })
738
+ // Nothing on the ladder: keep the login the terminal is already on, but
739
+ // only when it can really run the next leg. A model wall leaves the account
740
+ // open by design, so claiming {agent, account} with no model here respawned
741
+ // the CLI on the model that had just walled, walled again, and burned all
742
+ // twelve legs. The model rides the claimed rung for the same reason, and
743
+ // `out` is kept so the all-out wait below still has its reset clocks.
744
+ const own = readUsage(agent, account)
745
+ const ownOpen = isAvailable(own, nowS) && !(legModel && wallActive(own.walls?.[legModel], nowS))
746
+ if (!choice.next && ownOpen && !exclude.some((x) => x.agent === agent && x.account === account)) {
747
+ choice = { next: { agent, account, ...(legModel ? { model: legModel } : {}) }, out: choice.out ?? [], reasons: choice.reasons ?? [], preferred_taken: false }
748
+ }
547
749
  if (!choice.next) return {}
548
750
  claimed = true
549
751
  return {
@@ -566,6 +768,25 @@ export function claimHandoffChoice({ sid, agent, account, installed, bundle = nu
566
768
  return { choice, claimed, session }
567
769
  }
568
770
 
771
+ // A pick that could not be taken, said in the words the ladder already used.
772
+ // `choice.reasons` carries {agent, account, model, reason} for every rung the
773
+ // walk passed over, so the cost gate, the reserve and a `below:N` rule all have
774
+ // their own sentence sitting there; re-deriving the explanation from the
775
+ // account's `limited_until` printed "at its limit until unknown" for a rung
776
+ // that was never walled at all. The limit sentence stays as the fallback for a
777
+ // rung the walk never reached. Both names carry their model (rungLabel), or a
778
+ // downshift reads as "claude was picked but ...; handing off to claude".
779
+ // → { asked, got, why }
780
+ export function pickedAside({ prefer, next, choice, excluded = [], read = readUsage }) {
781
+ const want = { agent: prefer.agent, account: prefer.account ?? 'default', model: prefer.model ?? null }
782
+ const asked = rungLabel(want)
783
+ const got = rungLabel(next)
784
+ if (excluded.some((x) => x.agent === want.agent && x.account === want.account)) return { asked, got, why: 'the strict harness policy refused it' }
785
+ const hit = (choice?.reasons ?? []).find((r) => r.agent === want.agent && r.account === want.account && (want.model ? (r.model ?? null) === want.model : true))
786
+ if (hit?.reason) return { asked, got, why: hit.reason }
787
+ return { asked, got, why: `it is at its limit until ${fmtReset(read(want.agent, want.account).limited_until)}` }
788
+ }
789
+
569
790
  // The portable harness, decided before a leg starts (src/harness/index.mjs).
570
791
  // Off by default: then this records nothing and changes nothing. On, it
571
792
  // carries the source agent's working environment to the agent about to run,
@@ -592,8 +813,27 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
592
813
  // affected; only what Leg adds is licensed.
593
814
  const ent = entitlement()
594
815
  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)
816
+ // `--resume-card <id>`: Take over on the board paused a card and handed the
817
+ // human this command. The terminal opens in that card's own worktree, primed
818
+ // from its bundle (redesign C.4). Leg's flag, never the agent's.
819
+ const lifted = takeFlagValue(args, '--resume-card')
820
+ args = lifted.args
821
+ let card = null
822
+ if (lifted.value !== null) {
823
+ const hit = resolveCardId(lifted.value)
824
+ if (!hit.id) {
825
+ say(hit.matches.length
826
+ ? `"${lifted.value}" matches ${hit.matches.length} cards (${hit.matches.slice(0, 5).join(', ')}); use the full id`
827
+ : `no card matches "${lifted.value}" (leg card ls lists them)`)
828
+ return 3
829
+ }
830
+ card = readCard(hit.id)
831
+ if (!cardWorkRoot(card)) { say(`card ${hit.id} has no checkout on this machine yet; run it once, or open ${card.repo} yourself`); return 3 }
832
+ }
833
+ // --no-worktree is Leg's flag, not the agent's: it never passes through.
834
+ // A card take-over shares the card's checkout for the same reason: cutting a
835
+ // second worktree on its branch is what Take over exists to avoid.
836
+ const shareCheckout = args.includes('--no-worktree') || Boolean(continued) || Boolean(card)
597
837
  args = args.filter((a) => a !== '--no-worktree')
598
838
  let autoApproveCli = null
599
839
  if (args.includes('--no-auto-approve')) {
@@ -604,11 +844,16 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
604
844
  args = args.filter((a) => a !== '--auto-approve')
605
845
  }
606
846
  const autoApprove = resolveAutoApprove({ cliFlag: autoApproveCli })
607
- const cwd = cwdOpt ? realPath(cwdOpt) : process.cwd()
847
+ const cwd = card ? realPath(cardWorkRoot(card)) : (cwdOpt ? realPath(cwdOpt) : process.cwd())
608
848
  const board = await ensureBoard({ open })
609
849
  let accounts = readAccounts()
610
850
  const installed = await installedAgents()
611
- const handoffOrder = readPreferences().handoff_order
851
+ // The machine's preferences are copied into this terminal at start: the
852
+ // ladder it walks, and the spending rules it walks it under. Later edits
853
+ // reach a running terminal only through the board's per-terminal ladder.
854
+ const prefs = readPreferences()
855
+ const handoffOrder = prefs.handoff_order
856
+ const handoffLadder = prefs.handoff_ladder
612
857
  let account = process.env.LEG_ACCOUNT || process.env.BATON_ACCOUNT || 'default'
613
858
  if (!(accounts[agent] ?? ['default']).includes(account)) { say(`no ${agent} account "${account}"; using default`); account = 'default' }
614
859
  // A persisted wall is only a cache. Ask Codex's read-only account endpoint
@@ -623,20 +868,27 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
623
868
  await fetchGrokUsage({ configDir: grokHome }).catch(() => {})
624
869
  }
625
870
  // Start on an account that is not at its wall, if we already know one is.
871
+ let startModel = null
626
872
  const nowS = Math.floor(Date.now() / 1000)
627
873
  const u0 = readUsage(agent, account)
628
874
  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 }
875
+ // automatic: the human asked for this agent, not for this destination, so
876
+ // the machine's own floors (the reserve, the spending gate) still apply to
877
+ // the rung Leg substitutes for it.
878
+ 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 })
879
+ 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
880
  else say(`${agent} (${account}) is at its limit until ${fmtReset(u0.limited_until)}; starting anyway (every option is out)`)
632
881
  }
633
882
  const g = gitInfo(cwd)
634
883
  const sid = newSessionId(agent)
635
- const chain = candidates({ agent, account, accounts, order: handoffOrder })
884
+ const chain = candidates({ agent, account, accounts, order: handoffOrder, ladder: handoffLadder, model: startModel ?? modelFromArgs(agent, args) })
636
885
  // record the session BEFORE cutting a worktree, so a crash or Ctrl-C during
637
886
  // `git worktree add` still leaves a card (with a Remove button), never a
638
887
  // 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] })
888
+ 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 })
889
+ // the ladder is a copy too, so the board can edit this terminal's rungs
890
+ // without changing the machine default under every other terminal
891
+ updateSession(sid, { handoff_ladder: handoffLadder })
640
892
  if (continued) {
641
893
  // the agent's own id and transcript are known before the first turn, so
642
894
  // history dedups this leg against the conversation it continues at once
@@ -658,7 +910,17 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
658
910
  }
659
911
 
660
912
  let prompt = null
913
+ if (card) {
914
+ // the card's bundle is this terminal's first prompt, and the record says
915
+ // where this terminal came from, so the board can draw the line back
916
+ updateSession(sid, { lineage: { from: card.card_id, to: null }, task: card.task ?? null },
917
+ { event: { type: 'continued', summary: `taking over card ${card.card_id} (${card.status} at ${card.station}) in ${cwd}` } })
918
+ prompt = takeOverPrompt(card, cwd)
919
+ say(`taking over card ${card.card_id} in ${cwd}${card.last_bundle ? ` from bundle ${card.last_bundle}` : ''}`)
920
+ }
661
921
  let legArgs = args
922
+ let legModel = startModel
923
+ let legResume = null
662
924
  let exit = 0
663
925
  // the first leg is the agent the human chose: its harness is prepared per
664
926
  // policy and recorded, never refused (strict applies to hand-offs)
@@ -667,7 +929,7 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
667
929
  // bounds a wait; a normal session runs one leg and exits
668
930
  for (let leg = 0; ; leg++) {
669
931
  const s = readSession(sid)
670
- const r = await runLeg({ agent, account, args: legArgs, session: s, prompt, boardUrl: board.url, autoApprove })
932
+ const r = await runLeg({ agent, account, args: legArgs, session: s, prompt, boardUrl: board.url, autoApprove, model: legModel, resume: legResume })
671
933
  if (r.reason === 'exit') { exit = r.code ?? 0; break }
672
934
  // limit or handoff: bundle, choose next, go again in this terminal
673
935
  const cur = readSession(sid)
@@ -682,7 +944,7 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
682
944
  const excluded = []
683
945
  // the destination a human picked on the board, if they picked one
684
946
  const prefer = r.target ?? null
685
- let claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded, prefer })
947
+ let claim = claimHandoffChoice({ sid, agent, account, model: cur.model ?? null, installed, bundle, reason: r.reason, exclude: excluded, prefer, automatic: r.reason !== 'handoff' })
686
948
  let choice = claim.choice
687
949
  let cancelled = false
688
950
  let blocked = false
@@ -700,10 +962,13 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
700
962
  say(`every option is out. First back: ${label} at ${first ? fmtReset(first.resets_at) : 'unknown'}`)
701
963
  for (const o of all) say(` ${o.agent}${o.account !== 'default' ? '/' + o.account : ''}: resets ${fmtReset(o.resets_at)}`)
702
964
  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'}` } })
965
+ // `type: 'reset'` tells this apart from the Notification hook's
966
+ // `waiting` (a human being waited on). Same key, two shapes, one
967
+ // discriminator; see the field comment in src/sessions.mjs.
968
+ 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
969
  const r2 = await waitInTerminal({ sid, label, resetsAt: first?.resets_at ?? null })
705
970
  if (r2 === 'cancelled') { cancelled = true; break }
706
- claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded, prefer })
971
+ claim = claimHandoffChoice({ sid, agent, account, model: cur.model ?? null, installed, bundle, reason: r.reason, exclude: excluded, prefer, automatic: r.reason !== 'handoff' })
707
972
  choice = claim.choice
708
973
  }
709
974
  if (cancelled) {
@@ -719,7 +984,7 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
719
984
  if (prepared.proceed) break
720
985
  excluded.push(choice.next)
721
986
  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 })
987
+ claim = claimHandoffChoice({ sid, agent, account, model: cur.model ?? null, installed, bundle, reason: r.reason, exclude: excluded, prefer, automatic: r.reason !== 'handoff' })
723
988
  choice = claim.choice
724
989
  if (!choice.next) blocked = true
725
990
  }
@@ -735,11 +1000,7 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
735
1000
  // refuse it, and a terminal that quietly went somewhere else is the kind
736
1001
  // of surprise this board exists to remove.
737
1002
  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)}`
1003
+ const { asked, got, why } = pickedAside({ prefer, next, choice, excluded })
743
1004
  say(`${asked} was picked but ${why}; handing off to ${got} instead`)
744
1005
  appendEvent(sid, { type: 'status', summary: `${asked} was picked for this hand-off but ${why}; ${got} took it instead` })
745
1006
  }
@@ -751,8 +1012,29 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
751
1012
  exit = 3
752
1013
  break
753
1014
  }
754
- appendEvent(sid, { type: 'handoff', summary: `${agent}${account !== 'default' ? '/' + account : ''} → ${next.agent}${next.account !== 'default' ? '/' + next.account : ''}${bundle ? ` (bundle ${bundle.id})` : ''}` })
755
- if (bundle) {
1015
+ // Every rung the ladder walked past, in the ledger, with the reason: a
1016
+ // terminal that skipped claude/fable because credits are off must say so
1017
+ // (B.3). "not installed" is left out: that one is about this machine, not
1018
+ // about this hand-off, and it would repeat on every leg.
1019
+ for (const why of choice.reasons ?? []) {
1020
+ if (why.reason === 'not installed on this machine') continue
1021
+ const line = skipLine(why)
1022
+ say(line)
1023
+ appendEvent(sid, { type: 'status', summary: line })
1024
+ }
1025
+ // The one hand-off that keeps the conversation: a claude downshift with the
1026
+ // agent's own session id on the record. `--resume <id> --model <alias>`
1027
+ // starts the next leg inside the same conversation, so the bundle is not
1028
+ // written into a prompt and nothing is re-explained. Every other rung takes
1029
+ // the bundle: an upshift back to fable (which would re-read the whole
1030
+ // context at fable's rate), a second account, and codex, whose `resume`
1031
+ // subcommand exists but has never been seen composing with `-m` here.
1032
+ const fromRung = { agent, account, model: cur.model ?? null }
1033
+ const keepsConversation = Boolean(next.agent === 'claude' && isDownshift(fromRung, next) && cur.agent_session_id)
1034
+ appendEvent(sid, { type: 'handoff', summary: `${rungLabel(fromRung)} → ${rungLabel(next)}${keepsConversation ? ' (kept the conversation)' : bundle ? ` (bundle ${bundle.id})` : ''}` })
1035
+ if (keepsConversation) {
1036
+ prompt = null
1037
+ } else if (bundle) {
756
1038
  prompt = resumePrompt(cur, bundle, next)
757
1039
  } else {
758
1040
  const delta = sessionCommitDelta(workRoot(cur) ?? cur.cwd, cur)
@@ -761,13 +1043,16 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
761
1043
  : 'Check git status and git diff, then continue the work.'
762
1044
  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
1045
  }
764
- say(`starting ${next.agent}${next.account !== 'default' ? '/' + next.account : ''} in this terminal from the bundle`)
1046
+ say(`starting ${rungLabel(next)} in this terminal ${keepsConversation ? 'with --resume: kept the conversation' : 'from the bundle'}`)
1047
+ legResume = keepsConversation ? cur.agent_session_id : null
1048
+ legModel = next.model ?? null
765
1049
  agent = next.agent; account = next.account; legArgs = []
766
1050
  // the chain is what comes after the agent now taking over, not after the
767
1051
  // one that started the session: the card's "next" names a live option
768
1052
  updateSession(sid, (fresh) => {
769
1053
  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 }) }
1054
+ const freshLadder = ladderFor(fresh)
1055
+ 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
1056
  })
772
1057
  }
773
1058
  const fin = readSession(sid)