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