@ucsandman/legcli 0.14.0 → 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/CHANGELOG.md +99 -0
- package/README.md +51 -13
- package/bin/leg.mjs +104 -62
- package/docs/DECISIONS.md +8 -0
- package/docs/DEVIATIONS.md +33 -0
- package/docs/ERRORS.md +1 -1
- package/docs/ROADMAP-v2.md +12 -1
- package/docs/concepts.md +52 -14
- package/docs/configuration.md +5 -3
- package/docs/review-2026-09-18.md +172 -0
- package/fixtures/verified.json +1 -1
- package/package.json +1 -1
- package/src/accounts.mjs +67 -7
- package/src/attach.mjs +184 -48
- package/src/board/board.js +1 -1
- package/src/board/floor.js +1 -1
- package/src/bundle.mjs +33 -11
- package/src/digest.mjs +197 -0
- package/src/git.mjs +97 -0
- package/src/handoff.mjs +20 -2
- package/src/history/common.mjs +3 -1
- package/src/history/providers/claude.mjs +5 -1
- package/src/history/worktrees.mjs +105 -25
- package/src/launcher.mjs +1 -1
- package/src/limits.mjs +19 -1
- package/src/scheduler-status.mjs +22 -0
- package/src/scheduler.mjs +3 -14
- package/src/server.mjs +171 -38
- package/src/session-detail.mjs +25 -2
- package/src/sessions.mjs +18 -3
- package/src/synthesis.mjs +23 -4
- package/src/usage.mjs +27 -1
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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:
|
|
163
|
-
head:
|
|
164
|
-
dirty:
|
|
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
|
-
|
|
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
|
|
554
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1026
|
-
//
|
|
1027
|
-
// starts the next leg inside the same
|
|
1028
|
-
// written into a prompt and nothing is
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
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
|
-
|
|
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 {
|
|
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
|
|
package/src/board/board.js
CHANGED
|
@@ -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.
|
|
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
|
package/src/board/floor.js
CHANGED
|
@@ -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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
77
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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) {
|