@ucsandman/legcli 0.7.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 (184) hide show
  1. package/CHANGELOG.md +560 -0
  2. package/LICENSE +58 -0
  3. package/NOTICE +4 -0
  4. package/README.md +558 -0
  5. package/bin/fake-agent.mjs +143 -0
  6. package/bin/leg.mjs +449 -0
  7. package/docs/DECISIONS.md +119 -0
  8. package/docs/DEMO.md +214 -0
  9. package/docs/DEVIATIONS.md +182 -0
  10. package/docs/ERRORS.md +326 -0
  11. package/docs/README.md +40 -0
  12. package/docs/REUSE.md +289 -0
  13. package/docs/ROADMAP-v2.md +55 -0
  14. package/docs/VOCABULARY.md +243 -0
  15. package/docs/adapters.md +300 -0
  16. package/docs/board-guide.md +579 -0
  17. package/docs/cli-contracts.md +401 -0
  18. package/docs/concepts.md +426 -0
  19. package/docs/configuration.md +220 -0
  20. package/docs/faq.md +206 -0
  21. package/docs/getting-started.md +251 -0
  22. package/docs/real-run.md +80 -0
  23. package/docs/review-2026-09-14.md +72 -0
  24. package/docs/screenshots/board-400px.png +0 -0
  25. package/docs/screenshots/board-details-open.png +0 -0
  26. package/docs/screenshots/board-done.png +0 -0
  27. package/docs/screenshots/board-drawer.png +0 -0
  28. package/docs/screenshots/board-empty.png +0 -0
  29. package/docs/screenshots/board-handoff.png +0 -0
  30. package/docs/screenshots/board-running.png +0 -0
  31. package/docs/screenshots/demo-1-claude-running.png +0 -0
  32. package/docs/screenshots/demo-2-limit-hit.png +0 -0
  33. package/docs/screenshots/demo-3-handoff-bundle.png +0 -0
  34. package/docs/screenshots/demo-4-codex-running.png +0 -0
  35. package/docs/screenshots/demo-5-done.png +0 -0
  36. package/docs/screenshots/floor-final-1280.png +0 -0
  37. package/docs/screenshots/floor-final-400.png +0 -0
  38. package/docs/screenshots/floor-landing.png +0 -0
  39. package/docs/screenshots/floor.png +0 -0
  40. package/docs/screenshots/share-guest-1280.png +0 -0
  41. package/docs/screenshots/share-owner-1280.png +0 -0
  42. package/docs/screenshots/terminals-1280.png +0 -0
  43. package/docs/screenshots/thumb-done.jpg +0 -0
  44. package/docs/screenshots/thumb-handoff-details.jpg +0 -0
  45. package/docs/screenshots/thumb-limit-hit.jpg +0 -0
  46. package/docs/screenshots/youtube-thumbnail.jpg +0 -0
  47. package/fixtures/limits/agy/agy-resource-exhausted.json +11 -0
  48. package/fixtures/limits/claude/claude-budget-limit.json +11 -0
  49. package/fixtures/limits/claude/claude-max-turns.json +11 -0
  50. package/fixtures/limits/claude/claude-model-limit.json +11 -0
  51. package/fixtures/limits/claude/claude-session-limit.json +11 -0
  52. package/fixtures/limits/claude/claude-weekly-limit.json +11 -0
  53. package/fixtures/limits/codex/codex-quota-exceeded.json +11 -0
  54. package/fixtures/limits/codex/codex-rate-limit-exceeded.json +11 -0
  55. package/fixtures/limits/codex/codex-skills-notice.json +11 -0
  56. package/fixtures/limits/codex/codex-usage-limit-reached.json +11 -0
  57. package/fixtures/limits/codex/codex-usage-limit.json +11 -0
  58. package/fixtures/limits/generic/generic-429.json +11 -0
  59. package/fixtures/limits/generic/generic-overloaded.json +11 -0
  60. package/fixtures/limits/generic/generic-quota.json +11 -0
  61. package/fixtures/limits/generic/generic-rate-limit.json +11 -0
  62. package/fixtures/limits/generic/generic-resource-exhausted.json +11 -0
  63. package/fixtures/limits/generic/generic-usage-limit.json +11 -0
  64. package/fixtures/limits/grok/grok-not-logged-in.json +11 -0
  65. package/fixtures/limits/negative/auth-source-set.json +11 -0
  66. package/fixtures/limits/negative/compile-error.json +11 -0
  67. package/fixtures/limits/negative/empty-stdout-exit-0.json +11 -0
  68. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +1 -0
  69. package/fixtures/live/agy/cmd.txt +1 -0
  70. package/fixtures/live/agy/err.log +0 -0
  71. package/fixtures/live/agy/out.log +1 -0
  72. package/fixtures/live/agy/parsed.json +5 -0
  73. package/fixtures/live/agy/run.json +18 -0
  74. package/fixtures/live/agy/supervisor.log +2 -0
  75. package/fixtures/live/claude/cmd.txt +1 -0
  76. package/fixtures/live/claude/err.log +0 -0
  77. package/fixtures/live/claude/limit-rate_limit.json +21 -0
  78. package/fixtures/live/claude/out.log +1 -0
  79. package/fixtures/live/claude/parsed.json +11 -0
  80. package/fixtures/live/claude/run.json +18 -0
  81. package/fixtures/live/claude/supervisor.log +2 -0
  82. package/fixtures/live/codex/cmd.txt +1 -0
  83. package/fixtures/live/codex/err.log +1 -0
  84. package/fixtures/live/codex/last.md +2 -0
  85. package/fixtures/live/codex/limit-usage_limit_exceeded.json +25 -0
  86. package/fixtures/live/codex/out.log +8 -0
  87. package/fixtures/live/codex/parsed.json +15 -0
  88. package/fixtures/live/codex/run.json +18 -0
  89. package/fixtures/live/codex/supervisor.log +2 -0
  90. package/fixtures/live/grok/cmd.txt +1 -0
  91. package/fixtures/live/grok/err.log +32 -0
  92. package/fixtures/live/grok/out.log +7 -0
  93. package/fixtures/live/grok/parsed.json +5 -0
  94. package/fixtures/live/grok/run.json +18 -0
  95. package/fixtures/live/grok/supervisor.log +2 -0
  96. package/fixtures/verified.json +34 -0
  97. package/package.json +68 -0
  98. package/scripts/board-shots.mjs +69 -0
  99. package/scripts/build-docs-site.mjs +308 -0
  100. package/scripts/check-claims.mjs +150 -0
  101. package/scripts/clean-clone-check.sh +42 -0
  102. package/scripts/license-sign.mjs +23 -0
  103. package/scripts/limits-table.mjs +38 -0
  104. package/scripts/live-limits.mjs +36 -0
  105. package/scripts/privacy-check.mjs +63 -0
  106. package/scripts/probe.mjs +92 -0
  107. package/scripts/seed-fake-cards.mjs +34 -0
  108. package/scripts/seed-floor-board.mjs +69 -0
  109. package/scripts/seed-wes-board.mjs +91 -0
  110. package/scripts/stripe-setup.mjs +99 -0
  111. package/scripts/vercel-env.mjs +40 -0
  112. package/src/accounts.mjs +119 -0
  113. package/src/adapters/agy.mjs +56 -0
  114. package/src/adapters/claude.mjs +54 -0
  115. package/src/adapters/codex.mjs +73 -0
  116. package/src/adapters/common.mjs +32 -0
  117. package/src/adapters/fake.mjs +48 -0
  118. package/src/adapters/grok.mjs +51 -0
  119. package/src/adapters/index.mjs +27 -0
  120. package/src/adapters/resolve.mjs +40 -0
  121. package/src/attach.mjs +592 -0
  122. package/src/auth.mjs +85 -0
  123. package/src/board/board.css +543 -0
  124. package/src/board/board.js +1307 -0
  125. package/src/board/favicon.svg +1 -0
  126. package/src/board/floor.html +127 -0
  127. package/src/board/floor.js +670 -0
  128. package/src/board/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
  129. package/src/board/fonts/OFL-azeret-mono.txt +94 -0
  130. package/src/board/fonts/atkinson-board.woff2 +0 -0
  131. package/src/board/fonts/azeret-board.woff2 +0 -0
  132. package/src/board/index.html +251 -0
  133. package/src/board/sessions.js +1451 -0
  134. package/src/bundle.mjs +103 -0
  135. package/src/cards.mjs +114 -0
  136. package/src/chain.mjs +264 -0
  137. package/src/commands.mjs +79 -0
  138. package/src/contract.mjs +73 -0
  139. package/src/env.mjs +19 -0
  140. package/src/fsx.mjs +72 -0
  141. package/src/git-snapshot.mjs +152 -0
  142. package/src/handoff.mjs +171 -0
  143. package/src/hook.mjs +49 -0
  144. package/src/land.mjs +106 -0
  145. package/src/launcher.mjs +272 -0
  146. package/src/leases.mjs +86 -0
  147. package/src/ledger.mjs +369 -0
  148. package/src/license.mjs +168 -0
  149. package/src/limits.mjs +97 -0
  150. package/src/live-capture.mjs +69 -0
  151. package/src/mergequeue.mjs +225 -0
  152. package/src/orchestrator.mjs +365 -0
  153. package/src/pipeline.mjs +100 -0
  154. package/src/preferences.mjs +47 -0
  155. package/src/presets.mjs +23 -0
  156. package/src/ratelimit.mjs +49 -0
  157. package/src/redact.mjs +47 -0
  158. package/src/resume.mjs +354 -0
  159. package/src/runner.mjs +438 -0
  160. package/src/scheduler.mjs +118 -0
  161. package/src/server.mjs +845 -0
  162. package/src/session-detail.mjs +129 -0
  163. package/src/sessions.mjs +202 -0
  164. package/src/share.mjs +163 -0
  165. package/src/stations/agent.mjs +42 -0
  166. package/src/stations/build.mjs +9 -0
  167. package/src/stations/human.mjs +5 -0
  168. package/src/stations/land.mjs +6 -0
  169. package/src/stations/plan.mjs +8 -0
  170. package/src/stations/pr.mjs +31 -0
  171. package/src/stations/review.mjs +8 -0
  172. package/src/stations/test.mjs +27 -0
  173. package/src/store.mjs +90 -0
  174. package/src/sync/dashclaw.mjs +70 -0
  175. package/src/sync/index.mjs +54 -0
  176. package/src/sync/workboard.mjs +54 -0
  177. package/src/taps/agy.mjs +64 -0
  178. package/src/taps/claude-usage.mjs +67 -0
  179. package/src/taps/claude.mjs +170 -0
  180. package/src/taps/codex.mjs +286 -0
  181. package/src/trust.mjs +323 -0
  182. package/src/usage.mjs +179 -0
  183. package/src/wait.mjs +30 -0
  184. package/src/worktree.mjs +207 -0
