@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +158 -67
  3. package/bin/leg.mjs +168 -18
  4. package/docs/DECISIONS.md +10 -0
  5. package/docs/DEMO.md +20 -14
  6. package/docs/DEVIATIONS.md +1 -0
  7. package/docs/ERRORS.md +94 -0
  8. package/docs/ROADMAP-v2.md +69 -11
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/adapters.md +93 -11
  11. package/docs/board-guide.md +401 -66
  12. package/docs/cli-contracts.md +235 -22
  13. package/docs/concepts.md +167 -19
  14. package/docs/configuration.md +113 -5
  15. package/docs/faq.md +21 -5
  16. package/docs/getting-started.md +15 -11
  17. package/docs/redesign-2026-09-17.md +477 -0
  18. package/docs/screenshots/background-1280.png +0 -0
  19. package/docs/screenshots/board-400px.png +0 -0
  20. package/docs/screenshots/board-details-open.png +0 -0
  21. package/docs/screenshots/board-drawer.png +0 -0
  22. package/docs/screenshots/board-handoff.png +0 -0
  23. package/docs/screenshots/board-running.png +0 -0
  24. package/docs/screenshots/capacity-drawer-1280.png +0 -0
  25. package/docs/screenshots/settings-ladder-1280.png +0 -0
  26. package/docs/screenshots/terminals-1280.png +0 -0
  27. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  28. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  29. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  30. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  31. package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
  32. package/fixtures/live/claude/resume-model-probe.json +20 -0
  33. package/fixtures/live/claude/usage-oauth.json +87 -0
  34. package/fixtures/live/grok/cmd.txt +1 -1
  35. package/fixtures/live/grok/parsed.json +6 -3
  36. package/fixtures/live/grok/run.json +22 -10
  37. package/fixtures/verified.json +8 -1
  38. package/package.json +3 -2
  39. package/scripts/build-docs-site.mjs +4 -4
  40. package/scripts/probe.mjs +2 -1
  41. package/scripts/seed-fake-cards.mjs +59 -6
  42. package/scripts/seed-wes-board.mjs +81 -12
  43. package/src/accounts.mjs +6 -1
  44. package/src/adapters/cli.mjs +130 -0
  45. package/src/adapters/custom.mjs +271 -0
  46. package/src/adapters/grok.mjs +51 -10
  47. package/src/adapters/index.mjs +34 -7
  48. package/src/attach.mjs +350 -42
  49. package/src/audit.mjs +118 -0
  50. package/src/board/audit.js +123 -0
  51. package/src/board/board.css +134 -9
  52. package/src/board/board.js +482 -106
  53. package/src/board/index.html +89 -7
  54. package/src/board/sessions.js +1371 -113
  55. package/src/buckets.mjs +101 -0
  56. package/src/cards.mjs +9 -1
  57. package/src/chain.mjs +13 -0
  58. package/src/hook.mjs +7 -1
  59. package/src/ledger.mjs +10 -2
  60. package/src/orchestrator.mjs +13 -4
  61. package/src/preferences.mjs +214 -5
  62. package/src/scheduler.mjs +24 -1
  63. package/src/server.mjs +615 -50
  64. package/src/sessions.mjs +17 -1
  65. package/src/share.mjs +66 -6
  66. package/src/taps/claude-usage.mjs +91 -2
  67. package/src/taps/claude.mjs +144 -5
  68. package/src/taps/codex.mjs +23 -3
  69. package/src/taps/grok.mjs +4 -0
  70. package/src/usage.mjs +424 -13
