@ucsandman/legcli 0.13.1 → 0.15.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.
package/src/attach.mjs CHANGED
@@ -10,29 +10,30 @@
10
10
  import http from 'node:http'
11
11
  import net from 'node:net'
12
12
  import { spawn, spawnSync } from 'node:child_process'
13
- import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } 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 { git, status as gitStatus } from './git.mjs'
17
18
  import { home, readCard, listCards } from './store.mjs'
18
19
  import { loadResume } from './handoff.mjs'
19
20
  import { worktreePath } from './worktree.mjs'
20
21
  import { get as getAdapter } from './adapters/index.mjs'
21
- import { SUPERVISED_AGENTS, HANDOFF_ORDER_CAPABILITY, newSessionId, createSession, readSession, updateSession, appendEvent, takeControl, sessionDir, listSessions, reapLost, isActive, workRoot } from './sessions.mjs'
22
+ import { SUPERVISED_AGENTS, HANDOFF_ORDER_CAPABILITY, newSessionId, createSession, readSession, updateSession, appendEvent, takeControl, clearControl, sessionDir, listSessions, reapLost, isActive, workRoot } from './sessions.mjs'
22
23
  import { ensure as ensureWorktree, remove as removeWorktree } from './worktree.mjs'
23
- import { canonPath, realPath } from './fsx.mjs'
24
+ import { canonPath, realPath, writeJsonAtomic } from './fsx.mjs'
24
25
  import { whoami, readShare, isOn as shareIsOn } from './share.mjs'
25
26
  import { readAccounts, envFor, refreshAccount } from './accounts.mjs'
26
- import { recordUsage, markLimited, chooseNext, candidates, fmtReset, WARN_PCT, readUsage, usageIsStale, isAvailable, wallActive, rungLabel, skipLine } from './usage.mjs'
27
+ import { recordUsage, markLimited, chooseNext, candidates, fmtReset, WARN_PCT, readUsage, usageIsStale, isAvailable, wallActive, rungLabel, skipLine, keepsConversation as keepsConversationRule } from './usage.mjs'
27
28
  import { entitlement, allows, describe as describeLicense } from './license.mjs'
28
29
  import { writeSettings, userStatusLine, transcriptTail as claudeTail, modelAlias, modelFromTranscript, printable } from './taps/claude.mjs'
29
- import { modelFlagFor, isDownshift } from './buckets.mjs'
30
+ import { modelFlagFor } from './buckets.mjs'
30
31
  import { ensureTrust, trustLine } from './trust.mjs'
31
32
  import { findRollout, createTail, parseLines, readCodexUsage, transcriptTail as codexTail } from './taps/codex.mjs'
32
33
  import { fetchClaudeUsage } from './taps/claude-usage.mjs'
33
34
  import { scanLog, promptsSince, logSize } from './taps/agy.mjs'
34
35
  import { fetchGrokUsage, scanLog as scanGrokLog, promptsSince as grokPromptsSince } from './taps/grok.mjs'
35
- import { saveSessionBundle, resumePrompt, sessionCommitDelta } from './bundle.mjs'
36
+ import { saveSessionBundle, saveSessionBundleAsync, resumePrompt, sessionCommitDelta } from './bundle.mjs'
36
37
  import { endSessionPointer } from './resume.mjs'
37
38
  import { openBoard, pidfile } from './launcher.mjs'
38
39
  import { LAYOUT } from './accounts.mjs'
@@ -76,7 +77,11 @@ export function isCurrentLeg(session, { pid, agent, account }) {
76
77
  }
77
78
 
78
79
  // ---- board ----