package/src/attach.mjs ADDED
@@ -0,0 +1,592 @@
1
+ // attach — `baton claude|codex|agy [args…]`: the normal interactive agent in
2
+ // this terminal, with Baton alongside it. Baton (1) makes sure the board is
3
+ // up and opens it once, (2) registers the session so it shows on the board,
4
+ // (3) taps the agent for usage (claude: hooks + status line via --settings;
5
+ // codex: its rollout file; agy: its log), (4) polls git for the files the
6
+ // session touches, (5) keeps the handoff bundle current, and (6) on a usage
7
+ // limit saves the bundle, stops the agent, and starts the next option in the
8
+ // same terminal from that bundle. Subscription logins only: API keys are
9
+ // stripped from the child environment (src/env.mjs).
10
+ import http from 'node:http'
11
+ import { spawn, spawnSync } from 'node:child_process'
12
+ import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
13
+ import { join, dirname, resolve, relative } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { sanitizeEnv } from './env.mjs'
16
+ import { home } from './store.mjs'
17
+ import { get as getAdapter } from './adapters/index.mjs'
18
+ import { AGENTS, HANDOFF_ORDER_CAPABILITY, newSessionId, createSession, readSession, updateSession, appendEvent, takeControl, sessionDir, listSessions, reapLost, isActive, workRoot } from './sessions.mjs'
19
+ import { ensure as ensureWorktree, remove as removeWorktree } from './worktree.mjs'
20
+ import { canonPath, realPath } from './fsx.mjs'
21
+ import { whoami, readShare, isOn as shareIsOn } from './share.mjs'
22
+ import { readAccounts, envFor, refreshAccount } from './accounts.mjs'
23
+ import { recordUsage, markLimited, chooseNext, candidates, fmtReset, WARN_PCT, readUsage, isAvailable } from './usage.mjs'
24
+ import { entitlement, allows, describe as describeLicense } from './license.mjs'
25
+ import { writeSettings, userStatusLine, transcriptTail as claudeTail } from './taps/claude.mjs'
26
+ import { ensureTrust, trustLine } from './trust.mjs'
27
+ import { findRollout, createTail, parseLines, readCodexUsage, transcriptTail as codexTail } from './taps/codex.mjs'
28
+ import { scanLog, promptsSince, logSize } from './taps/agy.mjs'
29
+ import { fetchClaudeUsage } from './taps/claude-usage.mjs'
30
+ import { saveSessionBundle, resumePrompt } from './bundle.mjs'
31
+ import { endSessionPointer } from './resume.mjs'
32
+ import { openBoard, pidfile } from './launcher.mjs'
33
+ import { LAYOUT } from './accounts.mjs'
34
+ import { captureLive } from './live-capture.mjs'
35
+ import { waitForReset, fmtCountdown } from './wait.mjs'
36
+ import { readPreferences, normalizeHandoffOrder } from './preferences.mjs'
37
+
38
+ const SRC = dirname(fileURLToPath(import.meta.url))
39
+ const SERVER = join(SRC, 'server.mjs')
40
+ const POLL_MS = Number(process.env.LEG_ATTACH_POLL_MS || process.env.BATON_ATTACH_POLL_MS || 2000)
41
+ const GIT_EVERY = 3 // polls
42
+ const USAGE_MS = Number(process.env.LEG_USAGE_POLL_MS || process.env.BATON_USAGE_POLL_MS || 60000)
43
+ const say = (line) => process.stderr.write(`[leg] ${line}\n`)
44
+
45
+ async function refreshCodexUsage(account, codexHome, { timeoutMs = 8000, signal = null } = {}) {
46
+ const r = await readCodexUsage({ codexHome, timeoutMs, signal })
47
+ if (!r.ok) return r
48
+ const u = recordUsage('codex', account, r.limits, 'codex app-server account/rateLimits/read', { observed_at: r.observed_at, available: r.available })
49
+ return { ...r, usage: u }
50
+ }
51
+
52
+ export function isCurrentLeg(session, { pid, agent, account }) {
53
+ return Boolean(session && session.pid === pid && session.agent === agent && session.account === account)
54
+ }
55
+
56
+ // ---- board ----
57
+ function health(port, host = '127.0.0.1') {
58
+ return new Promise((res) => {
59
+ const req = http.get({ host, port, path: '/api/health', timeout: 1500 }, (r) => {
60
+ let d = ''
61
+ r.on('data', (c) => { d += c })
62
+ r.on('end', () => {
63
+ // 401 is a board: with share on, even health asks for a token
64
+ if (r.statusCode === 401) return res({ ok: true, guarded: true })
65
+ try { res(r.statusCode === 200 ? JSON.parse(d) : null) } catch { res(null) }
66
+ })
67
+ })
68
+ req.on('error', () => res(null)); req.on('timeout', () => { req.destroy(); res(null) })
69
+ })
70
+ }
71
+
72
+ export async function ensureBoard({ open = true } = {}) {
73
+ // with share on the board lives on the shared address, not loopback
74
+ const share = readShare()
75
+ const shared = shareIsOn(share)
76
+ const port = shared ? share.port : Number(process.env.LEG_PORT || process.env.BATON_PORT || 4747)
77
+ const host = shared ? share.bind : '127.0.0.1'
78
+ const url = `http://${host}:${port}`
79
+ if ((process.env.LEG_NO_BOARD || process.env.BATON_NO_BOARD) === '1') return { url: null, started: false, skipped: true }
80
+ if (await health(port, host)) return { url, started: false }
81
+ mkdirSync(home(), { recursive: true })
82
+ const logFd = (await import('node:fs')).openSync(join(home(), 'board.log'), 'a')
83
+ const child = spawn(process.execPath, [SERVER], { detached: true, windowsHide: true, stdio: ['ignore', logFd, logFd], env: { ...process.env, BATON_PORT: String(port), BATON_BIND: host, BATON_QUIET: '0' } })
84
+ child.unref()
85
+ const t0 = Date.now()
86
+ while (Date.now() - t0 < 15000) {
87
+ if (await health(port, host)) {
88
+ // only claim the pidfile for a child we actually started: under a race,
89
+ // another `baton` won the port and ours died on EADDRINUSE — writing our
90
+ // dead pid would make `baton down` kill nothing and report "not running"
91
+ const ours = child.exitCode === null && Boolean(child.pid)
92
+ if (ours) writeFileSync(pidfile(), JSON.stringify({ pid: child.pid, port, bind: host, children: [child.pid], detached: true, started_by: 'attach', started_at: new Date().toISOString() }, null, 2) + '\n')
93
+ if (open) openBoard(url)
94
+ return { url, started: ours }
95
+ }
96
+ await new Promise((r) => setTimeout(r, 200))
97
+ }
98
+ say(`board did not come up on ${url} (see ${join(home(), 'board.log')}); continuing without it`)
99
+ return { url, started: false, failed: true }
100
+ }
101
+
102
+ // ---- git ----
103
+ function git(cwd, args) {
104
+ const r = spawnSync('git', args, { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
105
+ // trimEnd only: a porcelain line starts with a space (" M README.md")
106
+ return r.status === 0 ? r.stdout.trimEnd() : null
107
+ }
108
+ export function gitInfo(cwd) {
109
+ const repo = git(cwd, ['rev-parse', '--show-toplevel'])
110
+ if (!repo) return { repo: null, branch: null, head: null, dirty: [] }
111
+ return {
112
+ repo: repo.replace(/\//g, process.platform === 'win32' ? '\\' : '/'),
113
+ branch: git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']),
114
+ head: git(cwd, ['rev-parse', 'HEAD']),
115
+ dirty: (git(cwd, ['status', '--porcelain']) ?? '').split('\n').filter(Boolean).map((l) => l.slice(3).replace(/^"|"$/g, '')).filter((f) => !/^(\.leg|\.baton|\.context-handoffs|\.dashclaw-local)\//.test(f)),
116
+ }
117
+ }
118
+
119
+ // ---- collisions ----
120
+ // Two agents in one working tree write over each other's files. When another
121
+ // live session already works in this checkout, this one gets its own:
122
+ // <repo>/.baton-worktrees/<sid> on branch baton/<sid>, cut from the branch the
123
+ // checkout has out, and it comes back through the merge queue (Land on its card).
124
+ export function isolate({ g, cwd, sid, sessions = reapLost(listSessions()) }) {
125
+ const here = canonPath(g.repo)
126
+ // exclude this session itself: its record now exists before isolate runs, and
127
+ // a session must never be isolated from its own checkout
128
+ const live = sessions.filter((s) => s.session_id !== sid && isActive(s) && workRoot(s) && canonPath(workRoot(s)) === here)
129
+ if (!live.length) return null
130
+ const base = g.branch && g.branch !== 'HEAD' ? g.branch : null
131
+ const wt = ensureWorktree(g.repo, sid, { trunk: base ?? 'HEAD' })
132
+ const sub = relative(realPath(g.repo), realPath(cwd))
133
+ const inTree = sub && !sub.startsWith('..') ? join(wt.path, sub) : wt.path
134
+ return { path: wt.path, branch: wt.branch, base, cwd: existsSync(inTree) ? inTree : wt.path, live }
135
+ }
136
+
137
+ // Which agents are actually on this machine, so the chooser never hands off to
138
+ // a binary that is not installed (that spawned ENOENT and killed the session
139
+ // with exit 127 instead of waiting for a reset). Resolves each adapter the way
140
+ // the runner would spawn it (native exe, npm entry, or BATON_<AGENT>_BIN).
141
+ let installedCache = null
142
+ async function installedAgents() {
143
+ if (installedCache) return installedCache
144
+ const out = {}
145
+ for (const name of AGENTS) {
146
+ try {
147
+ const { bin, viaNode, entry } = (await getAdapter(name)).resolve()
148
+ const target = viaNode ? (entry ?? bin) : bin
149
+ if (/[\\/]/.test(target)) out[name] = existsSync(target)
150
+ else { const r = spawnSync(target, ['--version'], { windowsHide: true, encoding: 'utf8', timeout: 8000 }); out[name] = !r.error && r.status === 0 }
151
+ } catch { out[name] = false }
152
+ }
153
+ installedCache = out
154
+ return out
155
+ }
156
+
157
+ // ---- process control ----
158
+ function killTree(pid) {
159
+ if (!pid) return
160
+ if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, encoding: 'utf8' })
161
+ else { try { process.kill(-pid, 'SIGTERM') } catch { try { process.kill(pid, 'SIGTERM') } catch {} } }
162
+ }
163
+ // A killed agent never runs its own cleanup, so every mode it set outlives it:
164
+ // mouse reporting (the wheel then prints `[<65;40;24M` runs into the shell),
165
+ // bracketed paste, application keys, autowrap off, and a scrolling region that
166
+ // makes the next leg's output land on top of the lines already on screen.
167
+ // DECSTBM homes the cursor, so the margin reset is wrapped in DECSC/DECRC, and
168
+ // the erase clears only the dead agent's half-drawn frame below the cursor.
169
+ export const TERMINAL_RESET =
170
+ '\x1b[?1049l' + // leave any alternate screen
171
+ '\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?1015l' + // every mouse reporting mode
172
+ '\x1b[?1004l\x1b[?2004l' + // focus events, bracketed paste
173
+ '\x1b[?1l\x1b>' + // normal cursor keys, numeric keypad
174
+ '\x1b[?7h' + // autowrap
175
+ '\x1b7\x1b[r\x1b8' + // full-height scrolling region, cursor kept
176
+ '\x1b[?25h\x1b[0m' + // cursor visible, default attributes
177
+ '\r\x1b[J\n' // a clean line to continue on
178
+
179
+ function restoreTerminal() {
180
+ try { if (process.stdin.isTTY) process.stdin.setRawMode(false) } catch {}
181
+ try { process.stdout.write(TERMINAL_RESET) } catch {}
182
+ }
183
+
184
+ // ---- spawn spec per agent ----
185
+ export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd }) {
186
+ const adapter = await getAdapter(agent)
187
+ const { bin, viaNode, entry } = adapter.resolve()
188
+ const argv = []
189
+ // viaNode: either an npm entry (codex bin/codex.js) or a BATON_<AGENT>_BIN that names a .mjs (tests)
190
+ if (viaNode) argv.push(entry ?? bin)
191
+ // a leg Baton starts on its own (after a hand-off) takes BATON_<AGENT>_ARGS,
192
+ // e.g. BATON_CODEX_ARGS="-m gpt-5-mini" to keep a test chain on cheap models
193
+ if (prompt) args = [...(process.env[`BATON_${agent.toUpperCase()}_ARGS`] ?? '').split(/\s+/).filter(Boolean), ...args]
194
+ if (agent === 'claude') {
195
+ const settings = writeSettings(sessionId, { statusLine: userStatusLine(process.env.CLAUDE_CONFIG_DIR || (account !== 'default' ? envFor('claude', account).CLAUDE_CONFIG_DIR : undefined)) })
196
+ argv.push(...args, '--settings', settings)
197
+ if (prompt) argv.push(prompt)
198
+ } else if (agent === 'codex') {
199
+ argv.push(...args)
200
+ if (prompt) argv.push(prompt)
201
+ } else if (agent === 'agy') {
202
+ const log = join(sessionDir(sessionId), 'agy.log')
203
+ argv.push(...args, '--log-file', log)
204
+ if (prompt) argv.push('-i', prompt)
205
+ }
206
+ const env = { ...sanitizeEnv(process.env, { interactive: true }), ...envFor(agent, account), LEG_SESSION: sessionId, BATON_SESSION: sessionId }
207
+ return { bin: viaNode ? process.execPath : bin, args: argv, env, cwd }
208
+ }
209
+
210
+ // ---- one agent leg ----
211
+ // Returns { reason: 'exit'|'limit'|'handoff', code }
212
+ async function runLeg({ agent, account, args, session, prompt, boardUrl }) {
213
+ const sid = session.session_id
214
+ refreshAccount(agent, account)
215
+ // A handoff happens when the limit hits, which is usually when nobody is
216
+ // watching. An agent that has never run in this folder would stop on its
217
+ // first-run trust prompt and wait for a keypress that is not coming, so the
218
+ // answer goes on file before the agent starts. BATON_TRUST=never opts out.
219
+ const trust = ensureTrust(agent, session.cwd, { cwd: session.cwd })
220
+ const trusted = trustLine(trust)
221
+ if (trusted) { say(trusted); appendEvent(sid, { type: 'trust', summary: trusted }) }
222
+ const spec = await spawnSpec(agent, { account, args, sessionId: sid, prompt, cwd: session.cwd })
223
+ appendEvent(sid, { type: 'leg', summary: `${agent} (${account}) starting${prompt ? ' from the handoff bundle' : ''}` })
224
+ const startedMs = Date.now()
225
+ const turnsAtLegStart = session.turns ?? 0
226
+ // agy appends to one log for the whole session: a second agy leg reads from
227
+ // the end of what the first one wrote, or it walls itself on that leg's line
228
+ const agyLog = agent === 'agy' ? join(sessionDir(sid), 'agy.log') : null
229
+ const agyTail = agyLog ? createTail(agyLog, { from: logSize(agyLog) }) : null
230
+ let child
231
+ try {
232
+ child = spawn(spec.bin, spec.args, { cwd: spec.cwd, env: spec.env, stdio: 'inherit', windowsHide: false })
233
+ } catch (err) {
234
+ updateSession(sid, { status: 'ended', ended_at: new Date().toISOString() }, { event: { type: 'error', summary: `${agent} failed to start: ${err.message}` } })
235
+ return { reason: 'exit', code: 127 }
236
+ }
237
+ // every leg starts on its own card: the agent that just left takes its
238
+ // percentages, its warning and its usage source with it
239
+ updateSession(sid, { pid: child.pid, agent, account, status: agent === 'claude' ? 'starting' : 'running', limit: null, warning: null, limits: null, usage_source: null, usage_error: null })
240
+
241
+ // taps
242
+ let rollout = null; let tail = null
243
+ let polls = 0; let warned = false
244
+ let stop = null
245
+ const done = new Promise((res) => { stop = res })
246
+ child.on('error', (err) => { appendEvent(sid, { type: 'error', summary: `${agent} spawn error: ${err.message}` }); stop({ reason: 'exit', code: 127 }) })
247
+ child.on('exit', (code) => stop({ reason: 'exit', code: code ?? -1 }))
248
+
249
+ // claude: the 5h/7d percentages come from Claude Code's usage endpoint
250
+ // (src/taps/claude-usage.mjs); the wall itself arrives through the
251
+ // StopFailure hook.
252
+ let usageTimer = null
253
+ const usageAbort = new AbortController()
254
+ if (agent === 'claude') {
255
+ const pollUsage = async () => {
256
+ const r = await fetchClaudeUsage({ configDir: spec.env.CLAUDE_CONFIG_DIR || LAYOUT.claude.home() })
257
+ const s = readSession(sid)
258
+ if (!isCurrentLeg(s, { pid: child.pid, agent, account })) return
259
+ // a 404, a body that is not JSON, or a shape with no window at all: the
260
+ // card says usage unknown and the StopFailure hook still owns the limit
261
+ const usable = r.ok && r.limits && (r.limits.five_hour || r.limits.seven_day)
262
+ if (usable) {
263
+ recordUsage('claude', account, r.limits, 'claude usage endpoint')
264
+ updateSession(sid, { limits: r.limits, usage_source: 'claude usage endpoint', usage_error: null })
265
+ } else if (!s.usage_error) {
266
+ const why = r.error ?? 'the usage endpoint answered with no window'
267
+ updateSession(sid, { usage_error: why }, { event: { type: 'status', summary: `claude usage unavailable: ${why}` } })
268
+ }
269
+ }
270
+ pollUsage().catch(() => {})
271
+ usageTimer = setInterval(() => pollUsage().catch(() => {}), USAGE_MS)
272
+ usageTimer.unref?.()
273
+ } else if (agent === 'codex' && !(process.env.LEG_CODEX_BIN || process.env.BATON_CODEX_BIN)) {
274
+ const pollUsage = async () => {
275
+ const r = await refreshCodexUsage(account, spec.env.CODEX_HOME || LAYOUT.codex.home(), { signal: usageAbort.signal })
276
+ const s = readSession(sid)
277
+ if (!isCurrentLeg(s, { pid: child.pid, agent, account })) return
278
+ if (r.ok) {
279
+ const patch = { limits: r.limits, usage_source: 'codex app-server account/rateLimits/read', usage_error: null }
280
+ if (r.available === false) {
281
+ patch.status = 'limit'
282
+ patch.limit = { reason: 'usage_limit_exceeded', detail: 'Codex reports ordinary usage is unavailable', resets_at: r.usage.limited_until, at: r.observed_at }
283
+ }
284
+ updateSession(sid, patch)
285
+ } else if (!s.usage_error) {
286
+ updateSession(sid, { usage_error: r.error }, { event: { type: 'status', summary: `codex usage unavailable: ${r.error}` } })
287
+ }
288
+ }
289
+ pollUsage().catch(() => {})
290
+ usageTimer = setInterval(() => pollUsage().catch(() => {}), USAGE_MS)
291
+ usageTimer.unref?.()
292
+ }
293
+
294
+ const timer = setInterval(() => {
295
+ try {
296
+ const s = readSession(sid)
297
+ if (!s) return
298
+ polls += 1
299
+ const patch = {}
300
+ // git: which files this session is touching, where trunk is
301
+ if (polls % GIT_EVERY === 1) {
302
+ const g = gitInfo(s.cwd)
303
+ if (g.repo) { patch.files_dirty = g.dirty; patch.head = g.head; patch.branch = g.branch }
304
+ }
305
+ // codex: find + tail the rollout
306
+ if (agent === 'codex') {
307
+ if (!rollout) {
308
+ const codexHome = spec.env.CODEX_HOME || LAYOUT.codex.home()
309
+ rollout = findRollout({ codexHome, cwd: s.cwd, sinceMs: startedMs })
310
+ if (rollout) { tail = createTail(rollout.path); patch.transcript_path = rollout.path; patch.agent_session_id = rollout.meta.id ?? null; appendEvent(sid, { type: 'agent_ready', summary: `codex thread ${rollout.meta.id ?? '?'}` }) }
311
+ }
312
+ if (tail) {
313
+ const r = parseLines(tail.read())
314
+ if (r.limits) {
315
+ const u = recordUsage('codex', account, r.limits, 'codex rollout token_count', { observed_at: r.limits_at })
316
+ if (u.usage_applied) { patch.limits = r.limits; patch.last_activity = new Date().toISOString() }
317
+ }
318
+ const firstUser = r.messages.find((m) => m.role === 'user')
319
+ if (!s.task && firstUser) patch.task = firstUser.text.slice(0, 500)
320
+ if (r.taskStarted) patch.turns = (s.turns ?? 0) + r.taskStarted
321
+ if (r.files.length) patch.files_touched = [...new Set([...(s.files_touched ?? []), ...r.files])].slice(-200)
322
+ for (const m of r.messages.filter((x) => x.role === 'assistant').slice(-1)) appendEvent(sid, { type: 'turn_done', summary: m.text.slice(0, 160) })
323
+ if (r.limit) {
324
+ const u = markLimited('codex', account, { resets_at: r.limit.resets_at, reason: r.limit.reason, source: 'codex rollout task_complete.error', observed_at: r.limit.observed_at })
325
+ if (u.wall_applied) {
326
+ const { raw, ...lim } = r.limit
327
+ patch.status = 'limit'; patch.limit = { ...lim, resets_at: r.limit.resets_at ?? u.limited_until, at: new Date().toISOString() }
328
+ appendEvent(sid, { type: 'limit', summary: `codex usage limit: ${r.limit.detail}` })
329
+ try { captureLive('codex', 'usage_limit_exceeded', raw ?? lim, { sessionId: sid }) } catch {}
330
+ }
331
+ }
332
+ }
333
+ }
334
+ // agy: log text + history prompts
335
+ if (agent === 'agy') {
336
+ const text = agyTail.read().join('\n')
337
+ const hit = text ? scanLog(text) : null
338
+ if (hit && s.status !== 'limit') {
339
+ const u = markLimited('agy', account, { resets_at: hit.resets_at, reason: hit.signal, source: 'agy log' })
340
+ patch.status = 'limit'; patch.limit = { reason: hit.signal, detail: hit.detail, resets_at: hit.resets_at ?? u.limited_until, at: new Date().toISOString() }
341
+ appendEvent(sid, { type: 'limit', summary: `agy limit (${hit.signal}): ${hit.detail.slice(0, 160)}` })
342
+ try { captureLive('agy', hit.signal, { log_excerpt: hit.detail, resets_at: hit.resets_at }, { sessionId: sid }) } catch {}
343
+ }
344
+ const prompts = promptsSince({ cwd: s.cwd, sinceMs: startedMs })
345
+ // the prompts are this leg's; the count on the card is the session's
346
+ const turns = turnsAtLegStart + prompts.length
347
+ if (prompts.length && turns !== (s.turns ?? 0)) {
348
+ patch.turns = turns; patch.last_activity = new Date().toISOString()
349
+ if (!s.task) patch.task = prompts[0].text.slice(0, 500)
350
+ if (!s.agent_session_id && prompts[0].conversationId) patch.agent_session_id = prompts[0].conversationId
351
+ if (s.status === 'starting') patch.status = 'running'
352
+ }
353
+ }
354
+ // warning threshold (every agent; claude's limits arrive via the usage poller)
355
+ {
356
+ const lim = patch.limits ?? s.limits
357
+ const hot = lim ? [['5h', lim.five_hour], ['7d', lim.seven_day]].filter(([, w]) => w).sort((a, b) => b[1].pct - a[1].pct)[0] : null
358
+ if (hot && hot[1].pct >= WARN_PCT && !warned) {
359
+ warned = true
360
+ patch.warning = { window: hot[0], pct: hot[1].pct, resets_at: hot[1].resets_at, at: new Date().toISOString() }
361
+ if ((patch.status ?? s.status) === 'running') patch.status = 'warning'
362
+ appendEvent(sid, { type: 'warning', summary: `${agent} ${hot[0]} window at ${Math.round(hot[1].pct)}%; next option ${s.chain?.[0] ? s.chain[0].agent : 'none'}` })
363
+ process.stderr.write('\x07')
364
+ }
365
+ }
366
+ // periodic checkpoint of the bundle (every ~2 min while active)
367
+ if (polls % Math.max(1, Math.round(120000 / POLL_MS)) === 0 && (s.turns ?? 0) > 0) {
368
+ try { saveSessionBundle({ ...s, ...patch }, { messages: messagesFor(agent, { ...s, ...patch }), why: 'checkpoint' }) } catch (err) { appendEvent(sid, { type: 'error', summary: `bundle checkpoint failed: ${err.message.slice(0, 160)}` }) }
369
+ }
370
+ const next = Object.keys(patch).length ? updateSession(sid, patch) : s
371
+ const ctl = takeControl(sid)
372
+ // end wins over a merged handoff: control.json now merges writes, so a
373
+ // record can carry both; End (stop entirely) is the stronger, latest intent
374
+ 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 }
375
+ if (ctl?.handoff) {
376
+ 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}` : ''}` } })
377
+ clearInterval(timer); killTree(child.pid); restoreTerminal(); stop({ reason: 'handoff', code: null }); return
378
+ }
379
+ // a stale warning patch can overwrite status:'limit' from the hook, but the
380
+ // limit OBJECT survives the clobber — hand off on either signal
381
+ if ((next.status === 'limit' || next.limit) && (process.env.LEG_NO_HANDOFF || process.env.BATON_NO_HANDOFF) !== '1') {
382
+ clearInterval(timer); killTree(child.pid); restoreTerminal(); stop({ reason: 'limit', code: null })
383
+ }
384
+ } catch (err) {
385
+ try { appendEvent(sid, { type: 'error', summary: `tap error: ${String(err.message).slice(0, 160)}` }) } catch {}
386
+ }
387
+ }, POLL_MS)
388
+ timer.unref?.()
389
+ const result = await done
390
+ clearInterval(timer)
391
+ usageAbort.abort()
392
+ if (usageTimer) clearInterval(usageTimer)
393
+ void boardUrl
394
+ return result
395
+ }
396
+
397
+ // The countdown: one line rewritten in place on a TTY, one line a minute
398
+ // otherwise. Ends 'ready' at the reset, 'cancelled' on Ctrl-C or a board End.
399
+ async function waitInTerminal({ sid, label, resetsAt }) {
400
+ const ac = new AbortController()
401
+ const onSigint = () => ac.abort()
402
+ process.once('SIGINT', onSigint)
403
+ const tty = Boolean(process.stderr.isTTY)
404
+ let lastLine = 0
405
+ const r = await waitForReset({
406
+ resetsAt, signal: ac.signal, tickMs: Number(process.env.LEG_WAIT_TICK_MS || process.env.BATON_WAIT_TICK_MS || 1000),
407
+ isCancelled: () => { const c = takeControl(sid); if (c?.end) return true; if (c?.handoff) appendEvent(sid, { type: 'status', summary: 'hand-off requested while waiting; every option is still out' }); return false },
408
+ onTick: (remaining) => {
409
+ const line = `[leg] waiting for ${label} · ${fmtCountdown(remaining)} to the reset (${new Date(resetsAt * 1000).toLocaleTimeString()}) · Ctrl-C to quit`
410
+ if (tty) process.stderr.write(`\r\x1b[2K${line}`)
411
+ else if (Date.now() - lastLine >= 60000) { lastLine = Date.now(); process.stderr.write(line + '\n') }
412
+ },
413
+ })
414
+ process.removeListener('SIGINT', onSigint)
415
+ if (tty) process.stderr.write('\r\x1b[2K')
416
+ if (r === 'ready') say(`${label} is back; starting it from the bundle`)
417
+ return r
418
+ }
419
+
420
+ function messagesFor(agent, s) {
421
+ if (agent === 'claude') return claudeTail(s.transcript_path)
422
+ if (agent === 'codex') return codexTail(s.transcript_path)
423
+ return []
424
+ }
425
+
426
+ // Choose and commit the next provider under the same session-file lock used by
427
+ // the board's order editor. Whichever writer acquires the lock first wins:
428
+ // an order save is consumed here, or the editor sees handing_off and refuses.
429
+ // No eligible choice leaves the session unclaimed so all-out waiting can keep
430
+ // accepting order edits.
431
+ export function claimHandoffChoice({ sid, agent, account, installed, bundle = null, reason = 'limit', nowS = Math.floor(Date.now() / 1000) }) {
432
+ let choice = { next: null, out: [] }
433
+ let claimed = false
434
+ const session = updateSession(sid, (current) => {
435
+ const accounts = readAccounts()
436
+ const order = normalizeHandoffOrder(current.handoff_order)
437
+ choice = chooseNext({ agent, account, accounts, installed, order, nowS })
438
+ if (!choice.next && isAvailable(readUsage(agent, account), nowS)) choice = { next: { agent, account }, out: [] }
439
+ if (!choice.next) return {}
440
+ claimed = true
441
+ return {
442
+ status: 'handing_off',
443
+ waiting: null,
444
+ all_out: null,
445
+ handoff: {
446
+ from: { agent, account },
447
+ to: choice.next,
448
+ bundle_id: bundle?.id ?? null,
449
+ reason: reason === 'limit' ? 'usage limit' : 'requested',
450
+ at: new Date().toISOString(),
451
+ },
452
+ }
453
+ })
454
+ return { choice, claimed, session }
455
+ }
456
+
457
+ // ---- the command ----
458
+ export async function attach(agent, args = [], { open = true } = {}) {
459
+ if (!AGENTS.includes(agent)) throw new Error(`unknown agent "${agent}" (claude|codex|agy)`)
460
+ // the paid gate: a valid key, or no session (exit 4). The bare agent is never
461
+ // affected; only what Baton adds is licensed.
462
+ const ent = entitlement()
463
+ if (!allows(ent, 'run')) { say(describeLicense(ent)); return 4 }
464
+ // --no-worktree is Baton's flag, not the agent's: it never passes through
465
+ const shareCheckout = args.includes('--no-worktree')
466
+ args = args.filter((a) => a !== '--no-worktree')
467
+ const cwd = process.cwd()
468
+ const board = await ensureBoard({ open })
469
+ let accounts = readAccounts()
470
+ const installed = await installedAgents()
471
+ const handoffOrder = readPreferences().handoff_order
472
+ let account = process.env.LEG_ACCOUNT || process.env.BATON_ACCOUNT || 'default'
473
+ if (!accounts[agent].includes(account)) { say(`no ${agent} account "${account}"; using default`); account = 'default' }
474
+ // A persisted wall is only a cache. Ask Codex's read-only account endpoint
475
+ // before using it to skip this login; an explicit true can clear an older
476
+ // wall, false refreshes it, and unknown preserves it.
477
+ if (agent === 'codex' && installed.codex && !process.env.BATON_CODEX_BIN) {
478
+ const codexHome = envFor('codex', account).CODEX_HOME || LAYOUT.codex.home()
479
+ await refreshCodexUsage(account, codexHome).catch(() => {})
480
+ }
481
+ // Start on an account that is not at its wall, if we already know one is.
482
+ const nowS = Math.floor(Date.now() / 1000)
483
+ const u0 = readUsage(agent, account)
484
+ if (u0.limited_until && u0.limited_until > nowS) {
485
+ const alt = chooseNext({ agent, account, accounts, installed, order: handoffOrder, nowS })
486
+ 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 }
487
+ else say(`${agent} (${account}) is at its limit until ${fmtReset(u0.limited_until)}; starting anyway (every option is out)`)
488
+ }
489
+ const g = gitInfo(cwd)
490
+ const sid = newSessionId(agent)
491
+ const chain = candidates({ agent, account, accounts, order: handoffOrder })
492
+ // record the session BEFORE cutting a worktree, so a crash or Ctrl-C during
493
+ // `git worktree add` still leaves a card (with a Remove button), never a
494
+ // silent orphan under .baton-worktrees with no record and no button
495
+ 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] })
496
+ let iso = null
497
+ if (g.repo && !shareCheckout) {
498
+ try { iso = isolate({ g, cwd, sid }) } catch (err) { say(`could not make a worktree (${String(err.message).split('\n')[0].slice(0, 200)}); sharing the checkout`); try { removeWorktree(g.repo, sid) } catch {} }
499
+ }
500
+ if (iso) updateSession(sid, { cwd: iso.cwd, branch: iso.branch, worktree: { path: iso.path, branch: iso.branch, base: iso.base } })
501
+ updateSession(sid, { head_at_start: g.head, head: g.head, files_dirty: iso ? [] : g.dirty, board_url: board.url })
502
+ say(`session ${sid} · ${agent}${account !== 'default' ? '/' + account : ''} · board ${board.url ?? 'off'}${board.started ? ' (started)' : ''} · next: ${chain.map((c) => c.agent + (c.account !== 'default' ? '/' + c.account : '')).join(' → ') || 'none'}`)
503
+ if (iso) {
504
+ const others = iso.live.map((s) => `${s.agent} ${s.session_id.split('-').pop()}`).join(', ')
505
+ say(`another session is live in this checkout (${others}): this one works in ${iso.cwd} on ${iso.branch}; Land on its card brings it to ${iso.base ?? 'nothing (detached HEAD)'} · --no-worktree to share`)
506
+ appendEvent(sid, { type: 'worktree', summary: `own worktree ${iso.path} on ${iso.branch} from ${iso.base ?? 'a detached HEAD'}; live in the checkout: ${others}` })
507
+ }
508
+
509
+ let prompt = null
510
+ let legArgs = args
511
+ let exit = 0
512
+ // unbounded: the 12-leg cap below stops a runaway chain, and the all-out wait
513
+ // bounds a wait; a normal session runs one leg and exits
514
+ for (let leg = 0; ; leg++) {
515
+ const s = readSession(sid)
516
+ const r = await runLeg({ agent, account, args: legArgs, session: s, prompt, boardUrl: board.url })
517
+ if (r.reason === 'exit') { exit = r.code ?? 0; break }
518
+ // limit or handoff: bundle, choose next, go again in this terminal
519
+ const cur = readSession(sid)
520
+ say(r.reason === 'limit' ? `${agent} hit its usage limit${cur.limit?.detail ? `: ${cur.limit.detail.slice(0, 140)}` : ''}` : 'handing off as requested')
521
+ const whyStopped = r.reason === 'limit' ? `${agent} usage limit` : 'handoff requested (the agent was stopped mid-turn; edits in the worktree may be half-applied)'
522
+ let bundle = null
523
+ try { bundle = saveSessionBundle(cur, { messages: messagesFor(agent, cur), why: whyStopped }); say(`bundle saved: ${bundle.path}`) } catch (err) { say(`bundle save failed: ${err.message}`) }
524
+ // saveSessionBundle writes the notes file before it shells out to chb, so
525
+ // even when chb is missing and the save throws, the context is on disk
526
+ const notesFile = join(workRoot(cur) ?? cur.cwd, '.leg', `session-${sid}.md`)
527
+ let claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason })
528
+ let choice = claim.choice
529
+ let cancelled = false
530
+ while (!choice.next) {
531
+ // every option is out: keep the terminal, count down to the SOONEST reset
532
+ // (the current agent's own wall included — it may be the first back), then
533
+ // start that option from the bundle. Ctrl-C (or End) quits with exit 3.
534
+ const own = readUsage(agent, account)
535
+ const all = [...choice.out]
536
+ if (Number.isFinite(own.limited_until)) all.push({ agent, account, resets_at: own.limited_until, reason: own.limited_reason ?? 'limit' })
537
+ all.sort((a, b) => (a.resets_at ?? Infinity) - (b.resets_at ?? Infinity))
538
+ const first = all[0]
539
+ const label = first ? `${first.agent}${first.account !== 'default' ? '/' + first.account : ''}` : 'unknown'
540
+ say(`every option is out. First back: ${label} at ${first ? fmtReset(first.resets_at) : 'unknown'}`)
541
+ for (const o of all) say(` ${o.agent}${o.account !== 'default' ? '/' + o.account : ''}: resets ${fmtReset(o.resets_at)}`)
542
+ say(`waiting for ${label}; Ctrl-C to quit`)
543
+ 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'}` } })
544
+ const r2 = await waitInTerminal({ sid, label, resetsAt: first?.resets_at ?? null })
545
+ if (r2 === 'cancelled') { cancelled = true; break }
546
+ claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason })
547
+ choice = claim.choice
548
+ }
549
+ if (cancelled) {
550
+ updateSession(sid, { status: 'ended', ended_at: new Date().toISOString(), waiting: null }, { event: { type: 'ended', summary: 'quit while waiting for a reset (exit 3)' } })
551
+ exit = 3
552
+ break
553
+ }
554
+ const next = choice.next
555
+ // bound the number of hand-offs in one terminal so a chain that limits
556
+ // instantly can never loop forever; stopping is explicit, not a silent exit 0
557
+ if (leg >= 11) {
558
+ say(`reached the 12-leg hand-off limit for one session; stopping. Run leg again in this directory to continue from the bundle.`)
559
+ updateSession(sid, { status: 'ended', ended_at: new Date().toISOString(), exit_code: 3 }, { event: { type: 'ended', summary: 'reached the 12-leg hand-off limit; stopped (exit 3)' } })
560
+ exit = 3
561
+ break
562
+ }
563
+ appendEvent(sid, { type: 'handoff', summary: `${agent}${account !== 'default' ? '/' + account : ''} → ${next.agent}${next.account !== 'default' ? '/' + next.account : ''}${bundle ? ` (bundle ${bundle.id})` : ''}` })
564
+ prompt = bundle
565
+ ? resumePrompt(cur, bundle, next)
566
+ : `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).` : ''} Check git status and git diff, then continue the work. The task: ${cur.task ?? 'see the recent changes'}`
567
+ say(`starting ${next.agent}${next.account !== 'default' ? '/' + next.account : ''} in this terminal from the bundle`)
568
+ agent = next.agent; account = next.account; legArgs = []
569
+ // the chain is what comes after the agent now taking over, not after the
570
+ // one that started the session: the card's "next" names a live option
571
+ updateSession(sid, (fresh) => {
572
+ const freshOrder = normalizeHandoffOrder(fresh.handoff_order)
573
+ return { lineage: { from: cur.agent, to: next.agent }, chain: candidates({ agent: next.agent, account: next.account, accounts: readAccounts(), order: freshOrder }) }
574
+ })
575
+ }
576
+ const fin = readSession(sid)
577
+ if (fin && fin.status !== 'ended') updateSession(sid, { status: 'ended', ended_at: new Date().toISOString(), exit_code: exit }, { event: { type: 'ended', summary: `session ended (exit ${exit})` } })
578
+ // Every way out of the loop arrives here: a clean exit, a cancelled wait, the
579
+ // 12-leg cap, an agent that never started. The terminal is gone, so RESUME.md
580
+ // must stop describing it as live — Baton owns that file, and leaving the last
581
+ // hand-off sitting there is exactly the lie this rewrite exists to stop.
582
+ try { endSessionPointer(readSession(sid)) } catch (err) { appendEvent(sid, { type: 'error', summary: `resume pointer not rewritten: ${err.message.slice(0, 160)}` }) }
583
+ try { rmSync(join(sessionDir(sid), 'control.json'), { force: true }) } catch {}
584
+ return exit
585
+ }
586
+
587
+ export function resolveArgs(argv) {
588
+ const [agent, ...rest] = argv
589
+ return { agent, args: rest }
590
+ }
591
+
592
+ void existsSync; void resolve