@@ -0,0 +1,101 @@
1
+ // buckets — the one place per-model knowledge lives: the wording a CLI uses
2
+ // when it walls a single model, the model names Leg is willing to say out
3
+ // loud, and the flag each CLI spells its model with.
4
+ // Why one file: the same three facts were about to be needed by the usage
5
+ // record (which wall goes where), the taps (what a StopFailure message meant)
6
+ // and argv building (`--model` vs `-m`). Split across those, a reworded wall
7
+ // or a new alias would have to be fixed in three places and would be found by
8
+ // whichever one was missed. Nothing here reads or writes state.
9
+ //
10
+ // Sources for the wording table: docs/en/costs ("You've hit your Opus limit",
11
+ // session and weekly limits shared across models), the live StopFailure in
12
+ // fixtures/live/claude/limit-rate_limit.json ("You've reached your Fable
13
+ // limit."), and docs/cli-contracts.md:464 for codex ("usage limit for {name}",
14
+ // docs-only: no live codex per-model wall has been captured).
15
+ // Flags: verified in the adapters — src/adapters/claude.mjs:29 and agy.mjs:34
16
+ // push `--model`, codex.mjs:42 and grok.mjs:45 push `-m`.
17
+
18
+ // Model names per agent, lowercase, in ladder order (strongest first).
19
+ // Only claude publishes per-model buckets today; the others take a model on
20
+ // the command line but expose no per-model limit, so their lists stay empty
21
+ // rather than carrying a guess.
22
+ export const MODEL_ALIASES = {
23
+ claude: ['fable', 'opus', 'sonnet', 'haiku'],
24
+ codex: [],
25
+ agy: [],
26
+ grok: [],
27
+ }
28
+
29
+ const MODEL_FLAGS = { claude: '--model', agy: '--model', fake: '--model', codex: '-m', grok: '-m' }
30
+
31
+ // The flag this agent's CLI spells a model with, or null when Leg does not
32
+ // know it. A null answer means "do not pass a model", never "guess --model".
33
+ export function modelFlagFor(agent) {
34
+ return MODEL_FLAGS[agent] ?? null
35
+ }
36
+
37
+ // Where a model sits on its agent's own list: 0 is the strongest. -1 means Leg
38
+ // does not know the name, and an unknown name is never called stronger or
39
+ // weaker than anything.
40
+ export function modelRank(agent, model) {
41
+ if (!model) return -1
42
+ return (MODEL_ALIASES[agent] ?? []).indexOf(String(model).toLowerCase())
43
+ }
44
+
45
+ // A downshift is a move to a weaker model of the SAME login. It is the only
46
+ // move `claude --resume <id> --model <alias>` is used for: the probe in
47
+ // fixtures/live/claude/resume-model-probe.json shows the conversation survives
48
+ // and only the new model answers, but the context is re-read at the new model's
49
+ // rate (cache read 0 on the first resumed turn), so an upshift back to fable
50
+ // would pay that re-read at fable's price and takes the bundle instead.
51
+ export function isDownshift(from, to) {
52
+ if (!from || !to) return false
53
+ if (from.agent !== to.agent || (from.account ?? 'default') !== (to.account ?? 'default')) return false
54
+ const a = modelRank(from.agent, from.model)
55
+ const b = modelRank(to.agent, to.model)
56
+ return a >= 0 && b >= 0 && b > a
57
+ }
58
+
59
+ const SESSION_OR_WEEKLY = /(session|weekly) limit/i
60
+ const SPEND_WALL = /spend limit/i
61
+ const CODEX_MODEL_WALL = /usage limit for ([\w .-]+)/i
62
+
63
+ // "You've hit your Fable limit" / "You've reached your Opus limit". Built from
64
+ // the agent's own alias list, so codex's "You've hit your usage limit" can
65
+ // never be read as a model called "usage": a name Leg does not know falls
66
+ // through to rule 5 and walls the login.
67
+ function modelWallRe(agent) {
68
+ const names = MODEL_ALIASES[agent] ?? []
69
+ if (!names.length) return null
70
+ return new RegExp(`You.ve (?:hit|reached) your (${names.join('|')}) limit`, 'i')
71
+ }
72
+
73
+ // What a wall message walled. Ordered; the first rule that matches wins.
74
+ // { scope: 'account' } the whole login is out
75
+ // { scope: 'account', bucket: 'spend' } the spend cap, not a window
76
+ // { scope: 'model', model: 'fable' } one model family only
77
+ // Unrecognised wording is 'account' on purpose: walling the whole login is the
78
+ // direction that fails safe when the wording is reworded again, which it
79
+ // already was once ("hit" became "reached").
80
+ export function bucketFromWall(agent, text) {
81
+ const s = String(text ?? '')
82
+ // 1. session and weekly limits are shared across every model (docs/en/costs),
83
+ // so switching model buys nothing: the account is out.
84
+ if (SESSION_OR_WEEKLY.test(s)) return { scope: 'account' }
85
+ // 2. "You've hit/reached your <Model> limit" — one family.
86
+ const re = modelWallRe(agent)
87
+ const m = re ? re.exec(s) : null
88
+ if (m) return { scope: 'model', model: m[1].toLowerCase() }
89
+ // 3. the spend cap is an account fact, and it is not a window.
90
+ if (SPEND_WALL.test(s)) return { scope: 'account', bucket: 'spend' }
91
+ // 4. codex names the limit it hit (docs-only wording). The name runs to the
92
+ // end of the sentence, so cut at the first period that ends one: a model
93
+ // name's own dots (gpt-5.6-sol) are never followed by a space.
94
+ const c = CODEX_MODEL_WALL.exec(s)
95
+ if (c) {
96
+ const model = c[1].split(/\.(?=\s|$)/)[0].trim().toLowerCase()
97
+ if (model) return { scope: 'model', model }
98
+ }
99
+ // 5. anything else: the whole login.
100
+ return { scope: 'account' }
101
+ }
package/src/cards.mjs CHANGED
@@ -38,7 +38,12 @@ function kv(raw) {
38
38
  return m
39
39
  }
40
40
 
