@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.
- package/CHANGELOG.md +560 -0
- package/LICENSE +58 -0
- package/NOTICE +4 -0
- package/README.md +558 -0
- package/bin/fake-agent.mjs +143 -0
- package/bin/leg.mjs +449 -0
- package/docs/DECISIONS.md +119 -0
- package/docs/DEMO.md +214 -0
- package/docs/DEVIATIONS.md +182 -0
- package/docs/ERRORS.md +326 -0
- package/docs/README.md +40 -0
- package/docs/REUSE.md +289 -0
- package/docs/ROADMAP-v2.md +55 -0
- package/docs/VOCABULARY.md +243 -0
- package/docs/adapters.md +300 -0
- package/docs/board-guide.md +579 -0
- package/docs/cli-contracts.md +401 -0
- package/docs/concepts.md +426 -0
- package/docs/configuration.md +220 -0
- package/docs/faq.md +206 -0
- package/docs/getting-started.md +251 -0
- package/docs/real-run.md +80 -0
- package/docs/review-2026-09-14.md +72 -0
- package/docs/screenshots/board-400px.png +0 -0
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/board-done.png +0 -0
- package/docs/screenshots/board-drawer.png +0 -0
- package/docs/screenshots/board-empty.png +0 -0
- package/docs/screenshots/board-handoff.png +0 -0
- package/docs/screenshots/board-running.png +0 -0
- package/docs/screenshots/demo-1-claude-running.png +0 -0
- package/docs/screenshots/demo-2-limit-hit.png +0 -0
- package/docs/screenshots/demo-3-handoff-bundle.png +0 -0
- package/docs/screenshots/demo-4-codex-running.png +0 -0
- package/docs/screenshots/demo-5-done.png +0 -0
- package/docs/screenshots/floor-final-1280.png +0 -0
- package/docs/screenshots/floor-final-400.png +0 -0
- package/docs/screenshots/floor-landing.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/share-guest-1280.png +0 -0
- package/docs/screenshots/share-owner-1280.png +0 -0
- package/docs/screenshots/terminals-1280.png +0 -0
- package/docs/screenshots/thumb-done.jpg +0 -0
- package/docs/screenshots/thumb-handoff-details.jpg +0 -0
- package/docs/screenshots/thumb-limit-hit.jpg +0 -0
- package/docs/screenshots/youtube-thumbnail.jpg +0 -0
- package/fixtures/limits/agy/agy-resource-exhausted.json +11 -0
- package/fixtures/limits/claude/claude-budget-limit.json +11 -0
- package/fixtures/limits/claude/claude-max-turns.json +11 -0
- package/fixtures/limits/claude/claude-model-limit.json +11 -0
- package/fixtures/limits/claude/claude-session-limit.json +11 -0
- package/fixtures/limits/claude/claude-weekly-limit.json +11 -0
- package/fixtures/limits/codex/codex-quota-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-rate-limit-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-skills-notice.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit-reached.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit.json +11 -0
- package/fixtures/limits/generic/generic-429.json +11 -0
- package/fixtures/limits/generic/generic-overloaded.json +11 -0
- package/fixtures/limits/generic/generic-quota.json +11 -0
- package/fixtures/limits/generic/generic-rate-limit.json +11 -0
- package/fixtures/limits/generic/generic-resource-exhausted.json +11 -0
- package/fixtures/limits/generic/generic-usage-limit.json +11 -0
- package/fixtures/limits/grok/grok-not-logged-in.json +11 -0
- package/fixtures/limits/negative/auth-source-set.json +11 -0
- package/fixtures/limits/negative/compile-error.json +11 -0
- package/fixtures/limits/negative/empty-stdout-exit-0.json +11 -0
- package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +1 -0
- package/fixtures/live/agy/cmd.txt +1 -0
- package/fixtures/live/agy/err.log +0 -0
- package/fixtures/live/agy/out.log +1 -0
- package/fixtures/live/agy/parsed.json +5 -0
- package/fixtures/live/agy/run.json +18 -0
- package/fixtures/live/agy/supervisor.log +2 -0
- package/fixtures/live/claude/cmd.txt +1 -0
- package/fixtures/live/claude/err.log +0 -0
- package/fixtures/live/claude/limit-rate_limit.json +21 -0
- package/fixtures/live/claude/out.log +1 -0
- package/fixtures/live/claude/parsed.json +11 -0
- package/fixtures/live/claude/run.json +18 -0
- package/fixtures/live/claude/supervisor.log +2 -0
- package/fixtures/live/codex/cmd.txt +1 -0
- package/fixtures/live/codex/err.log +1 -0
- package/fixtures/live/codex/last.md +2 -0
- package/fixtures/live/codex/limit-usage_limit_exceeded.json +25 -0
- package/fixtures/live/codex/out.log +8 -0
- package/fixtures/live/codex/parsed.json +15 -0
- package/fixtures/live/codex/run.json +18 -0
- package/fixtures/live/codex/supervisor.log +2 -0
- package/fixtures/live/grok/cmd.txt +1 -0
- package/fixtures/live/grok/err.log +32 -0
- package/fixtures/live/grok/out.log +7 -0
- package/fixtures/live/grok/parsed.json +5 -0
- package/fixtures/live/grok/run.json +18 -0
- package/fixtures/live/grok/supervisor.log +2 -0
- package/fixtures/verified.json +34 -0
- package/package.json +68 -0
- package/scripts/board-shots.mjs +69 -0
- package/scripts/build-docs-site.mjs +308 -0
- package/scripts/check-claims.mjs +150 -0
- package/scripts/clean-clone-check.sh +42 -0
- package/scripts/license-sign.mjs +23 -0
- package/scripts/limits-table.mjs +38 -0
- package/scripts/live-limits.mjs +36 -0
- package/scripts/privacy-check.mjs +63 -0
- package/scripts/probe.mjs +92 -0
- package/scripts/seed-fake-cards.mjs +34 -0
- package/scripts/seed-floor-board.mjs +69 -0
- package/scripts/seed-wes-board.mjs +91 -0
- package/scripts/stripe-setup.mjs +99 -0
- package/scripts/vercel-env.mjs +40 -0
- package/src/accounts.mjs +119 -0
- package/src/adapters/agy.mjs +56 -0
- package/src/adapters/claude.mjs +54 -0
- package/src/adapters/codex.mjs +73 -0
- package/src/adapters/common.mjs +32 -0
- package/src/adapters/fake.mjs +48 -0
- package/src/adapters/grok.mjs +51 -0
- package/src/adapters/index.mjs +27 -0
- package/src/adapters/resolve.mjs +40 -0
- package/src/attach.mjs +592 -0
- package/src/auth.mjs +85 -0
- package/src/board/board.css +543 -0
- package/src/board/board.js +1307 -0
- package/src/board/favicon.svg +1 -0
- package/src/board/floor.html +127 -0
- package/src/board/floor.js +670 -0
- package/src/board/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
- package/src/board/fonts/OFL-azeret-mono.txt +94 -0
- package/src/board/fonts/atkinson-board.woff2 +0 -0
- package/src/board/fonts/azeret-board.woff2 +0 -0
- package/src/board/index.html +251 -0
- package/src/board/sessions.js +1451 -0
- package/src/bundle.mjs +103 -0
- package/src/cards.mjs +114 -0
- package/src/chain.mjs +264 -0
- package/src/commands.mjs +79 -0
- package/src/contract.mjs +73 -0
- package/src/env.mjs +19 -0
- package/src/fsx.mjs +72 -0
- package/src/git-snapshot.mjs +152 -0
- package/src/handoff.mjs +171 -0
- package/src/hook.mjs +49 -0
- package/src/land.mjs +106 -0
- package/src/launcher.mjs +272 -0
- package/src/leases.mjs +86 -0
- package/src/ledger.mjs +369 -0
- package/src/license.mjs +168 -0
- package/src/limits.mjs +97 -0
- package/src/live-capture.mjs +69 -0
- package/src/mergequeue.mjs +225 -0
- package/src/orchestrator.mjs +365 -0
- package/src/pipeline.mjs +100 -0
- package/src/preferences.mjs +47 -0
- package/src/presets.mjs +23 -0
- package/src/ratelimit.mjs +49 -0
- package/src/redact.mjs +47 -0
- package/src/resume.mjs +354 -0
- package/src/runner.mjs +438 -0
- package/src/scheduler.mjs +118 -0
- package/src/server.mjs +845 -0
- package/src/session-detail.mjs +129 -0
- package/src/sessions.mjs +202 -0
- package/src/share.mjs +163 -0
- package/src/stations/agent.mjs +42 -0
- package/src/stations/build.mjs +9 -0
- package/src/stations/human.mjs +5 -0
- package/src/stations/land.mjs +6 -0
- package/src/stations/plan.mjs +8 -0
- package/src/stations/pr.mjs +31 -0
- package/src/stations/review.mjs +8 -0
- package/src/stations/test.mjs +27 -0
- package/src/store.mjs +90 -0
- package/src/sync/dashclaw.mjs +70 -0
- package/src/sync/index.mjs +54 -0
- package/src/sync/workboard.mjs +54 -0
- package/src/taps/agy.mjs +64 -0
- package/src/taps/claude-usage.mjs +67 -0
- package/src/taps/claude.mjs +170 -0
- package/src/taps/codex.mjs +286 -0
- package/src/trust.mjs +323 -0
- package/src/usage.mjs +179 -0
- package/src/wait.mjs +30 -0
- 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
|