79
- function health(port, host = '127.0.0.1') {
80
+ // `unref`: the socket is not a reason for this process to stay alive. The
81
+ // board wait runs in the background now, and a pending health probe would
82
+ // otherwise keep a terminal whose agent has already exited on the loop for up
83
+ // to the four-second timeout.
84
+ function health(port, host = '127.0.0.1', { unref = false } = {}) {
80
85
  return new Promise((res) => {
81
86
  const req = http.get({ host, port, path: '/api/health', timeout: 4000 }, (r) => {
82
87
  let d = ''
@@ -87,6 +92,7 @@ function health(port, host = '127.0.0.1') {
87
92
  try { res(r.statusCode === 200 ? JSON.parse(d) : null) } catch { res(null) }
88
93
  })
89
94
  })
95
+ if (unref) req.on('socket', (s) => s.unref())
90
96
  req.on('error', () => res(null)); req.on('timeout', () => { req.destroy(); res(null) })
91
97
  })
92
98
  }
@@ -104,7 +110,7 @@ function portTaken(port, host = '127.0.0.1') {
104
110
  })
105
111
  }
106
112
 
107
- export async function ensureBoard({ open = true } = {}) {
113
+ export async function ensureBoard({ open = true, wait = true } = {}) {
108
114
  // with share on the board lives on the shared address, not loopback
109
115
  const share = readShare()
110
116
  const shared = shareIsOn(share)
@@ -130,38 +136,79 @@ export async function ensureBoard({ open = true } = {}) {
130
136
  const logFd = (await import('node:fs')).openSync(join(home(), 'board.log'), 'a')
131
137
  const child = spawn(process.execPath, [SERVER], { detached: true, windowsHide: true, stdio: ['ignore', logFd, logFd], env: { ...process.env, LEG_PORT: String(port), LEG_BIND: host, LEG_QUIET: '0', BATON_PORT: String(port), BATON_BIND: host, BATON_QUIET: '0' } })
132
138
  child.unref()
139
+ // The agent starts NOW. This used to poll /api/health every 200 ms for up to
140
+ // fifteen seconds before the human's agent got its first instruction, and a
141
+ // board takes about 1.1 s to answer: 487 ms to the agent became 1280 ms on
142
+ // the one terminal of the day that has to start a board (profile 2026-09-18,
143
+ // §2, top-10 item 10). `settled` is how the caller can still make sure the
144
+ // board it started got claimed before it exits.
145
+ const settled = awaitBoard({ child, port, host, url, open, unref: !wait })
146
+ // a caller that spawns a board and then exits (leg share on|off) must have the
147
+ // pidfile before it returns, or `leg down` has nothing to stop
148
+ if (wait) { const r = await settled; return { url, started: r.claimed, ...(r.failed ? { failed: true } : {}) } }
149
+ return { url, started: true, settled }
150
+ }
151
+
152
+ // The old blocking poll, moved behind the agent. Same 15 s budget, same give-up
153
+ // line, same pidfile. `unref`: for the terminal, the timer and the socket are
154
+ // not reasons to stay alive once the agent is gone — but a caller AWAITING this
155
+ // has nothing else on the loop, and an unref'd wait would let the process exit
156
+ // before the answer arrived.
157
+ function awaitBoard({ child, port, host, url, open, unref = false, budgetMs = 15000 }) {
133
158
  const t0 = Date.now()
134
- while (Date.now() - t0 < 15000) {
135
- const h = await health(port, host)
136
- if (h) {
137
- // only claim the pidfile for a child we actually started: under a race,
138
- // another `leg` won the port and ours died on EADDRINUSE writing our
139
- // dead pid would make `leg down` kill nothing and report "not running"
140
- const ours = h.pid ? h.pid === child.pid : (child.exitCode === null && Boolean(child.pid))
141
- 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')
142
- if (open) openBoard(url)
143
- return { url, started: ours }
159
+ return new Promise((res) => {
160
+ const tick = async () => {
161
+ const h = await health(port, host, { unref })
162
+ if (h) {
163
+ // only claim the pidfile for a child we actually started: under a race,
164
+ // another `leg` won the port and ours died on EADDRINUSE — writing our
165
+ // dead pid would make `leg down` kill nothing and report "not running"
166
+ const ours = h.pid ? h.pid === child.pid : (child.exitCode === null && Boolean(child.pid))
167
+ 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')
168
+ if (open) openBoard(url)
169
+ return res({ claimed: ours })
170
+ }
171
+ if (Date.now() - t0 >= budgetMs) {
172
+ say(`board did not come up on ${url} (see ${join(home(), 'board.log')}); continuing without it`)
173
+ return res({ claimed: false, failed: true })
174
+ }
175
+ const t = setTimeout(() => { tick().catch(() => res({ claimed: false })) }, 200)
176
+ if (unref) t.unref?.()
144
177
  }
145
- await new Promise((r) => setTimeout(r, 200))
146
- }
147
- say(`board did not come up on ${url} (see ${join(home(), 'board.log')}); continuing without it`)
148
- return { url, started: false, failed: true }
178
+ tick().catch(() => res({ claimed: false }))
179
+ })
149
180
  }
150
181
 
151
- // ---- git ----
152
- function git(cwd, args) {
153
- const r = spawnSync('git', args, { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
154
- // trimEnd only: a porcelain line starts with a space (" M README.md")
155
- return r.status === 0 ? r.stdout.trimEnd() : null
182
+ // A terminal whose agent exits in the first second — `leg claude --help`, a
183
+ // stub in the suite — would otherwise leave the board it just started with no
184
+ // pidfile, so `leg down` could not stop it. Bounded: the claim usually landed
185
+ // long ago, and an exit must never hang on a board that is not coming.
186
+ async function claimBoardBeforeExit(settled, ms = 2000) {
187
+ if (!settled) return
188
+ let t = null
189
+ try { await Promise.race([settled, new Promise((r) => { t = setTimeout(r, ms) })]) } catch {} finally { if (t) clearTimeout(t) }
156
190
  }
191
+
192
+ // ---- git ----
193
+ // src/git.mjs owns the subprocess and the porcelain. Everything below is the
194
+ // shape the rest of Leg was written against.
195
+
196
+ // A repo with no commits has no HEAD, so `rev-parse --abbrev-ref HEAD` failed
197
+ // there and the branch stayed null; isolate() reads that as "nothing to cut a
198
+ // worktree from". status() knows the name, so the null is kept on purpose.
199
+ const branchOf = (st) => (st && st.head ? st.branch : null)
200
+
157
201
  export function gitInfo(cwd) {
158
202
  const repo = git(cwd, ['rev-parse', '--show-toplevel'])
159
203
  if (!repo) return { repo: null, branch: null, head: null, dirty: [] }
204
+ // one `status --porcelain=v2 --branch` in place of --abbrev-ref HEAD,
205
+ // rev-parse HEAD and --porcelain: three fewer processes per call
206
+ const st = gitStatus(cwd)
160
207
  return {
161
208
  repo: repo.replace(/\//g, process.platform === 'win32' ? '\\' : '/'),
162
- branch: git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']),
163
- head: git(cwd, ['rev-parse', 'HEAD']),
164
- 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)),
209
+ branch: branchOf(st),
210
+ head: st?.head ?? null,
211
+ dirty: st?.dirty ?? [],
165
212
  }
166
213
  }
167
214
 
@@ -179,6 +226,24 @@ export function aheadCount(cwd, fallbackBase = null) {
179
226
  return Number.isFinite(parsed) ? parsed : null
180
227
  }
181
228
 
229
+ // The same count off a status() answer already in hand, which is where the poll
230
+ // gets it: `# branch.ab +N` when the checkout tracks an upstream that exists,
231
+ // else a rev-list from the commit HEAD was at when the session began. Two of the
232
+ // six git processes per poll round were this question; now it is usually none.
233
+ // `cache` is one { head, ahead } pair: a terminal whose HEAD has not moved
234
+ // cannot have changed its count, and a HEAD still at the start is exactly zero.
235
+ export function aheadFromStatus(cwd, st, fallbackBase = null, cache = null) {
236
+ if (st?.upstream && Number.isFinite(st.ahead)) return st.ahead
237
+ if (!fallbackBase || !st?.head) return null
238
+ if (st.head === fallbackBase) return 0
239
+ if (cache && cache.head === st.head) return cache.ahead
240
+ const n = git(cwd, ['rev-list', '--count', `${fallbackBase}..${st.head}`])
241
+ const parsed = parseInt(String(n ?? '').trim(), 10)
242
+ const ahead = Number.isFinite(parsed) ? parsed : null
243
+ if (cache) { cache.head = st.head; cache.ahead = ahead }
244
+ return ahead
245
+ }
246
+
182
247
  // ---- collisions ----
183
248
  // Two agents in one working tree write over each other's files. When another
184
249
  // live session already works in this checkout, this one gets its own:
@@ -206,8 +271,34 @@ async function loadAdapter(name) {
206
271
  return getAdapter(name)
207
272
  }
208
273
 
274
+ // A resolved absolute path is one existsSync and needs no cache. A bare name on
275
+ // PATH is a subprocess with an 8 s timeout budget, per agent, on every launch —
276
+ // that is the answer worth keeping, and it is kept for a day in
277
+ // $LEG_HOME/installed.json keyed by the bin it resolved to.
278
+ const INSTALLED_TTL_MS = 24 * 60 * 60 * 1000
279
+ const installedFile = () => join(home(), 'installed.json')
280
+
281
+ function probeVersion(target) {
282
+ const r = spawnSync(target, ['--version'], { windowsHide: true, encoding: 'utf8', timeout: 8000 })
283
+ return !r.error && r.status === 0
284
+ }
285
+
286
+ // `probe`, `now` and `ttlMs` are injected by test/attach-perf.test.mjs, which is
287
+ // the only thing that can prove a cache hit spawned nothing.
288
+ export function cachedVersionProbe(name, target, { probe = probeVersion, now = Date.now(), ttlMs = INSTALLED_TTL_MS } = {}) {
289
+ const file = installedFile()
290
+ let disk = {}
291
+ try { const j = JSON.parse(readFileSync(file, 'utf8')); if (j && typeof j === 'object') disk = j } catch {}
292
+ const hit = disk[name]
293
+ // a different resolved bin is a different question, so it is a miss
294
+ if (hit && hit.bin === target && Number.isFinite(hit.at) && now - hit.at >= 0 && now - hit.at < ttlMs) return Boolean(hit.installed)
295
+ const installed = probe(target)
296
+ try { mkdirSync(home(), { recursive: true }); writeJsonAtomic(file, { ...disk, [name]: { installed, bin: target, at: now } }) } catch {}
297
+ return installed
298
+ }
299
+
209
300
  let installedCache = null
210
- async function installedAgents() {
301
+ export async function installedAgents() {
211
302
  if (installedCache) return installedCache
212
303
  const out = {}
213
304
  for (const name of SUPERVISED_AGENTS) {
@@ -215,7 +306,10 @@ async function installedAgents() {
215
306
  const { bin, viaNode, entry } = (await loadAdapter(name)).resolve()
216
307
  const target = viaNode ? (entry ?? bin) : bin
217
308
  if (/[\\/]/.test(target)) out[name] = existsSync(target)
218
- else { const r = spawnSync(target, ['--version'], { windowsHide: true, encoding: 'utf8', timeout: 8000 }); out[name] = !r.error && r.status === 0 }
309
+ // a *_BIN override is somebody pointing this at a stub on purpose: ask,
310
+ // and remember nothing about it
311
+ else if (process.env[`LEG_${name.toUpperCase()}_BIN`] || process.env[`BATON_${name.toUpperCase()}_BIN`]) out[name] = probeVersion(target)
312
+ else out[name] = cachedVersionProbe(name, target)
219
313
  } catch { out[name] = false }
220
314
  }
221
315
  installedCache = out
@@ -412,6 +506,22 @@ export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd,
412
506
  return { bin: viaNode ? process.execPath : bin, args: argv, env, cwd }
413
507
  }
414
508
 
509
+ // One bundle checkpoint at a time. The save shells out to the python CLI, so
510
+ // it is off the poll tick now (src/bundle.mjs saveSessionBundleAsync) and can
511
+ // outlive its own interval; two of them against a session's single bundle slug
512
+ // is the one thing that was impossible while it blocked. `idle()` is how the
513
+ // hand-off save makes sure no checkpoint is still writing.
514
+ export function checkpointGate() {
515
+ let pending = null
516
+ const gate = (run) => {
517
+ if (pending) return false
518
+ pending = Promise.resolve().then(run).catch(() => {}).finally(() => { pending = null })
519
+ return true
520
+ }
521
+ gate.idle = () => pending ?? Promise.resolve()
522
+ return gate
523
+ }
524
+
415
525
  // ---- one agent leg ----
416
526
  // Returns { reason: 'exit'|'limit'|'handoff', code, target } — `target` is the
417
527
  // destination a human picked on the board ("Hand off now to codex"), carried
@@ -466,6 +576,10 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
466
576
  tail = createTail(rollout.path, { from: logSize(rollout.path) })
467
577
  }
468
578
  let polls = 0; let warned = false; let stoodDown = false
579
+ // one { head, ahead } pair: a HEAD that has not moved cannot have changed its
580
+ // commit count, so the poll spawns nothing for it
581
+ const aheadCache = { head: null, ahead: null }
582
+ const checkpoint = checkpointGate()
469
583
  let stop = null
470
584
  const done = new Promise((res) => { stop = res })
471
585
  child.on('error', (err) => { appendEvent(sid, { type: 'error', summary: `${agent} spawn error: ${err.message}` }); stop({ reason: 'exit', code: 127 }) })
@@ -548,10 +662,15 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
548
662
  if (!s) return
549
663
  polls += 1
550
664
  const patch = {}
551
- // git: which files this session is touching, where trunk is
665
+ // git: which files this session is touching, where trunk is. ONE process:
666
+ // `status --porcelain=v2 --branch` carries the head, the branch, the dirty
667
+ // list and the upstream's own ahead count, and a non-answer is the "not a
668
+ // repository" gate `rev-parse --show-toplevel` used to be. This poll ran
669
+ // six git processes every six seconds and blocked the terminal's own event
670
+ // loop 4.4-10.3 s a minute (profile 2026-09-18, §3, top-10 item 2).
552
671
  if (polls % GIT_EVERY === 1) {
553
- const g = gitInfo(s.cwd)
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) }
672
+ const st = gitStatus(s.cwd)
673
+ if (st) { patch.files_dirty = st.dirty; patch.head = st.head; patch.branch = branchOf(st); patch.ahead = aheadFromStatus(s.cwd, st, s.head_at_start, aheadCache) }
555
674
  }
556
675
  // codex: find + tail the rollout
557
676
  if (agent === 'codex') {
@@ -633,9 +752,16 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
633
752
  process.stderr.write('\x07')
634
753
  }
635
754
  }
636
- // periodic checkpoint of the bundle (every ~2 min while active)
755
+ // periodic checkpoint of the bundle (every ~2 min while active), off this
756
+ // tick: the save is a python subprocess with a 120 s timeout, and taking
757
+ // it synchronously froze limit detection, the board's End and Hand off
758
+ // buttons and every tap for its whole run. Failures still land on the
759
+ // session's timeline, exactly as they did.
637
760
  if (polls % Math.max(1, Math.round(120000 / POLL_MS)) === 0 && (s.turns ?? 0) > 0) {
638
- 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)}` }) }
761
+ const at = { ...s, ...patch }
762
+ checkpoint(async () => {
763
+ try { await saveSessionBundleAsync(at, { messages: messagesFor(agent, at), why: 'checkpoint' }) } catch (err) { appendEvent(sid, { type: 'error', summary: `bundle checkpoint failed: ${err.message.slice(0, 160)}` }) }
764
+ })
639
765
  }
640
766
  const next = Object.keys(patch).length ? updateSession(sid, patch) : s
641
767
  const ctl = takeControl(sid)
@@ -672,6 +798,9 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
672
798
  clearInterval(timer)
673
799
  if (usageTimer) clearInterval(usageTimer)
674
800
  if (fallbackTimer) clearInterval(fallbackTimer)
801
+ // the hand-off save runs next, against the same bundle slug: a checkpoint
802
+ // still writing would be two chb processes on one bundle
803
+ await checkpoint.idle()
675
804
  return result
676
805
  }
677
806
 
@@ -845,7 +974,7 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
845
974
  }
846
975
  const autoApprove = resolveAutoApprove({ cliFlag: autoApproveCli })
847
976
  const cwd = card ? realPath(cardWorkRoot(card)) : (cwdOpt ? realPath(cwdOpt) : process.cwd())
848
- const board = await ensureBoard({ open })
977
+ const board = await ensureBoard({ open, wait: false })
849
978
  let accounts = readAccounts()
850
979
  const installed = await installedAgents()
851
980
  // The machine's preferences are copied into this terminal at start: the
@@ -1007,7 +1136,10 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
1007
1136
  // bound the number of hand-offs in one terminal so a chain that limits
1008
1137
  // instantly can never loop forever; stopping is explicit, not a silent exit 0
1009
1138
  if (leg >= 11) {
1010
- say(`reached the 12-leg hand-off limit for one session; stopping. Run leg again in this directory to continue from the bundle.`)
1139
+ // Nothing reloads a bundle on a fresh `leg <agent>`, so this used to
1140
+ // promise a hand-off that never happened. The path and the reader are the
1141
+ // true part.
1142
+ say(`reached the 12-leg hand-off limit for one session; stopping.${bundle?.path ? ` The bundle is at ${bundle.path}; \`leg resume\` prints the hand-off it describes. A fresh \`leg <agent>\` here is a new session and does not load it: point that agent at the file.` : ' No bundle was saved for this hand-off.'}`)
1011
1143
  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)' } })
1012
1144
  exit = 3
1013
1145
  break
@@ -1022,15 +1154,16 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
1022
1154
  say(line)
1023
1155
  appendEvent(sid, { type: 'status', summary: line })
1024
1156
  }
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.
1157
+ // The hand-offs that keep the conversation (src/usage.mjs keepsConversation):
1158
+ // a claude downshift on the same login, or another claude login that can
1159
+ // see this transcript. `--resume <id>` starts the next leg inside the same
1160
+ // conversation, so the bundle is not written into a prompt and nothing is
1161
+ // re-explained. Every other rung takes the bundle. The destination account
1162
+ // is refreshed first so a login made by an older Leg gets its `projects`
1163
+ // junction before the rule looks for the transcript through it.
1032
1164
  const fromRung = { agent, account, model: cur.model ?? null }
1033
- const keepsConversation = Boolean(next.agent === 'claude' && isDownshift(fromRung, next) && cur.agent_session_id)
1165
+ if (next.account !== 'default') refreshAccount(next.agent, next.account)
1166
+ const keepsConversation = keepsConversationRule({ from: fromRung, to: next, session: cur })
1034
1167
  appendEvent(sid, { type: 'handoff', summary: `${rungLabel(fromRung)} → ${rungLabel(next)}${keepsConversation ? ' (kept the conversation)' : bundle ? ` (bundle ${bundle.id})` : ''}` })
1035
1168
  if (keepsConversation) {
1036
1169
  prompt = null
@@ -1062,7 +1195,10 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
1062
1195
  // must stop describing it as live — Leg owns that file, and leaving the last
1063
1196
  // hand-off sitting there is exactly the lie this rewrite exists to stop.
1064
1197
  try { endSessionPointer(readSession(sid)) } catch (err) { appendEvent(sid, { type: 'error', summary: `resume pointer not rewritten: ${err.message.slice(0, 160)}` }) }
1065
- try { rmSync(join(sessionDir(sid), 'control.json'), { force: true }) } catch {}
1198
+ try { clearControl(sid) } catch {}
1199
+ // the board this terminal started may still be coming up: claim it before we
1200
+ // go, or `leg down` has nothing to stop
1201
+ await claimBoardBeforeExit(board.settled)
1066
1202
  return exit
1067
1203
  }
1068
1204
 
@@ -68,7 +68,7 @@
68
68
  // release lands on disk under a board that was started before it: the page
69
69
  // then draws controls the process has no routes for (an empty agent select,
70
70
  // no buckets). test/files-version.test.mjs pins this to package.json.
71
- const FILES_VERSION = '0.13.1'
71
+ const FILES_VERSION = '0.15.0'
72
72
  const TIMELINE_CAP = 12
73
73
  // mirrors LOOPBACK in src/auth.mjs; state.bind is "<host>:<port>" and an IPv6
74
74
  // host arrives bracketed
@@ -50,7 +50,7 @@
50
50
  // name. test/board-updates.test.mjs pins `state.stopped` in this file by
51
51
  // source text, which is why the module object keeps the name.
52
52
  // pinned to package.json by test/files-version.test.mjs; see board.js FILES_VERSION
53
- const FILES_VERSION = '0.13.1'
53
+ const FILES_VERSION = '0.15.0'
54
54
  const state = { es: null, retryMs: 1000, timers: [], stopped: false, sseRequest: 0, floorRequest: 0, trunkRequest: 0, headRequest: 0, cardsRequest: 0, lastReadingAt: null, bind: (typeof location !== 'undefined' && location.host) || '127.0.0.1:4747', pendingFloor: null, pendingCards: null, cards: new Map(), blockers: new Map(), doneOpen: false, ringId: null, scheduler: {}, trunkOff: false }
55
55
 
56
56
  // What the entry row reads on every render: the terminals (for the repo it
package/src/bundle.mjs CHANGED
@@ -3,9 +3,9 @@
3
3
  // seam (src/handoff.mjs: chb(), resolveChb) so the CLI is still the only
4
4
  // writer of bundle files. One bundle per session (`save --update <slug>`).
5
5
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
6
- import { spawnSync } from 'node:child_process'
7
6
  import { join } from 'node:path'
8
- import { chb, ensureExcluded } from './handoff.mjs'
7
+ import { git as gitRun } from './git.mjs'
8
+ import { chb, chbAsync, ensureExcluded } from './handoff.mjs'
9
9
  import { scrub } from './redact.mjs'
10
10
  import { updateSession, workRoot } from './sessions.mjs'
11
11
  import { perSessionFile, writeHandoffPointer } from './resume.mjs'
@@ -14,11 +14,7 @@ import { readSynthesis, formatSynthesisSection, synthesisDirective, SYNTHESIS_PO
14
14
  const LEG_DIRS = /^(\.leg|\.baton|\.context-handoffs|\.dashclaw-local)[\\/]/
15
15
  const bullets = (items) => items.filter(Boolean).map((x) => `- ${String(x).replace(/\r?\n/g, ' ').trim()}`)
16
16
 
17
- function git(cwd, args) {
18
- const r = spawnSync('git', args, { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
19
- // trimEnd only: a porcelain line starts with a space (" M README.md")
20
- return r.status === 0 ? r.stdout.trimEnd() : ''
21
- }
17
+ const git = (cwd, args) => gitRun(cwd, args, { ok: true })
22
18
 
23
19
  export function slugFor(session) { return `leg-${session.session_id}`.toLowerCase().replace(/[^a-z0-9-]+/g, '-').slice(0, 80) }
24
20
 
@@ -73,8 +69,10 @@ export function sessionNotes(session, { messages = [], why = 'handoff' } = {}) {
73
69
  return scrub(lines.join('\n'))
74
70
  }
75
71
 
76
- // Save (or refresh) the session's bundle. Returns { bundle_id, path } or throws.
77
- export function saveSessionBundle(session, { messages = [], why = 'checkpoint' } = {}) {
72
+ // Everything a save does before the CLI runs. The notes file is written here,
73
+ // synchronously, so the context is on disk even when chb is missing and the
74
+ // save throws.
75
+ function prepareSave(session, { messages, why }) {
78
76
  // a session in its own worktree keeps its bundle and RESUME.md there, where the next agent starts
79
77
  const cwd = workRoot(session)
80
78
  const legDir = join(cwd, '.leg')
@@ -85,8 +83,11 @@ export function saveSessionBundle(session, { messages = [], why = 'checkpoint' }
85
83
  const slug = slugFor(session)
86
84
  const title = `leg ${session.agent} session ${session.session_id}`
87
85
  const base = ['save', '--repo-local', '--title', title, '--slug', slug, '--notes', notesPath, '--tag', 'leg', '--tag', session.agent]
88
- let r = session.bundle?.id ? chb([...base, '--update', slug], { cwd }) : { status: 1 }
89
- if (r.status !== 0) r = chb(base, { cwd })
86
+ return { cwd, notesPath, slug, base }
87
+ }
88
+
89
+ // Everything a save does with the CLI's answer.
90
+ function finishSave(session, r, { cwd, notesPath, why }) {
90
91
  if (r.status !== 0) throw new Error(`context-handoff-bundle save failed (exit ${r.status}): ${scrub(r.stderr || r.stdout).slice(0, 400)}`)
91
92
  let out
92
93
  try { out = JSON.parse(r.stdout) } catch { throw new Error(`context-handoff-bundle save printed no JSON: ${scrub(r.stdout).slice(0, 200)}`) }
@@ -105,6 +106,27 @@ export function saveSessionBundle(session, { messages = [], why = 'checkpoint' }
105
106
  return bundle
106
107
  }
107
108
 
109
+ // Save (or refresh) the session's bundle. Returns { bundle_id, path } or throws.
110
+ export function saveSessionBundle(session, { messages = [], why = 'checkpoint' } = {}) {
111
+ const { cwd, notesPath, slug, base } = prepareSave(session, { messages, why })
112
+ let r = session.bundle?.id ? chb([...base, '--update', slug], { cwd }) : { status: 1 }
113
+ if (r.status !== 0) r = chb(base, { cwd })
114
+ return finishSave(session, r, { cwd, notesPath, why })
115
+ }
116
+
117
+ // The same save with the CLI off the caller's event loop. Used by the periodic
118
+ // checkpoint in src/attach.mjs runLeg and nowhere else: a hand-off, a limit and
119
+ // a warning save must be finished before the next leg starts, so those stay
120
+ // synchronous. The checkpoint had no such reason to block, and blocking it froze
121
+ // limit detection and every board control for the length of a python
122
+ // subprocess with a 120 s timeout (profile 2026-09-18, §3).
123
+ export async function saveSessionBundleAsync(session, { messages = [], why = 'checkpoint' } = {}) {
124
+ const { cwd, notesPath, slug, base } = prepareSave(session, { messages, why })
125
+ let r = session.bundle?.id ? await chbAsync([...base, '--update', slug], { cwd }) : { status: 1 }
126
+ if (r.status !== 0) r = await chbAsync(base, { cwd })
127
+ return finishSave(session, r, { cwd, notesPath, why })
128
+ }
129
+
108
130
  // The resume text for the next agent: chb load into .baton/RESUME.md, plus a
109
131
  // short pointer prompt (argv stays small; the bundle carries the context).
110
132
  export function resumePrompt(session, bundle, next) {