@ucsandman/legcli 0.10.0 → 0.12.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 +212 -0
- package/README.md +158 -67
- package/bin/leg.mjs +168 -18
- package/docs/DECISIONS.md +10 -0
- package/docs/DEMO.md +20 -14
- package/docs/DEVIATIONS.md +1 -0
- package/docs/ERRORS.md +94 -0
- package/docs/ROADMAP-v2.md +69 -11
- package/docs/VOCABULARY.md +27 -0
- package/docs/adapters.md +93 -11
- package/docs/board-guide.md +401 -66
- package/docs/cli-contracts.md +235 -22
- package/docs/concepts.md +167 -19
- package/docs/configuration.md +113 -5
- 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/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/limits/grok/grok-balance-exhausted.json +11 -0
- package/fixtures/live/claude/resume-model-probe.json +20 -0
- package/fixtures/live/claude/usage-oauth.json +87 -0
- package/fixtures/live/grok/cmd.txt +1 -1
- package/fixtures/live/grok/parsed.json +6 -3
- package/fixtures/live/grok/run.json +22 -10
- package/fixtures/verified.json +8 -1
- package/package.json +3 -2
- package/scripts/build-docs-site.mjs +4 -4
- package/scripts/probe.mjs +2 -1
- package/scripts/seed-fake-cards.mjs +59 -6
- package/scripts/seed-wes-board.mjs +81 -12
- package/src/accounts.mjs +6 -1
- package/src/adapters/cli.mjs +130 -0
- package/src/adapters/custom.mjs +271 -0
- package/src/adapters/grok.mjs +51 -10
- package/src/adapters/index.mjs +34 -7
- package/src/attach.mjs +350 -42
- package/src/audit.mjs +118 -0
- package/src/board/audit.js +123 -0
- package/src/board/board.css +134 -9
- package/src/board/board.js +482 -106
- package/src/board/index.html +89 -7
- package/src/board/sessions.js +1371 -113
- 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/orchestrator.mjs +13 -4
- package/src/preferences.mjs +214 -5
- package/src/scheduler.mjs +24 -1
- package/src/server.mjs +615 -50
- package/src/sessions.mjs +17 -1
- package/src/share.mjs +66 -6
- package/src/taps/claude-usage.mjs +91 -2
- package/src/taps/claude.mjs +144 -5
- package/src/taps/codex.mjs +23 -3
- package/src/taps/grok.mjs +4 -0
- package/src/usage.mjs +424 -13
package/src/attach.mjs
CHANGED
|
@@ -14,16 +14,19 @@ 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, 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'
|
|
29
32
|
import { scanLog, promptsSince, logSize } from './taps/agy.mjs'
|
|
@@ -35,7 +38,7 @@ 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
|
|
|
@@ -58,7 +61,7 @@ const say = (line) => process.stderr.write(`[leg] ${line}\n`)
|
|
|
58
61
|
async function refreshCodexUsage(account, codexHome, { timeoutMs = 8000, signal = null } = {}) {
|
|
59
62
|
const r = await readCodexUsage({ codexHome, timeoutMs, signal })
|
|
60
63
|
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 })
|
|
64
|
+
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
65
|
return { ...r, usage: u }
|
|
63
66
|
}
|
|
64
67
|
|
|
@@ -156,6 +159,20 @@ export function gitInfo(cwd) {
|
|
|
156
159
|
}
|
|
157
160
|
}
|
|
158
161
|
|
|
162
|
+
// How many commits this checkout is ahead of where the work started: the
|
|
163
|
+
// upstream branch when the checkout tracks one, else the commit HEAD was at
|
|
164
|
+
// when the session began. null when this is not a repo, when there is neither
|
|
165
|
+
// an upstream nor a recorded start, or when git cannot answer. A count that
|
|
166
|
+
// could not be taken is never printed as a zero (redesign A.4 row 8).
|
|
167
|
+
export function aheadCount(cwd, fallbackBase = null) {
|
|
168
|
+
const upstream = git(cwd, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])
|
|
169
|
+
const base = (upstream && upstream.trim()) || fallbackBase
|
|
170
|
+
if (!base) return null
|
|
171
|
+
const n = git(cwd, ['rev-list', '--count', `${base}..HEAD`])
|
|
172
|
+
const parsed = parseInt(String(n ?? '').trim(), 10)
|
|
173
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
174
|
+
}
|
|
175
|
+
|
|
159
176
|
// ---- collisions ----
|
|
160
177
|
// Two agents in one working tree write over each other's files. When another
|
|
161
178
|
// live session already works in this checkout, this one gets its own:
|
|
@@ -226,8 +243,126 @@ function restoreTerminal() {
|
|
|
226
243
|
try { process.stdout.write(TERMINAL_RESET) } catch {}
|
|
227
244
|
}
|
|
228
245
|
|
|
246
|
+
// ---- model and terminal title ----
|
|
247
|
+
// The model this leg resolved to, from the argv the human actually passed:
|
|
248
|
+
// `--model x`, `-m x`, or the `--model=x` form. No default is invented — a
|
|
249
|
+
// model token on a row that nobody chose is a wrong number in disguise, so the
|
|
250
|
+
// answer for a bare `leg claude` is null until the transcript says otherwise.
|
|
251
|
+
export function modelFromArgs(agent, args = []) {
|
|
252
|
+
const flag = modelFlagFor(agent)
|
|
253
|
+
const names = flag === '-m' ? ['-m', '--model'] : [flag, '-m'].filter(Boolean)
|
|
254
|
+
for (let i = 0; i < args.length; i++) {
|
|
255
|
+
const a = String(args[i] ?? '')
|
|
256
|
+
for (const n of names) {
|
|
257
|
+
if (a === n && args[i + 1] && !String(args[i + 1]).startsWith('-')) return modelAlias(agent, args[i + 1])
|
|
258
|
+
if (a.startsWith(n + '=')) return modelAlias(agent, a.slice(n.length + 1))
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return null
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export const shortId = (sessionId) => String(sessionId ?? '').split('-').pop()
|
|
265
|
+
|
|
266
|
+
// ---- taking over a background card (redesign C.4) ----
|
|
267
|
+
// Leg's own flags never reach the agent's argv, so they are lifted out of the
|
|
268
|
+
// pass-through list before anything else looks at it. Both spellings, because
|
|
269
|
+
// a human copying `--resume-card=<id>` out of a shell history is not wrong.
|
|
270
|
+
export function takeFlagValue(args, flag) {
|
|
271
|
+
const out = []
|
|
272
|
+
let value = null
|
|
273
|
+
for (let i = 0; i < args.length; i++) {
|
|
274
|
+
const a = String(args[i] ?? '')
|
|
275
|
+
if (a === flag) {
|
|
276
|
+
const next = args[i + 1]
|
|
277
|
+
if (next !== undefined && !String(next).startsWith('-')) { value = String(next); i++ }
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
if (a.startsWith(flag + '=')) { value = a.slice(flag.length + 1); continue }
|
|
281
|
+
out.push(args[i])
|
|
282
|
+
}
|
|
283
|
+
return { args: out, value }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// A card id in full, or the short tail the board prints. Ambiguity is an error
|
|
287
|
+
// rather than a pick: opening a terminal in the wrong worktree is the one
|
|
288
|
+
// outcome this whole feature exists to avoid.
|
|
289
|
+
export function resolveCardId(want, cards = null) {
|
|
290
|
+
const wanted = String(want ?? '').trim()
|
|
291
|
+
if (!wanted) return { id: null, matches: [] }
|
|
292
|
+
if (readCard(wanted)) return { id: wanted, matches: [wanted] }
|
|
293
|
+
const ids = (cards ?? listCards()).map((c) => c.card_id)
|
|
294
|
+
const matches = ids.filter((id) => id === wanted || id.endsWith(`-${wanted}`) || id.includes(wanted))
|
|
295
|
+
return { id: matches.length === 1 ? matches[0] : null, matches }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Where that card's work is: the worktree it recorded, else the one its id
|
|
299
|
+
// names, else the repository itself. Never a path that is not there.
|
|
300
|
+
export function cardWorkRoot(card) {
|
|
301
|
+
if (card?.worktree && existsSync(card.worktree)) return card.worktree
|
|
302
|
+
if (card?.repo) {
|
|
303
|
+
try { const p = worktreePath(card.repo, card.card_id); if (existsSync(p)) return p } catch { /* not a repo any more */ }
|
|
304
|
+
}
|
|
305
|
+
// never the main checkout: a card's work belongs in its own worktree, and a
|
|
306
|
+
// terminal opened in `card.repo` would edit the human's checkout under the
|
|
307
|
+
// card's name. The take-over route cuts a worktree before it hands out the
|
|
308
|
+
// command, so this is only reached for a card made by an older Leg.
|
|
309
|
+
return null
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// The first prompt of a terminal that took a card over: the card's own bundle,
|
|
313
|
+
// loaded through the same CLI every hand-off uses, with the card's task above
|
|
314
|
+
// it. A bundle that will not load is said out loud and the terminal still opens.
|
|
315
|
+
export function takeOverPrompt(card, cwd) {
|
|
316
|
+
let loaded = ''
|
|
317
|
+
try { loaded = loadResume(cwd, card.last_bundle ?? 'latest') } catch { loaded = '' }
|
|
318
|
+
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`
|
|
319
|
+
return loaded
|
|
320
|
+
? `${head}${loaded}`
|
|
321
|
+
: `${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.`
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// `leg#7f3a leg/main`: which terminal this window is, and where it is working.
|
|
325
|
+
// Degrades to the id alone rather than printing a place that is not a repo.
|
|
326
|
+
export function terminalTitle(session) {
|
|
327
|
+
const where = session?.repo_name ? `${session.repo_name}${session.branch ? '/' + session.branch : ''}` : null
|
|
328
|
+
return `leg#${shortId(session?.session_id)}${where ? ' ' + where : ''}`
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// OSC 2 (set window title) for the CLIs with no title flag of their own.
|
|
332
|
+
// ASSUMED: a VT terminal keeps the title once the child starts drawing. codex,
|
|
333
|
+
// agy or grok may overwrite it with their own; nothing observed either way yet,
|
|
334
|
+
// and the probe is to start each one and read the tab (redesign E, row 4).
|
|
335
|
+
export function osc2(title) { return `\x1b]2;${printable(title)}\x07` }
|
|
336
|
+
|
|
337
|
+
// Claude Code can wait at the usage limit itself when the human's own settings
|
|
338
|
+
// re-enable autoContinueAtUsageLimit. Leg sets it false, but it does not own
|
|
339
|
+
// that file, so when the `quota_auto_resume_fired` Notification arrives the
|
|
340
|
+
// automatic hand-off stands down for that terminal: two waiters on one terminal
|
|
341
|
+
// is the failure to avoid (redesign B.5). A human pressing Hand off > is not
|
|
342
|
+
// affected — standing down is about what Leg does unasked.
|
|
343
|
+
export function handoffStoodDown(session) {
|
|
344
|
+
return session?.waiting?.type === 'quota_auto_resume' ? (session.waiting.message ?? 'Claude Code is waiting at the limit itself') : null
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// The model flag for a rung, or nothing. Nothing when the rung names no model,
|
|
348
|
+
// when Leg does not know how that CLI spells one, or when the human already
|
|
349
|
+
// passed a model themselves: an argv the human wrote is never overwritten.
|
|
350
|
+
export function modelFlags(agent, args = [], model = null) {
|
|
351
|
+
if (!model) return []
|
|
352
|
+
const flag = modelFlagFor(agent)
|
|
353
|
+
if (!flag) return []
|
|
354
|
+
if (modelFromArgs(agent, args)) return []
|
|
355
|
+
return [flag, model]
|
|
356
|
+
}
|
|
357
|
+
|
|
229
358
|
// ---- spawn spec per agent ----
|
|
230
|
-
|
|
359
|
+
// `model` is the rung's model (B.3); `resume` is the agent's own session id for
|
|
360
|
+
// the one case where a hand-off keeps the conversation instead of the bundle:
|
|
361
|
+
// a claude downshift (`--resume <id> --model <alias>`, settled by
|
|
362
|
+
// fixtures/live/claude/resume-model-probe.json). codex has a `resume`
|
|
363
|
+
// subcommand too, but composing it with `-m` is ASSUMED, not observed (codex
|
|
364
|
+
// is walled on this machine until Saturday), so codex ships bundle-primed.
|
|
365
|
+
export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd, autoApprove = resolveAutoApprove(), model = null, resume = null }) {
|
|
231
366
|
const adapter = await loadAdapter(agent)
|
|
232
367
|
const { bin, viaNode, entry } = adapter.resolve()
|
|
233
368
|
const argv = []
|
|
@@ -239,23 +374,32 @@ export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd,
|
|
|
239
374
|
if (agent === 'claude') {
|
|
240
375
|
const settings = writeSettings(sessionId, { statusLine: userStatusLine(process.env.CLAUDE_CONFIG_DIR || (account !== 'default' ? envFor('claude', account).CLAUDE_CONFIG_DIR : undefined)) })
|
|
241
376
|
const autoFlags = autoApprove && !args.includes('--dangerously-skip-permissions') ? ['--dangerously-skip-permissions'] : [] // auto-approve: not forbidden for interactive sessions
|
|
242
|
-
|
|
377
|
+
// `-n, --name <name>`: "Set a display name for this session (shown in the
|
|
378
|
+
// prompt box, /resume picker, and terminal title)" — fixtures/help/claude.txt
|
|
379
|
+
// line 132, in the general Options section, not one of the flags marked
|
|
380
|
+
// "only works with --print", and two of its three surfaces (prompt box,
|
|
381
|
+
// /resume picker) exist only in interactive mode. A name the human passed
|
|
382
|
+
// themselves is never overwritten.
|
|
383
|
+
const named = args.some((a) => a === '-n' || a === '--name' || String(a).startsWith('--name='))
|
|
384
|
+
const nameFlags = named ? [] : ['-n', terminalTitle(readSession(sessionId) ?? { session_id: sessionId })]
|
|
385
|
+
const resumeFlags = resume ? ['--resume', String(resume)] : []
|
|
386
|
+
argv.push(...resumeFlags, ...args, ...modelFlags(agent, args, model), ...autoFlags, ...nameFlags, '--settings', settings)
|
|
243
387
|
if (prompt) argv.push(prompt)
|
|
244
388
|
} else if (agent === 'codex') {
|
|
245
389
|
const hasApproval = args.includes('--ask-for-approval') || args.includes('-a') || args.some((x) => typeof x === 'string' && x.startsWith('--ask-for-approval='))
|
|
246
390
|
const autoFlags = autoApprove && !hasApproval ? ['--ask-for-approval', 'never'] : []
|
|
247
|
-
argv.push(...args, ...autoFlags)
|
|
391
|
+
argv.push(...args, ...modelFlags(agent, args, model), ...autoFlags)
|
|
248
392
|
if (prompt) argv.push(prompt)
|
|
249
393
|
} else if (agent === 'agy') {
|
|
250
394
|
const log = join(sessionDir(sessionId), 'agy.log')
|
|
251
395
|
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)
|
|
396
|
+
argv.push(...args, ...modelFlags(agent, args, model), ...autoFlags, '--log-file', log)
|
|
253
397
|
if (prompt) argv.push('-i', prompt)
|
|
254
398
|
} else if (agent === 'grok') {
|
|
255
399
|
const log = join(sessionDir(sessionId), 'grok.log')
|
|
256
400
|
const hasApprove = args.includes('--always-approve') || args.includes('--yolo') || args.includes('--approval-mode=yolo') // auto-approve check: not forbidden for interactive sessions
|
|
257
401
|
const autoFlags = autoApprove && !hasApprove ? ['--always-approve'] : [] // auto-approve: not forbidden for interactive sessions
|
|
258
|
-
argv.push(...args, ...autoFlags, '--debug-file', log)
|
|
402
|
+
argv.push(...args, ...modelFlags(agent, args, model), ...autoFlags, '--debug-file', log)
|
|
259
403
|
if (prompt) argv.push(prompt)
|
|
260
404
|
}
|
|
261
405
|
const env = { ...sanitizeEnv(process.env, { interactive: true }), ...envFor(agent, account), LEG_SESSION: sessionId, BATON_SESSION: sessionId }
|
|
@@ -263,8 +407,11 @@ export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd,
|
|
|
263
407
|
}
|
|
264
408
|
|
|
265
409
|
// ---- one agent leg ----
|
|
266
|
-
// Returns { reason: 'exit'|'limit'|'handoff', code }
|
|
267
|
-
|
|
410
|
+
// Returns { reason: 'exit'|'limit'|'handoff', code, target } — `target` is the
|
|
411
|
+
// destination a human picked on the board ("Hand off now to codex"), carried
|
|
412
|
+
// out to the loop below, which is what chooses the next leg.
|
|
413
|
+
|
|
414
|
+
async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApprove = resolveAutoApprove(), model = null, resume = null }) {
|
|
268
415
|
const sid = session.session_id
|
|
269
416
|
refreshAccount(agent, account)
|
|
270
417
|
// A handoff happens when the limit hits, which is usually when nobody is
|
|
@@ -274,8 +421,8 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
|
|
|
274
421
|
const trust = ensureTrust(agent, session.cwd, { cwd: session.cwd })
|
|
275
422
|
const trusted = trustLine(trust)
|
|
276
423
|
if (trusted) { say(trusted); appendEvent(sid, { type: 'trust', summary: trusted }) }
|
|
277
|
-
const spec = await spawnSpec(agent, { account, args, sessionId: sid, prompt, cwd: session.cwd, autoApprove })
|
|
278
|
-
appendEvent(sid, { type: 'leg', summary: `${agent} (${account}) starting${prompt ? ' from the handoff bundle' : ''}` })
|
|
424
|
+
const spec = await spawnSpec(agent, { account, args, sessionId: sid, prompt, cwd: session.cwd, autoApprove, model, resume })
|
|
425
|
+
appendEvent(sid, { type: 'leg', summary: `${agent}${model ? '/' + model : ''} (${account}) starting${resume ? ' with the conversation it already had' : prompt ? ' from the handoff bundle' : ''}` })
|
|
279
426
|
const startedMs = Date.now()
|
|
280
427
|
const turnsAtLegStart = session.turns ?? 0
|
|
281
428
|
// agy appends to one log for the whole session: a second agy leg reads from
|
|
@@ -284,6 +431,12 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
|
|
|
284
431
|
const agyTail = agyLog ? createTail(agyLog, { from: logSize(agyLog) }) : null
|
|
285
432
|
const grokLog = agent === 'grok' ? join(sessionDir(sid), 'grok.log') : null
|
|
286
433
|
const grokTail = grokLog ? createTail(grokLog, { from: logSize(grokLog) }) : null
|
|
434
|
+
// claude takes `-n` (spawnSpec). The other three have no title flag, so Leg
|
|
435
|
+
// writes the title itself, once, before the child owns the terminal. Only on
|
|
436
|
+
// a TTY: into a pipe or a log this would be four stray control bytes.
|
|
437
|
+
if (agent !== 'claude' && process.stdout.isTTY) {
|
|
438
|
+
try { process.stdout.write(osc2(terminalTitle(session))) } catch {}
|
|
439
|
+
}
|
|
287
440
|
let child
|
|
288
441
|
try {
|
|
289
442
|
child = spawn(spec.bin, spec.args, { cwd: spec.cwd, env: spec.env, stdio: 'inherit', windowsHide: false })
|
|
@@ -293,7 +446,9 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
|
|
|
293
446
|
}
|
|
294
447
|
// every leg starts on its own card: the agent that just left takes its
|
|
295
448
|
// percentages, its warning and its usage source with it
|
|
296
|
-
|
|
449
|
+
// the model goes with it: the next leg's argv is the only thing Leg knows
|
|
450
|
+
// about the model until that agent's own transcript says otherwise
|
|
451
|
+
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 })
|
|
297
452
|
|
|
298
453
|
// taps
|
|
299
454
|
let rollout = null; let tail = null
|
|
@@ -304,7 +459,7 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
|
|
|
304
459
|
rollout = { path: session.transcript_path, meta: { id: session.agent_session_id } }
|
|
305
460
|
tail = createTail(rollout.path, { from: logSize(rollout.path) })
|
|
306
461
|
}
|
|
307
|
-
let polls = 0; let warned = false
|
|
462
|
+
let polls = 0; let warned = false; let stoodDown = false
|
|
308
463
|
let stop = null
|
|
309
464
|
const done = new Promise((res) => { stop = res })
|
|
310
465
|
child.on('error', (err) => { appendEvent(sid, { type: 'error', summary: `${agent} spawn error: ${err.message}` }); stop({ reason: 'exit', code: 127 }) })
|
|
@@ -322,10 +477,20 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
|
|
|
322
477
|
if (!isCurrentLeg(s, { pid: child.pid, agent, account })) return
|
|
323
478
|
// a 404, a body that is not JSON, or a shape with no window at all: the
|
|
324
479
|
// card says usage unknown and the StopFailure hook still owns the limit
|
|
480
|
+
// which model actually answered. The transcript path arrives on the
|
|
481
|
+
// SessionStart hook payload (src/taps/claude.mjs `handleHook`, `base`),
|
|
482
|
+
// so this only reads once Claude Code has told Leg where its jsonl is.
|
|
483
|
+
// A fallback off fable shows up here and nowhere else.
|
|
484
|
+
const seen = modelFromTranscript(s.transcript_path, { agent: 'claude' })
|
|
485
|
+
if (seen && seen !== s.model) {
|
|
486
|
+
updateSession(sid, { model: seen }, { event: { type: 'status', summary: `claude is answering on ${seen}${s.model ? ` (was ${s.model})` : ''}` } })
|
|
487
|
+
}
|
|
325
488
|
const usable = r.ok && r.limits && (r.limits.five_hour || r.limits.seven_day)
|
|
326
489
|
if (usable) {
|
|
327
490
|
recordUsage('claude', account, r.limits, 'claude usage endpoint')
|
|
328
|
-
|
|
491
|
+
// the session record keeps the two windows it always had: the buckets
|
|
492
|
+
// live on the usage record, which is per login and not per terminal
|
|
493
|
+
updateSession(sid, { limits: { five_hour: r.limits.five_hour, seven_day: r.limits.seven_day }, usage_source: 'claude usage endpoint', usage_error: null })
|
|
329
494
|
} else if (!s.usage_error) {
|
|
330
495
|
const why = r.error ?? 'the usage endpoint answered with no window'
|
|
331
496
|
updateSession(sid, { usage_error: why }, { event: { type: 'status', summary: `claude usage unavailable: ${why}` } })
|
|
@@ -382,7 +547,7 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
|
|
|
382
547
|
// git: which files this session is touching, where trunk is
|
|
383
548
|
if (polls % GIT_EVERY === 1) {
|
|
384
549
|
const g = gitInfo(s.cwd)
|
|
385
|
-
if (g.repo) { patch.files_dirty = g.dirty; patch.head = g.head; patch.branch = g.branch }
|
|
550
|
+
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) }
|
|
386
551
|
}
|
|
387
552
|
// codex: find + tail the rollout
|
|
388
553
|
if (agent === 'codex') {
|
|
@@ -394,7 +559,7 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
|
|
|
394
559
|
if (tail) {
|
|
395
560
|
const r = parseLines(tail.read())
|
|
396
561
|
if (r.limits) {
|
|
397
|
-
const u = recordUsage('codex', account, r.limits, 'codex rollout token_count', { observed_at: r.limits_at })
|
|
562
|
+
const u = recordUsage('codex', account, { ...r.limits, facts: r.facts }, 'codex rollout token_count', { observed_at: r.limits_at })
|
|
398
563
|
if (u.usage_applied) { patch.limits = r.limits; patch.last_activity = new Date().toISOString() }
|
|
399
564
|
}
|
|
400
565
|
const firstUser = r.messages.find((m) => m.role === 'user')
|
|
@@ -474,12 +639,24 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
|
|
|
474
639
|
// record can carry both; End (stop entirely) is the stronger, latest intent
|
|
475
640
|
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 }
|
|
476
641
|
if (ctl?.handoff) {
|
|
477
|
-
|
|
478
|
-
|
|
642
|
+
const picked = ctl.target && typeof ctl.target === 'object' && ctl.target.agent
|
|
643
|
+
? { agent: String(ctl.target.agent), account: String(ctl.target.account ?? 'default'), ...(ctl.target.model ? { model: String(ctl.target.model) } : {}) }
|
|
644
|
+
: null
|
|
645
|
+
const toWhom = picked ? ` to ${rungLabel(picked)}` : ''
|
|
646
|
+
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}` : ''}` } })
|
|
647
|
+
clearInterval(timer); killTree(child.pid); restoreTerminal(); stop({ reason: 'handoff', code: null, target: picked }); return
|
|
479
648
|
}
|
|
480
649
|
// a stale warning patch can overwrite status:'limit' from the hook, but the
|
|
481
650
|
// limit OBJECT survives the clobber — hand off on either signal
|
|
482
651
|
if ((next.status === 'limit' || next.limit) && (process.env.LEG_NO_HANDOFF || process.env.BATON_NO_HANDOFF) !== '1') {
|
|
652
|
+
// Claude Code is waiting at the limit itself: stand down rather than
|
|
653
|
+
// kill a child that is about to resume on its own. Said once, with the
|
|
654
|
+
// reason, so the terminal that did not hand off is never a mystery.
|
|
655
|
+
const standDown = handoffStoodDown(next)
|
|
656
|
+
if (standDown) {
|
|
657
|
+
if (!stoodDown) { stoodDown = true; say(standDown); appendEvent(sid, { type: 'status', summary: `hand-off stood down: ${standDown}` }) }
|
|
658
|
+
return
|
|
659
|
+
}
|
|
483
660
|
clearInterval(timer); killTree(child.pid); restoreTerminal(); stop({ reason: 'limit', code: null })
|
|
484
661
|
}
|
|
485
662
|
} catch (err) {
|
|
@@ -529,14 +706,43 @@ function messagesFor(agent, s) {
|
|
|
529
706
|
// an order save is consumed here, or the editor sees handing_off and refuses.
|
|
530
707
|
// No eligible choice leaves the session unclaimed so all-out waiting can keep
|
|
531
708
|
// accepting order edits.
|
|
532
|
-
|
|
533
|
-
|
|
709
|
+
// `automatic` is the fact, not a guess from the shape of the call: only the
|
|
710
|
+
// usage limit (and the scheduler behind a card) hands off unasked. A human
|
|
711
|
+
// pressing Hand off now sends no destination at all when they take the default
|
|
712
|
+
// option, and inferring "automatic" from that missing target applied the
|
|
713
|
+
// reserve, the cost gate and climb-back to a hand-off the human asked for, on
|
|
714
|
+
// rungs the picker had just shown them as available (B.3).
|
|
715
|
+
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 }) {
|
|
716
|
+
let choice = { next: null, out: [], reasons: [] }
|
|
534
717
|
let claimed = false
|
|
718
|
+
const auto = automatic === null ? reason === 'limit' : Boolean(automatic)
|
|
719
|
+
// The machine's spending rules are read once, here: a rung that costs credits
|
|
720
|
+
// is skipped unless the human allowed it, the reserve applies to automatic
|
|
721
|
+
// hand-offs only, and climb-back decides whether an automatic hand-off may
|
|
722
|
+
// walk back UP the ladder (B.3, B.7).
|
|
723
|
+
const prefs = preferences ?? readPreferences()
|
|
535
724
|
const session = updateSession(sid, (current) => {
|
|
536
725
|
const accounts = readAccounts()
|
|
537
726
|
const order = normalizeHandoffOrder(current.handoff_order)
|
|
538
|
-
|
|
539
|
-
|
|
727
|
+
// the terminal's own ladder, else the long-hand form of its order: a
|
|
728
|
+
// terminal started before ladders existed behaves exactly as it did.
|
|
729
|
+
const ladder = ladderFor(current)
|
|
730
|
+
const legModel = model ?? current.model ?? null
|
|
731
|
+
choice = chooseNext({
|
|
732
|
+
agent, account, model: legModel, accounts, installed, order, ladder, nowS, exclude, prefer,
|
|
733
|
+
maySpend: prefs.may_spend, reserve: prefs.reserve, climbBack: prefs.climb_back, automatic: auto,
|
|
734
|
+
})
|
|
735
|
+
// Nothing on the ladder: keep the login the terminal is already on, but
|
|
736
|
+
// only when it can really run the next leg. A model wall leaves the account
|
|
737
|
+
// open by design, so claiming {agent, account} with no model here respawned
|
|
738
|
+
// the CLI on the model that had just walled, walled again, and burned all
|
|
739
|
+
// twelve legs. The model rides the claimed rung for the same reason, and
|
|
740
|
+
// `out` is kept so the all-out wait below still has its reset clocks.
|
|
741
|
+
const own = readUsage(agent, account)
|
|
742
|
+
const ownOpen = isAvailable(own, nowS) && !(legModel && wallActive(own.walls?.[legModel], nowS))
|
|
743
|
+
if (!choice.next && ownOpen && !exclude.some((x) => x.agent === agent && x.account === account)) {
|
|
744
|
+
choice = { next: { agent, account, ...(legModel ? { model: legModel } : {}) }, out: choice.out ?? [], reasons: choice.reasons ?? [], preferred_taken: false }
|
|
745
|
+
}
|
|
540
746
|
if (!choice.next) return {}
|
|
541
747
|
claimed = true
|
|
542
748
|
return {
|
|
@@ -548,6 +754,10 @@ export function claimHandoffChoice({ sid, agent, account, installed, bundle = nu
|
|
|
548
754
|
to: choice.next,
|
|
549
755
|
bundle_id: bundle?.id ?? null,
|
|
550
756
|
reason: reason === 'limit' ? 'usage limit' : 'requested',
|
|
757
|
+
// what was asked for, beside what was chosen: when a picked
|
|
758
|
+
// destination walled between the click and the hand-off, the card
|
|
759
|
+
// must say so rather than look like the pick was ignored
|
|
760
|
+
requested_to: prefer ?? null,
|
|
551
761
|
at: new Date().toISOString(),
|
|
552
762
|
},
|
|
553
763
|
}
|
|
@@ -555,6 +765,25 @@ export function claimHandoffChoice({ sid, agent, account, installed, bundle = nu
|
|
|
555
765
|
return { choice, claimed, session }
|
|
556
766
|
}
|
|
557
767
|
|
|
768
|
+
// A pick that could not be taken, said in the words the ladder already used.
|
|
769
|
+
// `choice.reasons` carries {agent, account, model, reason} for every rung the
|
|
770
|
+
// walk passed over, so the cost gate, the reserve and a `below:N` rule all have
|
|
771
|
+
// their own sentence sitting there; re-deriving the explanation from the
|
|
772
|
+
// account's `limited_until` printed "at its limit until unknown" for a rung
|
|
773
|
+
// that was never walled at all. The limit sentence stays as the fallback for a
|
|
774
|
+
// rung the walk never reached. Both names carry their model (rungLabel), or a
|
|
775
|
+
// downshift reads as "claude was picked but ...; handing off to claude".
|
|
776
|
+
// → { asked, got, why }
|
|
777
|
+
export function pickedAside({ prefer, next, choice, excluded = [], read = readUsage }) {
|
|
778
|
+
const want = { agent: prefer.agent, account: prefer.account ?? 'default', model: prefer.model ?? null }
|
|
779
|
+
const asked = rungLabel(want)
|
|
780
|
+
const got = rungLabel(next)
|
|
781
|
+
if (excluded.some((x) => x.agent === want.agent && x.account === want.account)) return { asked, got, why: 'the strict harness policy refused it' }
|
|
782
|
+
const hit = (choice?.reasons ?? []).find((r) => r.agent === want.agent && r.account === want.account && (want.model ? (r.model ?? null) === want.model : true))
|
|
783
|
+
if (hit?.reason) return { asked, got, why: hit.reason }
|
|
784
|
+
return { asked, got, why: `it is at its limit until ${fmtReset(read(want.agent, want.account).limited_until)}` }
|
|
785
|
+
}
|
|
786
|
+
|
|
558
787
|
// The portable harness, decided before a leg starts (src/harness/index.mjs).
|
|
559
788
|
// Off by default: then this records nothing and changes nothing. On, it
|
|
560
789
|
// carries the source agent's working environment to the agent about to run,
|
|
@@ -581,8 +810,27 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
|
|
|
581
810
|
// affected; only what Leg adds is licensed.
|
|
582
811
|
const ent = entitlement()
|
|
583
812
|
if (!allows(ent, 'run')) { say(describeLicense(ent)); return 4 }
|
|
584
|
-
//
|
|
585
|
-
|
|
813
|
+
// `--resume-card <id>`: Take over on the board paused a card and handed the
|
|
814
|
+
// human this command. The terminal opens in that card's own worktree, primed
|
|
815
|
+
// from its bundle (redesign C.4). Leg's flag, never the agent's.
|
|
816
|
+
const lifted = takeFlagValue(args, '--resume-card')
|
|
817
|
+
args = lifted.args
|
|
818
|
+
let card = null
|
|
819
|
+
if (lifted.value !== null) {
|
|
820
|
+
const hit = resolveCardId(lifted.value)
|
|
821
|
+
if (!hit.id) {
|
|
822
|
+
say(hit.matches.length
|
|
823
|
+
? `"${lifted.value}" matches ${hit.matches.length} cards (${hit.matches.slice(0, 5).join(', ')}); use the full id`
|
|
824
|
+
: `no card matches "${lifted.value}" (leg card ls lists them)`)
|
|
825
|
+
return 3
|
|
826
|
+
}
|
|
827
|
+
card = readCard(hit.id)
|
|
828
|
+
if (!cardWorkRoot(card)) { say(`card ${hit.id} has no checkout on this machine yet; run it once, or open ${card.repo} yourself`); return 3 }
|
|
829
|
+
}
|
|
830
|
+
// --no-worktree is Leg's flag, not the agent's: it never passes through.
|
|
831
|
+
// A card take-over shares the card's checkout for the same reason: cutting a
|
|
832
|
+
// second worktree on its branch is what Take over exists to avoid.
|
|
833
|
+
const shareCheckout = args.includes('--no-worktree') || Boolean(continued) || Boolean(card)
|
|
586
834
|
args = args.filter((a) => a !== '--no-worktree')
|
|
587
835
|
let autoApproveCli = null
|
|
588
836
|
if (args.includes('--no-auto-approve')) {
|
|
@@ -593,11 +841,16 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
|
|
|
593
841
|
args = args.filter((a) => a !== '--auto-approve')
|
|
594
842
|
}
|
|
595
843
|
const autoApprove = resolveAutoApprove({ cliFlag: autoApproveCli })
|
|
596
|
-
const cwd = cwdOpt ? realPath(cwdOpt) : process.cwd()
|
|
844
|
+
const cwd = card ? realPath(cardWorkRoot(card)) : (cwdOpt ? realPath(cwdOpt) : process.cwd())
|
|
597
845
|
const board = await ensureBoard({ open })
|
|
598
846
|
let accounts = readAccounts()
|
|
599
847
|
const installed = await installedAgents()
|
|
600
|
-
|
|
848
|
+
// The machine's preferences are copied into this terminal at start: the
|
|
849
|
+
// ladder it walks, and the spending rules it walks it under. Later edits
|
|
850
|
+
// reach a running terminal only through the board's per-terminal ladder.
|
|
851
|
+
const prefs = readPreferences()
|
|
852
|
+
const handoffOrder = prefs.handoff_order
|
|
853
|
+
const handoffLadder = prefs.handoff_ladder
|
|
601
854
|
let account = process.env.LEG_ACCOUNT || process.env.BATON_ACCOUNT || 'default'
|
|
602
855
|
if (!(accounts[agent] ?? ['default']).includes(account)) { say(`no ${agent} account "${account}"; using default`); account = 'default' }
|
|
603
856
|
// A persisted wall is only a cache. Ask Codex's read-only account endpoint
|
|
@@ -612,20 +865,27 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
|
|
|
612
865
|
await fetchGrokUsage({ configDir: grokHome }).catch(() => {})
|
|
613
866
|
}
|
|
614
867
|
// Start on an account that is not at its wall, if we already know one is.
|
|
868
|
+
let startModel = null
|
|
615
869
|
const nowS = Math.floor(Date.now() / 1000)
|
|
616
870
|
const u0 = readUsage(agent, account)
|
|
617
871
|
if (u0.limited_until && u0.limited_until > nowS) {
|
|
618
|
-
|
|
619
|
-
|
|
872
|
+
// automatic: the human asked for this agent, not for this destination, so
|
|
873
|
+
// the machine's own floors (the reserve, the spending gate) still apply to
|
|
874
|
+
// the rung Leg substitutes for it.
|
|
875
|
+
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 })
|
|
876
|
+
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 }
|
|
620
877
|
else say(`${agent} (${account}) is at its limit until ${fmtReset(u0.limited_until)}; starting anyway (every option is out)`)
|
|
621
878
|
}
|
|
622
879
|
const g = gitInfo(cwd)
|
|
623
880
|
const sid = newSessionId(agent)
|
|
624
|
-
const chain = candidates({ agent, account, accounts, order: handoffOrder })
|
|
881
|
+
const chain = candidates({ agent, account, accounts, order: handoffOrder, ladder: handoffLadder, model: startModel ?? modelFromArgs(agent, args) })
|
|
625
882
|
// record the session BEFORE cutting a worktree, so a crash or Ctrl-C during
|
|
626
883
|
// `git worktree add` still leaves a card (with a Remove button), never a
|
|
627
884
|
// silent orphan under .baton-worktrees with no record and no button
|
|
628
|
-
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] })
|
|
885
|
+
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 })
|
|
886
|
+
// the ladder is a copy too, so the board can edit this terminal's rungs
|
|
887
|
+
// without changing the machine default under every other terminal
|
|
888
|
+
updateSession(sid, { handoff_ladder: handoffLadder })
|
|
629
889
|
if (continued) {
|
|
630
890
|
// the agent's own id and transcript are known before the first turn, so
|
|
631
891
|
// history dedups this leg against the conversation it continues at once
|
|
@@ -647,7 +907,17 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
|
|
|
647
907
|
}
|
|
648
908
|
|
|
649
909
|
let prompt = null
|
|
910
|
+
if (card) {
|
|
911
|
+
// the card's bundle is this terminal's first prompt, and the record says
|
|
912
|
+
// where this terminal came from, so the board can draw the line back
|
|
913
|
+
updateSession(sid, { lineage: { from: card.card_id, to: null }, task: card.task ?? null },
|
|
914
|
+
{ event: { type: 'continued', summary: `taking over card ${card.card_id} (${card.status} at ${card.station}) in ${cwd}` } })
|
|
915
|
+
prompt = takeOverPrompt(card, cwd)
|
|
916
|
+
say(`taking over card ${card.card_id} in ${cwd}${card.last_bundle ? ` from bundle ${card.last_bundle}` : ''}`)
|
|
917
|
+
}
|
|
650
918
|
let legArgs = args
|
|
919
|
+
let legModel = startModel
|
|
920
|
+
let legResume = null
|
|
651
921
|
let exit = 0
|
|
652
922
|
// the first leg is the agent the human chose: its harness is prepared per
|
|
653
923
|
// policy and recorded, never refused (strict applies to hand-offs)
|
|
@@ -656,7 +926,7 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
|
|
|
656
926
|
// bounds a wait; a normal session runs one leg and exits
|
|
657
927
|
for (let leg = 0; ; leg++) {
|
|
658
928
|
const s = readSession(sid)
|
|
659
|
-
const r = await runLeg({ agent, account, args: legArgs, session: s, prompt, boardUrl: board.url, autoApprove })
|
|
929
|
+
const r = await runLeg({ agent, account, args: legArgs, session: s, prompt, boardUrl: board.url, autoApprove, model: legModel, resume: legResume })
|
|
660
930
|
if (r.reason === 'exit') { exit = r.code ?? 0; break }
|
|
661
931
|
// limit or handoff: bundle, choose next, go again in this terminal
|
|
662
932
|
const cur = readSession(sid)
|
|
@@ -669,7 +939,9 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
|
|
|
669
939
|
const notesFile = join(workRoot(cur) ?? cur.cwd, '.leg', `session-${sid}.md`)
|
|
670
940
|
// destinations the strict harness policy refused during this hand-off
|
|
671
941
|
const excluded = []
|
|
672
|
-
|
|
942
|
+
// the destination a human picked on the board, if they picked one
|
|
943
|
+
const prefer = r.target ?? null
|
|
944
|
+
let claim = claimHandoffChoice({ sid, agent, account, model: cur.model ?? null, installed, bundle, reason: r.reason, exclude: excluded, prefer, automatic: r.reason !== 'handoff' })
|
|
673
945
|
let choice = claim.choice
|
|
674
946
|
let cancelled = false
|
|
675
947
|
let blocked = false
|
|
@@ -687,10 +959,13 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
|
|
|
687
959
|
say(`every option is out. First back: ${label} at ${first ? fmtReset(first.resets_at) : 'unknown'}`)
|
|
688
960
|
for (const o of all) say(` ${o.agent}${o.account !== 'default' ? '/' + o.account : ''}: resets ${fmtReset(o.resets_at)}`)
|
|
689
961
|
say(`waiting for ${label}; Ctrl-C to quit`)
|
|
690
|
-
|
|
962
|
+
// `type: 'reset'` tells this apart from the Notification hook's
|
|
963
|
+
// `waiting` (a human being waited on). Same key, two shapes, one
|
|
964
|
+
// discriminator; see the field comment in src/sessions.mjs.
|
|
965
|
+
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'}` } })
|
|
691
966
|
const r2 = await waitInTerminal({ sid, label, resetsAt: first?.resets_at ?? null })
|
|
692
967
|
if (r2 === 'cancelled') { cancelled = true; break }
|
|
693
|
-
claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded })
|
|
968
|
+
claim = claimHandoffChoice({ sid, agent, account, model: cur.model ?? null, installed, bundle, reason: r.reason, exclude: excluded, prefer, automatic: r.reason !== 'handoff' })
|
|
694
969
|
choice = claim.choice
|
|
695
970
|
}
|
|
696
971
|
if (cancelled) {
|
|
@@ -706,7 +981,7 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
|
|
|
706
981
|
if (prepared.proceed) break
|
|
707
982
|
excluded.push(choice.next)
|
|
708
983
|
say(`${choice.next.agent} refused by the strict harness policy: ${prepared.reason ?? prepared.state}`)
|
|
709
|
-
claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded })
|
|
984
|
+
claim = claimHandoffChoice({ sid, agent, account, model: cur.model ?? null, installed, bundle, reason: r.reason, exclude: excluded, prefer, automatic: r.reason !== 'handoff' })
|
|
710
985
|
choice = claim.choice
|
|
711
986
|
if (!choice.next) blocked = true
|
|
712
987
|
}
|
|
@@ -717,6 +992,15 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
|
|
|
717
992
|
break
|
|
718
993
|
}
|
|
719
994
|
const next = choice.next
|
|
995
|
+
// A pick that could not be taken is never silent: between the click and
|
|
996
|
+
// this moment that account can wall, or the strict harness policy can
|
|
997
|
+
// refuse it, and a terminal that quietly went somewhere else is the kind
|
|
998
|
+
// of surprise this board exists to remove.
|
|
999
|
+
if (prefer && !choice.preferred_taken) {
|
|
1000
|
+
const { asked, got, why } = pickedAside({ prefer, next, choice, excluded })
|
|
1001
|
+
say(`${asked} was picked but ${why}; handing off to ${got} instead`)
|
|
1002
|
+
appendEvent(sid, { type: 'status', summary: `${asked} was picked for this hand-off but ${why}; ${got} took it instead` })
|
|
1003
|
+
}
|
|
720
1004
|
// bound the number of hand-offs in one terminal so a chain that limits
|
|
721
1005
|
// instantly can never loop forever; stopping is explicit, not a silent exit 0
|
|
722
1006
|
if (leg >= 11) {
|
|
@@ -725,8 +1009,29 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
|
|
|
725
1009
|
exit = 3
|
|
726
1010
|
break
|
|
727
1011
|
}
|
|
728
|
-
|
|
729
|
-
|
|
1012
|
+
// Every rung the ladder walked past, in the ledger, with the reason: a
|
|
1013
|
+
// terminal that skipped claude/fable because credits are off must say so
|
|
1014
|
+
// (B.3). "not installed" is left out: that one is about this machine, not
|
|
1015
|
+
// about this hand-off, and it would repeat on every leg.
|
|
1016
|
+
for (const why of choice.reasons ?? []) {
|
|
1017
|
+
if (why.reason === 'not installed on this machine') continue
|
|
1018
|
+
const line = skipLine(why)
|
|
1019
|
+
say(line)
|
|
1020
|
+
appendEvent(sid, { type: 'status', summary: line })
|
|
1021
|
+
}
|
|
1022
|
+
// The one hand-off that keeps the conversation: a claude downshift with the
|
|
1023
|
+
// agent's own session id on the record. `--resume <id> --model <alias>`
|
|
1024
|
+
// starts the next leg inside the same conversation, so the bundle is not
|
|
1025
|
+
// written into a prompt and nothing is re-explained. Every other rung takes
|
|
1026
|
+
// the bundle: an upshift back to fable (which would re-read the whole
|
|
1027
|
+
// context at fable's rate), a second account, and codex, whose `resume`
|
|
1028
|
+
// subcommand exists but has never been seen composing with `-m` here.
|
|
1029
|
+
const fromRung = { agent, account, model: cur.model ?? null }
|
|
1030
|
+
const keepsConversation = Boolean(next.agent === 'claude' && isDownshift(fromRung, next) && cur.agent_session_id)
|
|
1031
|
+
appendEvent(sid, { type: 'handoff', summary: `${rungLabel(fromRung)} → ${rungLabel(next)}${keepsConversation ? ' (kept the conversation)' : bundle ? ` (bundle ${bundle.id})` : ''}` })
|
|
1032
|
+
if (keepsConversation) {
|
|
1033
|
+
prompt = null
|
|
1034
|
+
} else if (bundle) {
|
|
730
1035
|
prompt = resumePrompt(cur, bundle, next)
|
|
731
1036
|
} else {
|
|
732
1037
|
const delta = sessionCommitDelta(workRoot(cur) ?? cur.cwd, cur)
|
|
@@ -735,13 +1040,16 @@ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null
|
|
|
735
1040
|
: 'Check git status and git diff, then continue the work.'
|
|
736
1041
|
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'}`
|
|
737
1042
|
}
|
|
738
|
-
say(`starting ${next
|
|
1043
|
+
say(`starting ${rungLabel(next)} in this terminal ${keepsConversation ? 'with --resume: kept the conversation' : 'from the bundle'}`)
|
|
1044
|
+
legResume = keepsConversation ? cur.agent_session_id : null
|
|
1045
|
+
legModel = next.model ?? null
|
|
739
1046
|
agent = next.agent; account = next.account; legArgs = []
|
|
740
1047
|
// the chain is what comes after the agent now taking over, not after the
|
|
741
1048
|
// one that started the session: the card's "next" names a live option
|
|
742
1049
|
updateSession(sid, (fresh) => {
|
|
743
1050
|
const freshOrder = normalizeHandoffOrder(fresh.handoff_order)
|
|
744
|
-
|
|
1051
|
+
const freshLadder = ladderFor(fresh)
|
|
1052
|
+
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 }) }
|
|
745
1053
|
})
|
|
746
1054
|
}
|
|
747
1055
|
const fin = readSession(sid)
|