41
- export async function createCard(input, actor = { type: 'human', id: 'local' }) {
41
+ // `allowPipelineFile` is false for anything that arrives over HTTP. A pipeline
42
+ // that is a filesystem path is a CLI convenience (`--pipeline ./my.json`);
43
+ // taken from a request body it is an arbitrary read of this machine, and the
44
+ // JSON parser's own message quotes the first bytes of whatever it opened, so a
45
+ // 400 would hand a non-owner the contents of a file they may not see.
46
+ export async function createCard(input, actor = { type: 'human', id: 'local' }, { allowPipelineFile = true } = {}) {
42
47
  if (!input.repo) throw new CardInputError('missing repo')
43
48
  // stored as given (resolved); every comparison below is canonical, and the
44
49
  // worktree path is derived from the real long form in src/worktree.mjs
@@ -93,6 +98,9 @@ export async function createCard(input, actor = { type: 'human', id: 'local' })
93
98
  if (Array.isArray(pipelineArg)) pipeline = buildPipeline({ stations: pipelineArg, chain })
94
99
  else if (typeof pipelineArg === 'string' && pipelineArg.trim().startsWith('[')) pipeline = buildPipeline({ stations: JSON.parse(pipelineArg), chain })
95
100
  else if (PRESET_NAMES.includes(pipelineArg)) pipeline = buildPipeline({ preset: pipelineArg, chain })
101
+ // the refusal names the presets and never the value it was handed: a
102
+ // message that quoted the path back would still answer "does this exist"
103
+ else if (!allowPipelineFile) throw new Error(`pipeline must be one of the presets (${PRESET_NAMES.join(', ')}) or a list of stations`)
96
104
  else pipeline = buildPipeline({ file: pipelineArg, chain })
97
105
  validatePipeline(pipeline, await loadAdapterModes())
98
106
  } catch (err) {
package/src/chain.mjs CHANGED
@@ -31,6 +31,7 @@ export const TRANSITIONS = [
31
31
  ['running', 'land:bounced', 'queued', 'land red/conflict → bounce to build with the failure attached (phase 7)'],
32
32
  ['running', 'land:failed', 'failed', 'land attempts exhausted (phase 7)'],
33
33
  ['running', 'pause', 'paused', 'human: kill the child, write a bundle'],
34
+ ['*non-terminal*', 'take_over', 'paused', 'human: sits down in the card\'s worktree themselves; the child is killed and the card leaves the runnable set'],
34
35
  ['paused', 'resume', 'queued', 'human: same station and leg; prompt = bundle load + contract'],
35
36
  ['*non-terminal*', 'kill', 'killed', 'human'],
36
37
  ['*non-terminal*', 'reassign', 'queued', 'human: rewrite the current station\'s chain from the current leg'],
@@ -196,6 +197,18 @@ export function transition(card, action, payload = {}) {
196
197
  events.push(ev('paused', 'paused by human'))
197
198
  return { card: { ...card, status: 'paused' }, events }
198
199
  }
200
+ // Take over: a human opens an interactive terminal in this card's worktree.
201
+ // `pause` is legal from `running` alone, so a queued or handing_off card
202
+ // stayed in the set the scheduler starts from and a leg was launched into
203
+ // the checkout the human had just been handed. Legal from every
204
+ // non-terminal status, and it always lands on `paused`, which is the one
205
+ // state that is both out of the scheduler's reach and honest about what
206
+ // the card is doing: nothing, because a person has it.
207
+ case 'take_over': {
208
+ assertStatus(card, action, NON_TERMINAL)
209
+ events.push(ev('taken_over', `taken over by a human at ${card.station} leg ${card.leg} (was ${card.status})`))
210
+ return { card: { ...card, status: 'paused' }, events }
211
+ }
199
212
  case 'resume': {
200
213
  assertStatus(card, action, ['paused'])
201
214
  events.push(ev('resumed', `resumed at ${card.station} leg ${card.leg}`))
package/src/hook.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  // (2.1.268 does not; see src/taps/claude-usage.mjs) and prints one Leg line.
10
10
  import { appendFileSync } from 'node:fs'
11
11
  import { join } from 'node:path'
12
- import { handleHook, handleStatusline } from './taps/claude.mjs'
12
+ import { handleHook, handleStatusline, terminalSequenceFor } from './taps/claude.mjs'
13
13
  import { sessionDir } from './sessions.mjs'
14
14
  import { captureLive } from './live-capture.mjs'
15
15
 
@@ -40,6 +40,12 @@ try {
40
40
  if (payload.hook_event_name === 'StopFailure' && payload.error) {
41
41
  try { captureLive('claude', String(payload.error), payload, { sessionId }) } catch {}
42
42
  }
43
+ // Notification hooks cannot block or modify anything and their
44
+ // systemMessage is discarded, but Claude Code still emits terminalSequence
45
+ // for them (hooks doc 1490, 622). That is the toast, and it is the only
46
+ // thing this process prints on stdout for a hook.
47
+ const seq = terminalSequenceFor(payload)
48
+ if (seq) process.stdout.write(JSON.stringify({ terminalSequence: seq }) + '\n')
43
49
  } else if (kind === 'claude-statusline') {
44
50
  const { text } = handleStatusline(sessionId, payload)
45
51
  try { appendFileSync(join(sessionDir(sessionId), 'hook.log'), `${new Date().toISOString()} statusline rate_limits=${JSON.stringify(payload.rate_limits ?? null)}\n`) } catch {}
package/src/ledger.mjs CHANGED
@@ -16,14 +16,22 @@ import { dashclawConfig, record } from './sync/dashclaw.mjs'
16
16
  export const EVENT_TYPES = ['card_created', 'leg_started', 'leg_progress', 'leg_exited',
17
17
  'limit_detected', 'handoff_written', 'leg_resumed', 'station_done', 'bounced', 'landed',
18
18
  'land_warning', 'land_retry', 'blocked_by', 'scheduler_started', 'scheduler_stopped',
19
- 'approval_needed', 'approved', 'reassigned', 'paused', 'resumed', 'killed', 'done',
19
+ 'approval_needed', 'approved', 'reassigned', 'paused', 'resumed', 'taken_over', 'killed', 'done',
20
20
  'failed', 'error', 'status', 'harness', 'harness_blocked']
21
21
  export const STATUSES = ['backlog', 'queued', 'running', 'handing_off', 'waiting_human',
22
22
  'needs_approval', 'paused', 'done', 'failed', 'killed']
23
23
  const CLOSED = ['done', 'failed', 'killed']
24
24
  // card.json keys `update --patch` may set (everything else goes through a named flag)
25
25
  export const PATCHABLE = ['pipeline', 'leases', 'land_attempts', 'land_mode', 'test_command', 'title', 'trunk',
26
- 'bounce_reason', 'kill_requested', 'worktree', 'next_leg', 'handoff_outcome', 'resume_from_bundle', 'failure', 'last_bundle', 'pr_url', 'harness']
26
+ 'bounce_reason', 'kill_requested', 'worktree', 'next_leg', 'handoff_outcome', 'resume_from_bundle', 'failure', 'last_bundle', 'pr_url', 'harness',
27
+ // where this card came from ({ from: <terminal id> } after "End, and keep
28
+ // going as a card"), and whether its worktree was adopted from that terminal
29
+ // rather than cut for the card: the orchestrator must not cut a second one
30
+ // over the top of it (redesign G4)
31
+ // the branch that checkout is actually on: `leg/<card-id>` for one the card
32
+ // cut, the TERMINAL's branch for one it adopted, which is not a name the
33
+ // board can derive from the card id (redesign C.3's register)
34
+ 'lineage', 'worktree_adopted', 'worktree_branch']
27
35
  const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,39}$/i
28
36
 
29
37
  export const ROOT = process.env.LEG_HOME || process.env.BATON_HOME || (existsSync(join(homedir(), '.leg')) ? join(homedir(), '.leg') : existsSync(join(homedir(), '.baton')) ? join(homedir(), '.baton') : join(homedir(), '.leg'))
@@ -288,9 +288,18 @@ export async function runCard(id, { actor = BATON_ACTOR } = {}) {
288
288
 
289
289
  async function driveCard(id, card, actor) {
290
290
  if (card.status === 'backlog') card = step(id, 'enqueue', {}, actor)
291
- const wt = ensureWorktree(card.repo, card.card_id, { trunk: card.trunk || 'main' })
292
- if (card.worktree !== wt.path) {
293
- ledgerUpdate(id, { patch: { worktree: wt.path } })
291
+ // A card born from "End, and keep going as a card" continues in the
292
+ // terminal's own worktree, exactly where the terminal stopped (redesign G4).
293
+ // Cutting a second worktree on that branch is the conflict machine the
294
+ // roadmap already rejects, so an adopted checkout is used as it is.
295
+ const wt = card.worktree_adopted && card.worktree && existsSync(card.worktree)
296
+ ? { path: card.worktree, branch: null, created: false }
297
+ : ensureWorktree(card.repo, card.card_id, { trunk: card.trunk || 'main' })
298
+ // the branch that checkout is on is recorded with it: the board's row prints
299
+ // it, and for an adopted checkout it is the TERMINAL's branch, which no
300
+ // reader can derive from the card id (redesign C.3)
301
+ if (card.worktree !== wt.path || (wt.branch && card.worktree_branch !== wt.branch)) {
302
+ ledgerUpdate(id, { patch: { worktree: wt.path, ...(wt.branch ? { worktree_branch: wt.branch } : {}) } })
294
303
  card = readCard(id)
295
304
  }
296
305
  for (;;) {
@@ -349,7 +358,7 @@ export function humanAction(id, action, payload = {}, actor = { type: 'human', i
349
358
  if (!card) throw new Error(`card not found: ${id}`)
350
359
  const result = transition(card, action, payload)
351
360
  const next = apply(id, card, result, actor)
352
- if (['kill', 'pause', 'reassign', 'handoff_now'].includes(action) && card.status === 'running') killActiveRun(id)
361
+ if (['kill', 'pause', 'reassign', 'handoff_now', 'take_over'].includes(action) && card.status === 'running') killActiveRun(id)
353
362
  return next
354
363
  }
355
364
 
@@ -5,6 +5,8 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
5
5
  import { join } from 'node:path'
6
6
  import { home } from './store.mjs'
7
7
  import { writeJsonAtomic, withFileLock } from './fsx.mjs'
8
+ import { MODEL_ALIASES } from './buckets.mjs'
9
+ import { readAccounts, ACCOUNT_NAME_RE } from './accounts.mjs'
8
10
 
9
11
  export const HANDOFF_AGENTS = ['claude', 'codex', 'agy']
10
12
  export const ALL_HANDOFF_AGENTS = ['claude', 'codex', 'agy', 'grok']
@@ -27,6 +29,180 @@ export function requireHandoffOrder(value) {
27
29
  return [...value]
28
30
  }
29
31
 
32
+ // ---- the fallback ladder (docs/redesign-2026-09-17.md B.3) ----
33
+ // A rung is a destination, not an agent: { agent, account, model, when, cost }.
34
+ // `handoff_order` stays on the file and stays derived from the ladder's
35
+ // distinct agent order, so `validHandoffOrder`, `requireHandoffOrder` and every
36
+ // older reader of this file keep working unchanged.
37
+ export const CLIMB_BACK_POLICIES = ['next-handoff', 'never'] // `when-quiet` is deliberately not shipped (B.7)
38
+ export const RUNG_COSTS = ['free', 'plan', 'credits', 'metered']
39
+ const WHEN_RE = /^(?:always|walled-only|below:(100|[0-9]{1,2}))$/
40
+
41
+ // The word for what a rung spends when nothing about the login is known yet.
42
+ // agy is free, grok bills metered credits through its proxy, a subscription
43
+ // login is `plan`. Deliberately not a function of live state: a word persisted
44
+ // in preferences.json must not be able to go stale (rungCost does the live part).
45
+ export function staticCost(agent) {
46
+ return agent === 'agy' ? 'free' : agent === 'grok' ? 'metered' : 'plan'
47
+ }
48
+
49
+ // What this rung spends RIGHT NOW, derived from the agent and the live login.
50
+ // `credits` is the one cost that depends on live state: claude/fable bills
51
+ // usage credits only once the login has them enabled (extra_usage.enabled),
52
+ // and until then it is ordinary plan usage.
53
+ //
54
+ // The word on the rung is never consulted: it is a label that was persisted
55
+ // once and can be any age. An install whose ladder was migrated from a bare
56
+ // handoff_order carries `plan` on every rung, and reading that word let an
57
+ // unattended hand-off take grok and bill metered credits with may_spend false
58
+ // (B.5's gate exists for exactly that rung). Agent plus live login, every time.
59
+ export function rungCost(rung, usage = null) {
60
+ if (!rung) return 'plan'
61
+ if (rung.agent === 'claude' && rung.model === 'fable' && usage?.extra_usage?.enabled === true) return 'credits'
62
+ return staticCost(rung.agent)
63
+ }
64
+
65
+ // An account name reaches the CLI's config dir (CLAUDE_CONFIG_DIR) and the
66
+ // usage record's file name, so a rung may only name one this machine actually
67
+ // has. Anything else is a path in disguise: `../../../../pwned` pointed the
68
+ // client at an attacker-chosen directory and wrote the record beside it.
69
+ export function validRungAccount(agent, account) {
70
+ if (!ACCOUNT_NAME_RE.test(String(account ?? ''))) return `rung "account" must be a name of letters, digits, - and _ (got "${account}")`
71
+ const known = readAccounts()[agent] ?? ['default']
72
+ if (!known.includes(account)) return `${agent} has no account "${account}" on this machine (${known.join(', ')})`
73
+ return null
74
+ }
75
+
76
+ export function normalizeRung(value) {
77
+ const agent = String(value?.agent ?? '')
78
+ const model = value?.model === undefined || value?.model === null || value?.model === '' ? null : String(value.model).toLowerCase()
79
+ return {
80
+ agent,
81
+ account: value?.account ? String(value.account) : 'default',
82
+ model,
83
+ when: typeof value?.when === 'string' && WHEN_RE.test(value.when) ? value.when : 'always',
84
+ cost: RUNG_COSTS.includes(value?.cost) ? value.cost : staticCost(agent),
85
+ }
86
+ }
87
+
88
+ export const rungKey = (r) => `${r.agent}--${r.account}--${r.model ?? ''}`
89
+
90
+ // One rung per agent, model null, the cost that agent actually has: what an
91
+ // existing `handoff_order` means, written out long-hand. Behaviour is
92
+ // bit-identical to the order it came from until a human edits a rung. The cost
93
+ // word is read off the agent (`staticCost`) rather than fixed at 'plan',
94
+ // because grok and agy are exactly the two agents the word exists for: a
95
+ // migrated grok rung written as 'plan' walks straight through the spending gate.
96
+ export function ladderFromOrder(order) {
97
+ return normalizeHandoffOrder(order).map((agent) => ({ agent, account: 'default', model: null, when: 'always', cost: staticCost(agent) }))
98
+ }
99
+
100
+ // A fresh install: the claude models first (a same-login switch keeps the
101
+ // conversation, B.5), then every other agent of the default order (G2).
102
+ export function defaultLadder() {
103
+ const rungs = MODEL_ALIASES.claude.slice(0, 3).map((model) => ({ agent: 'claude', account: 'default', model, when: 'always', cost: 'plan' }))
104
+ for (const agent of HANDOFF_AGENTS) if (agent !== 'claude') rungs.push({ agent, account: 'default', model: null, when: 'always', cost: staticCost(agent) })
105
+ return rungs
106
+ }
107
+
108
+ export function validHandoffLadder(value) {
109
+ try { requireHandoffLadder(value); return true } catch { return false }
110
+ }
111
+
112
+ export function requireHandoffLadder(value) {
113
+ if (!Array.isArray(value) || !value.length) throw new TypeError('handoff_ladder must be a non-empty array of rungs')
114
+ const seen = new Set()
115
+ const out = []
116
+ for (const raw of value) {
117
+ if (!raw || typeof raw !== 'object') throw new TypeError('each rung must be an object: {agent, account, model, when, cost}')
118
+ if (!ALL_HANDOFF_AGENTS.includes(raw.agent)) throw new TypeError(`unknown agent "${raw.agent}" in handoff_ladder (${ALL_HANDOFF_AGENTS.join(', ')})`)
119
+ const rung = normalizeRung(raw)
120
+ const badAccount = validRungAccount(rung.agent, rung.account)
121
+ if (badAccount) throw new TypeError(badAccount)
122
+ if (rung.model && !(MODEL_ALIASES[rung.agent] ?? []).includes(rung.model)) {
123
+ const known = (MODEL_ALIASES[rung.agent] ?? []).join(', ')
124
+ throw new TypeError(`${rung.agent} has no model "${rung.model}"${known ? ` (${known})` : ': Leg knows no model names for it'}`)
125
+ }
126
+ if (raw.when !== undefined && !(typeof raw.when === 'string' && WHEN_RE.test(raw.when))) throw new TypeError(`rung "when" must be always, below:N or walled-only (got "${raw.when}")`)
127
+ if (raw.cost !== undefined && !RUNG_COSTS.includes(raw.cost)) throw new TypeError(`rung "cost" must be one of ${RUNG_COSTS.join(', ')}`)
128
+ const key = rungKey(rung)
129
+ if (seen.has(key)) throw new TypeError(`handoff_ladder names ${rung.agent}/${rung.account}${rung.model ? '/' + rung.model : ''} twice`)
130
+ seen.add(key)
131
+ out.push(rung)
132
+ }
133
+ return out
134
+ }
135
+
136
+ // The ladder's distinct agents, in the order the ladder first names them, then
137
+ // whatever the old three- or four-agent contract still needs so that
138
+ // `validHandoffOrder` stays true for every older reader of this file.
139
+ export function orderFromLadder(ladder) {
140
+ const out = []
141
+ for (const r of ladder) if (!out.includes(r.agent)) out.push(r.agent)
142
+ const wanted = out.includes('grok') ? ALL_HANDOFF_AGENTS : HANDOFF_AGENTS
143
+ for (const a of wanted) if (!out.includes(a)) out.push(a)
144
+ return out.filter((a) => wanted.includes(a))
145
+ }
146
+
147
+ // The ladder a preferences object means: its own, else the long-hand form of
148
+ // its `handoff_order`, else the default ladder.
149
+ export function normalizeHandoffLadder(prefs) {
150
+ const value = Array.isArray(prefs) ? prefs : prefs?.handoff_ladder
151
+ if (Array.isArray(value) && value.length) { try { return requireHandoffLadder(value) } catch { /* fall through to the order */ } }
152
+ if (!Array.isArray(prefs) && validHandoffOrder(prefs?.handoff_order)) return ladderFromOrder(prefs.handoff_order)
153
+ if (Array.isArray(prefs)) return defaultLadder()
154
+ return defaultLadder()
155
+ }
156
+
157
+ // The ladder a RECORD means, where the record carries both keys and something
158
+ // that never heard of ladders may have written one of them. `handoff_order` is
159
+ // the older, narrower statement of the same intent: when the two disagree, the
160
+ // order wins and the ladder is rebuilt from it, because the writer that set an
161
+ // order alone is the one that did not know the ladder was there. Leg's own
162
+ // writers always set both, so this only ever fires for an outside edit.
163
+ export function ladderFor(record) {
164
+ const ladder = normalizeHandoffLadder({ handoff_ladder: record?.handoff_ladder, handoff_order: record?.handoff_order })
165
+ if (!Array.isArray(record?.handoff_ladder) || !record.handoff_ladder.length) return ladder
166
+ if (!validHandoffOrder(record?.handoff_order)) return ladder
167
+ const derived = orderFromLadder(ladder)
168
+ const same = derived.length === record.handoff_order.length && derived.every((a, i) => a === record.handoff_order[i])
169
+ return same ? ladder : ladderFromOrder(record.handoff_order)
170
+ }
171
+
172
+ export function normalizeClimbBack(value) {
173
+ return CLIMB_BACK_POLICIES.includes(value) ? value : 'next-handoff'
174
+ }
175
+
176
+ export function requireClimbBack(value) {
177
+ if (!CLIMB_BACK_POLICIES.includes(value)) throw new TypeError(`climb_back must be one of ${CLIMB_BACK_POLICIES.join(', ')}`)
178
+ return value
179
+ }
180
+
181
+ // Per login, the share of a window an automatic hand-off may not eat into, so a
182
+ // background card cannot spend the last of what the human wants for their own
183
+ // terminal. `{}` by default: a floor nobody asked for is a wrong number.
184
+ export function normalizeReserve(value) {
185
+ const out = {}
186
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return out
187
+ for (const [agent, pct] of Object.entries(value)) {
188
+ if (!ALL_HANDOFF_AGENTS.includes(agent)) continue
189
+ const n = Number(pct)
190
+ if (!Number.isFinite(n) || n <= 0 || n > 100) continue
191
+ out[agent] = Math.round(n)
192
+ }
193
+ return out
194
+ }
195
+
196
+ export function requireReserve(value) {
197
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('reserve must be an object of {agent: percent}')
198
+ for (const [agent, pct] of Object.entries(value)) {
199
+ if (!ALL_HANDOFF_AGENTS.includes(agent)) throw new TypeError(`unknown agent "${agent}" in reserve (${ALL_HANDOFF_AGENTS.join(', ')})`)
200
+ const n = Number(pct)
201
+ if (!Number.isFinite(n) || n <= 0 || n > 100) throw new TypeError(`reserve.${agent} must be a percentage between 1 and 100`)
202
+ }
203
+ return normalizeReserve(value)
204
+ }
205
+
30
206
  // Portable harness (src/harness/): off for every existing install. `enabled`
31
207
  // is the explicit consent `leg harness enable` records; `policy` is what an
32
208
  // unattended hand-off may do (warn: report only; sync: write managed state
@@ -71,29 +247,62 @@ export function resolveAutoApprove({ env = process.env, preferences = null, cliF
71
247
  return true
72
248
  }
73
249
 
250
+ // Where a terminal that is waiting on a human says so. `notify_terminal` is on
251
+ // by default: the OSC 9 toast reaches the window the human is already in, with
252
+ // no browser and no permission prompt (docs/redesign-2026-09-17.md E, the
253
+ // notifications table). `notify_board` is off by default because the browser's
254
+ // own Notification permission has to be granted first, and a toggle that asks
255
+ // for a permission nobody wanted is worse than no toggle.
256
+ const defaults = () => {
257
+ const ladder = defaultLadder()
258
+ return { handoff_order: orderFromLadder(ladder), handoff_ladder: ladder, climb_back: 'next-handoff', may_spend: false, reserve: {}, auto_approve: true, notify_terminal: true, notify_board: false, harness: { ...HARNESS_DEFAULTS } }
259
+ }
260
+
74
261
  export function readPreferences() {
75
262
  const file = preferencesFile()
76
- if (!existsSync(file)) return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true, harness: { ...HARNESS_DEFAULTS } }
263
+ if (!existsSync(file)) return defaults()
77
264
  try {
78
265
  const value = JSON.parse(readFileSync(file, 'utf8'))
266
+ // The ladder is the richer key, so it decides the order when the file
267
+ // carries both; a file written by an older Leg carries only the order, and
268
+ // the ladder it means is that order written out long-hand.
269
+ const ladder = normalizeHandoffLadder(value)
79
270
  return {
80
- handoff_order: normalizeHandoffOrder(value?.handoff_order),
271
+ handoff_order: Array.isArray(value?.handoff_ladder) && value.handoff_ladder.length ? orderFromLadder(ladder) : normalizeHandoffOrder(value?.handoff_order),
272
+ handoff_ladder: ladder,
273
+ climb_back: normalizeClimbBack(value?.climb_back),
274
+ may_spend: value?.may_spend === true,
275
+ reserve: normalizeReserve(value?.reserve),
81
276
  auto_approve: value?.auto_approve !== false,
277
+ notify_terminal: value?.notify_terminal !== false,
278
+ notify_board: value?.notify_board === true,
82
279
  harness: normalizeHarness(value?.harness),
83
280
  }
84
281
  } catch {
85
- return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true, harness: { ...HARNESS_DEFAULTS } }
282
+ return defaults()
86
283
  }
87
284
  }
88
285
 
89
286
  export function writePreferences(patch) {
90
- const order = patch?.handoff_order !== undefined ? requireHandoffOrder(patch?.handoff_order) : undefined
287
+ // Writing one of the two rewrites the other: the ladder is the shape Leg
288
+ // walks, `handoff_order` is the shape every older reader knows, and they may
289
+ // never disagree on disk.
290
+ const ladder = patch?.handoff_ladder !== undefined ? requireHandoffLadder(patch.handoff_ladder) : undefined
291
+ const order = patch?.handoff_order !== undefined ? requireHandoffOrder(patch.handoff_order) : undefined
292
+ const climbBack = patch?.climb_back !== undefined ? requireClimbBack(patch.climb_back) : undefined
293
+ const reserve = patch?.reserve !== undefined ? requireReserve(patch.reserve) : undefined
91
294
  mkdirSync(home(), { recursive: true })
92
295
  return withFileLock(preferencesFile() + '.lock', () => {
93
296
  const current = readPreferences()
94
297
  const next = { ...current }
95
- if (order !== undefined) next.handoff_order = order
298
+ if (ladder !== undefined) { next.handoff_ladder = ladder; next.handoff_order = orderFromLadder(ladder) }
299
+ if (order !== undefined) { next.handoff_order = order; if (ladder === undefined) next.handoff_ladder = ladderFromOrder(order) }
300
+ if (climbBack !== undefined) next.climb_back = climbBack
301
+ if (reserve !== undefined) next.reserve = reserve
302
+ if (patch?.may_spend !== undefined) next.may_spend = Boolean(patch.may_spend)
96
303
  if (patch?.auto_approve !== undefined) next.auto_approve = Boolean(patch.auto_approve)
304
+ if (patch?.notify_terminal !== undefined) next.notify_terminal = Boolean(patch.notify_terminal)
305
+ if (patch?.notify_board !== undefined) next.notify_board = Boolean(patch.notify_board)
97
306
  if (patch?.harness !== undefined) next.harness = requireHarness(patch.harness, current.harness)
98
307
  writeJsonAtomic(preferencesFile(), next)
99
308
  return next
package/src/scheduler.mjs CHANGED
@@ -9,6 +9,7 @@ import { conflicts } from './leases.mjs'
9
9
  import { canonPath } from './fsx.mjs'
10
10
  import { runCard, orphanedRun, unsettledRun, driverAlive } from './orchestrator.mjs'
11
11
  import { listCards, ledgerAppend, ledgerLog, home, sleep } from './store.mjs'
12
+ import { readSession, isActive } from './sessions.mjs'
12
13
 
13
14
  export const MAX_CONCURRENT = Math.max(1, parseInt((process.env.LEG_MAX_CONCURRENT || process.env.BATON_MAX_CONCURRENT) || '2', 10) || 2)
14
15
  const ACTIVE = ['running', 'handing_off']
@@ -44,6 +45,21 @@ export function pickRunnable(cards, { max = MAX_CONCURRENT, landing = new Set()
44
45
  return { start, blocked, running }
45
46
  }
46
47
 
48
+ // A card born from "End, and keep going as a card" runs in the terminal's own
49
+ // checkout. The board waits for that terminal to stop before it queues the
50
+ // card, but a card queued by hand (Run), by a rerun, or by an older board must
51
+ // not start a headless agent in a working tree an interactive one is still
52
+ // writing to. The record is the same one the board reads to know a terminal
53
+ // ended.
54
+ export function heldByLiveTerminal(card) {
55
+ const from = card?.lineage?.from
56
+ if (!from || !card.worktree_adopted) return null
57
+ try {
58
+ const s = readSession(from)
59
+ return s && isActive(s) ? from : null
60
+ } catch { return null }
61
+ }
62
+
47
63
  export function pidfile() { return join(home(), 'scheduler.pid') }
48
64
 
49
65
  export function createScheduler({ max = MAX_CONCURRENT, intervalMs = 1000, actor = { type: 'leg' } } = {}) {
@@ -53,7 +69,14 @@ export function createScheduler({ max = MAX_CONCURRENT, intervalMs = 1000, actor
53
69
  state.ticks += 1
54
70
  const cards = listCards()
55
71
  const landing = landingRepos(cards)
56
- const { start, blocked } = pickRunnable(cards, { max, landing })
72
+ const picked = pickRunnable(cards, { max, landing })
73
+ const start = []
74
+ const blocked = [...picked.blocked]
75
+ for (const c of picked.start) {
76
+ const terminal = heldByLiveTerminal(c)
77
+ if (terminal) blocked.push({ card: c, conflicts: [], reason: `terminal ${terminal} is still running in this card's checkout` })
78
+ else start.push(c)
79
+ }
57
80
  for (const b of blocked) {
58
81
  const key = b.conflicts.length ? b.conflicts.map((x) => `${x.holder}:${x.lease}`).join(',') : b.reason
59
82
  if (state.blockedKeys.get(b.card.card_id) === key) continue