@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/README.md +95 -65
  3. package/bin/leg.mjs +123 -14
  4. package/docs/DECISIONS.md +18 -0
  5. package/docs/DEMO.md +20 -14
  6. package/docs/DEVIATIONS.md +1 -0
  7. package/docs/ERRORS.md +68 -0
  8. package/docs/ROADMAP-v2.md +50 -5
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/board-guide.md +529 -96
  11. package/docs/cli-contracts.md +241 -5
  12. package/docs/concepts.md +167 -19
  13. package/docs/configuration.md +65 -1
  14. package/docs/faq.md +21 -5
  15. package/docs/getting-started.md +15 -11
  16. package/docs/redesign-2026-09-17.md +477 -0
  17. package/docs/screenshots/background-1280.png +0 -0
  18. package/docs/screenshots/board-400px.png +0 -0
  19. package/docs/screenshots/board-details-open.png +0 -0
  20. package/docs/screenshots/board-drawer.png +0 -0
  21. package/docs/screenshots/board-handoff.png +0 -0
  22. package/docs/screenshots/board-running.png +0 -0
  23. package/docs/screenshots/capacity-drawer-1280.png +0 -0
  24. package/docs/screenshots/floor.png +0 -0
  25. package/docs/screenshots/new-card-dialog.png +0 -0
  26. package/docs/screenshots/settings-ladder-1280.png +0 -0
  27. package/docs/screenshots/terminals-1280.png +0 -0
  28. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  29. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  30. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  31. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  32. package/fixtures/live/claude/resume-model-probe.json +20 -0
  33. package/fixtures/live/claude/usage-oauth.json +87 -0
  34. package/fixtures/verified.json +1 -1
  35. package/package.json +3 -2
  36. package/scripts/board-jump-probe.mjs +335 -0
  37. package/scripts/seed-fake-cards.mjs +59 -6
  38. package/scripts/seed-wes-board.mjs +81 -12
  39. package/src/accounts.mjs +6 -1
  40. package/src/attach.mjs +378 -93
  41. package/src/audit.mjs +1 -1
  42. package/src/board/board.css +203 -11
  43. package/src/board/board.js +664 -200
  44. package/src/board/entry.js +343 -0
  45. package/src/board/floor.html +51 -39
  46. package/src/board/floor.js +585 -73
  47. package/src/board/index.html +122 -45
  48. package/src/board/sessions.js +1569 -141
  49. package/src/board/strip.js +163 -0
  50. package/src/buckets.mjs +101 -0
  51. package/src/cards.mjs +9 -1
  52. package/src/chain.mjs +13 -0
  53. package/src/hook.mjs +7 -1
  54. package/src/ledger.mjs +10 -2
  55. package/src/models.mjs +265 -0
  56. package/src/orchestrator.mjs +13 -4
  57. package/src/preferences.mjs +278 -5
  58. package/src/scheduler.mjs +24 -1
  59. package/src/server.mjs +625 -78
  60. package/src/sessions.mjs +17 -1
  61. package/src/taps/claude-usage.mjs +107 -3
  62. package/src/taps/claude.mjs +144 -5
  63. package/src/taps/codex.mjs +23 -3
  64. package/src/usage-poll.mjs +260 -0
  65. package/src/usage.mjs +439 -12
@@ -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,228 @@ 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
+ // A rung's model reaches the agent's argv as `--model <id>` or `-m <id>`, so
77
+ // the first question is shape, not membership: a value with a space, a quote,
78
+ // a leading dash or a path separator in it is a flag or a path in disguise.
79
+ //
80
+ // Membership is asked of claude alone. MODEL_ALIASES.claude is a CLOSED list:
81
+ // four words Claude Code resolves itself, not service-side ids, so a fifth
82
+ // word there is a typo and saying so is help. The other three catalogs are
83
+ // live (src/models.mjs reads codex's cache file and asks agy and grok), they
84
+ // gain and lose names between Leg releases, and a list frozen in this file
85
+ // would refuse tomorrow's model with "Leg knows no model names for it" while
86
+ // the CLI next to it ran it happily. Their ids are checked for shape and then
87
+ // believed: the CLI itself is the authority on its own catalog, and it answers
88
+ // an id it does not have in one line on the leg's own log.
89
+ const RUNG_MODEL_RE = /^[a-z0-9][a-z0-9._:-]{0,63}$/
90
+
91
+ export function validRungModel(agent, model) {
92
+ const id = String(model ?? '')
93
+ if (!RUNG_MODEL_RE.test(id)) return `rung "model" must be a model id of letters, digits, . _ : and - (got "${model}")`
94
+ const closed = MODEL_ALIASES[agent] ?? []
95
+ if (closed.length && !closed.includes(id)) return `${agent} has no model "${id}" (${closed.join(', ')})`
96
+ return null
97
+ }
98
+
99
+ export function normalizeRung(value) {
100
+ const agent = String(value?.agent ?? '')
101
+ const model = value?.model === undefined || value?.model === null || value?.model === '' ? null : String(value.model).toLowerCase()
102
+ return {
103
+ agent,
104
+ account: value?.account ? String(value.account) : 'default',
105
+ model,
106
+ when: typeof value?.when === 'string' && WHEN_RE.test(value.when) ? value.when : 'always',
107
+ cost: RUNG_COSTS.includes(value?.cost) ? value.cost : staticCost(agent),
108
+ }
109
+ }
110
+
111
+ export const rungKey = (r) => `${r.agent}--${r.account}--${r.model ?? ''}`
112
+
113
+ // One rung per agent, model null, the cost that agent actually has: what an
114
+ // existing `handoff_order` means, written out long-hand. Behaviour is
115
+ // bit-identical to the order it came from until a human edits a rung. The cost
116
+ // word is read off the agent (`staticCost`) rather than fixed at 'plan',
117
+ // because grok and agy are exactly the two agents the word exists for: a
118
+ // migrated grok rung written as 'plan' walks straight through the spending gate.
119
+ export function ladderFromOrder(order) {
120
+ return normalizeHandoffOrder(order).map((agent) => ({ agent, account: 'default', model: null, when: 'always', cost: staticCost(agent) }))
121
+ }
122
+
123
+ // A fresh install: the claude models first (a same-login switch keeps the
124
+ // conversation, B.5), then every other agent of the default order (G2).
125
+ export function defaultLadder() {
126
+ const rungs = MODEL_ALIASES.claude.slice(0, 3).map((model) => ({ agent: 'claude', account: 'default', model, when: 'always', cost: 'plan' }))
127
+ for (const agent of HANDOFF_AGENTS) if (agent !== 'claude') rungs.push({ agent, account: 'default', model: null, when: 'always', cost: staticCost(agent) })
128
+ return rungs
129
+ }
130
+
131
+ export function validHandoffLadder(value) {
132
+ try { requireHandoffLadder(value); return true } catch { return false }
133
+ }
134
+
135
+ export function requireHandoffLadder(value) {
136
+ if (!Array.isArray(value) || !value.length) throw new TypeError('handoff_ladder must be a non-empty array of rungs')
137
+ const seen = new Set()
138
+ const out = []
139
+ for (const raw of value) {
140
+ if (!raw || typeof raw !== 'object') throw new TypeError('each rung must be an object: {agent, account, model, when, cost}')
141
+ if (!ALL_HANDOFF_AGENTS.includes(raw.agent)) throw new TypeError(`unknown agent "${raw.agent}" in handoff_ladder (${ALL_HANDOFF_AGENTS.join(', ')})`)
142
+ const rung = normalizeRung(raw)
143
+ const badAccount = validRungAccount(rung.agent, rung.account)
144
+ if (badAccount) throw new TypeError(badAccount)
145
+ const badModel = rung.model ? validRungModel(rung.agent, rung.model) : null
146
+ if (badModel) throw new TypeError(badModel)
147
+ 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}")`)
148
+ if (raw.cost !== undefined && !RUNG_COSTS.includes(raw.cost)) throw new TypeError(`rung "cost" must be one of ${RUNG_COSTS.join(', ')}`)
149
+ const key = rungKey(rung)
150
+ if (seen.has(key)) throw new TypeError(`handoff_ladder names ${rung.agent}/${rung.account}${rung.model ? '/' + rung.model : ''} twice`)
151
+ seen.add(key)
152
+ out.push(rung)
153
+ }
154
+ return out
155
+ }
156
+
157
+ // The ladder's distinct agents, in the order the ladder first names them, then
158
+ // whatever the old three- or four-agent contract still needs so that
159
+ // `validHandoffOrder` stays true for every older reader of this file.
160
+ export function orderFromLadder(ladder) {
161
+ const out = []
162
+ for (const r of ladder) if (!out.includes(r.agent)) out.push(r.agent)
163
+ const wanted = out.includes('grok') ? ALL_HANDOFF_AGENTS : HANDOFF_AGENTS
164
+ for (const a of wanted) if (!out.includes(a)) out.push(a)
165
+ return out.filter((a) => wanted.includes(a))
166
+ }
167
+
168
+ // One bad rung is one bad rung. `requireHandoffLadder` throws on the first
169
+ // problem it meets, and catching that threw the whole ladder away: a user who
170
+ // mistyped one claude alias in a file docs/configuration.md invites them to
171
+ // hand-edit lost their three good rungs with it, silently, and their terminals
172
+ // then handed off somewhere they never asked for. The same swallow fired for a
173
+ // rung naming an account that has since been removed, which is a live check.
174
+ //
175
+ // → { ladder, dropped }, where a dropped rung keeps the reason it was refused.
176
+ export function salvageHandoffLadder(value) {
177
+ const ladder = []
178
+ const dropped = []
179
+ const seen = new Set()
180
+ for (const raw of Array.isArray(value) ? value : []) {
181
+ let rung = null
182
+ try { [rung] = requireHandoffLadder([raw]) } catch (err) { dropped.push({ rung: raw, why: err.message }); continue }
183
+ const k = rungKey(rung)
184
+ if (seen.has(k)) { dropped.push({ rung: raw, why: `handoff_ladder names ${rung.agent}/${rung.account}${rung.model ? '/' + rung.model : ''} twice` }); continue }
185
+ seen.add(k)
186
+ ladder.push(rung)
187
+ }
188
+ return { ladder, dropped }
189
+ }
190
+
191
+ // The ladder a preferences object means: its own, else the long-hand form of
192
+ // its `handoff_order`, else the default ladder.
193
+ export function normalizeHandoffLadder(prefs) {
194
+ const value = Array.isArray(prefs) ? prefs : prefs?.handoff_ladder
195
+ if (Array.isArray(value) && value.length) {
196
+ const { ladder } = salvageHandoffLadder(value)
197
+ if (ladder.length) return ladder
198
+ /* nothing readable left: fall through to the order */
199
+ }
200
+ if (!Array.isArray(prefs) && validHandoffOrder(prefs?.handoff_order)) return ladderFromOrder(prefs.handoff_order)
201
+ if (Array.isArray(prefs)) return defaultLadder()
202
+ return defaultLadder()
203
+ }
204
+
205
+ // The ladder a RECORD means, where the record carries both keys and something
206
+ // that never heard of ladders may have written one of them. `handoff_order` is
207
+ // the older, narrower statement of the same intent: when the two disagree, the
208
+ // order wins and the ladder is rebuilt from it, because the writer that set an
209
+ // order alone is the one that did not know the ladder was there. Leg's own
210
+ // writers always set both, so this only ever fires for an outside edit.
211
+ export function ladderFor(record) {
212
+ const ladder = normalizeHandoffLadder({ handoff_ladder: record?.handoff_ladder, handoff_order: record?.handoff_order })
213
+ if (!Array.isArray(record?.handoff_ladder) || !record.handoff_ladder.length) return ladder
214
+ if (!validHandoffOrder(record?.handoff_order)) return ladder
215
+ const derived = orderFromLadder(ladder)
216
+ const same = derived.length === record.handoff_order.length && derived.every((a, i) => a === record.handoff_order[i])
217
+ return same ? ladder : ladderFromOrder(record.handoff_order)
218
+ }
219
+
220
+ export function normalizeClimbBack(value) {
221
+ return CLIMB_BACK_POLICIES.includes(value) ? value : 'next-handoff'
222
+ }
223
+
224
+ export function requireClimbBack(value) {
225
+ if (!CLIMB_BACK_POLICIES.includes(value)) throw new TypeError(`climb_back must be one of ${CLIMB_BACK_POLICIES.join(', ')}`)
226
+ return value
227
+ }
228
+
229
+ // Per login, the share of a window an automatic hand-off may not eat into, so a
230
+ // background card cannot spend the last of what the human wants for their own
231
+ // terminal. `{}` by default: a floor nobody asked for is a wrong number.
232
+ export function normalizeReserve(value) {
233
+ const out = {}
234
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return out
235
+ for (const [agent, pct] of Object.entries(value)) {
236
+ if (!ALL_HANDOFF_AGENTS.includes(agent)) continue
237
+ const n = Number(pct)
238
+ if (!Number.isFinite(n) || n <= 0 || n > 100) continue
239
+ out[agent] = Math.round(n)
240
+ }
241
+ return out
242
+ }
243
+
244
+ export function requireReserve(value) {
245
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('reserve must be an object of {agent: percent}')
246
+ for (const [agent, pct] of Object.entries(value)) {
247
+ if (!ALL_HANDOFF_AGENTS.includes(agent)) throw new TypeError(`unknown agent "${agent}" in reserve (${ALL_HANDOFF_AGENTS.join(', ')})`)
248
+ const n = Number(pct)
249
+ if (!Number.isFinite(n) || n <= 0 || n > 100) throw new TypeError(`reserve.${agent} must be a percentage between 1 and 100`)
250
+ }
251
+ return normalizeReserve(value)
252
+ }
253
+
30
254
  // Portable harness (src/harness/): off for every existing install. `enabled`
31
255
  // is the explicit consent `leg harness enable` records; `policy` is what an
32
256
  // unattended hand-off may do (warn: report only; sync: write managed state
@@ -71,29 +295,78 @@ export function resolveAutoApprove({ env = process.env, preferences = null, cliF
71
295
  return true
72
296
  }
73
297
 
298
+ // Where a terminal that is waiting on a human says so. `notify_terminal` is on
299
+ // by default: the OSC 9 toast reaches the window the human is already in, with
300
+ // no browser and no permission prompt (docs/redesign-2026-09-17.md E, the
301
+ // notifications table). `notify_board` is off by default because the browser's
302
+ // own Notification permission has to be granted first, and a toggle that asks
303
+ // for a permission nobody wanted is worse than no toggle.
304
+ const defaults = () => {
305
+ const ladder = defaultLadder()
306
+ 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 } }
307
+ }
308
+
309
+ // The file verbatim, or null when there is not one Leg can parse. Used by
310
+ // writePreferences to leave alone what it could not read.
311
+ function rawPreferences() {
312
+ try { return JSON.parse(readFileSync(preferencesFile(), 'utf8')) } catch { return null }
313
+ }
314
+
74
315
  export function readPreferences() {
75
316
  const file = preferencesFile()
76
- if (!existsSync(file)) return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true, harness: { ...HARNESS_DEFAULTS } }
317
+ if (!existsSync(file)) return defaults()
77
318
  try {
78
319
  const value = JSON.parse(readFileSync(file, 'utf8'))
320
+ // The ladder is the richer key, so it decides the order when the file
321
+ // carries both; a file written by an older Leg carries only the order, and
322
+ // the ladder it means is that order written out long-hand.
323
+ const ladder = normalizeHandoffLadder(value)
79
324
  return {
80
- handoff_order: normalizeHandoffOrder(value?.handoff_order),
325
+ handoff_order: Array.isArray(value?.handoff_ladder) && value.handoff_ladder.length ? orderFromLadder(ladder) : normalizeHandoffOrder(value?.handoff_order),
326
+ handoff_ladder: ladder,
327
+ climb_back: normalizeClimbBack(value?.climb_back),
328
+ may_spend: value?.may_spend === true,
329
+ reserve: normalizeReserve(value?.reserve),
81
330
  auto_approve: value?.auto_approve !== false,
331
+ notify_terminal: value?.notify_terminal !== false,
332
+ notify_board: value?.notify_board === true,
82
333
  harness: normalizeHarness(value?.harness),
83
334
  }
84
335
  } catch {
85
- return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true, harness: { ...HARNESS_DEFAULTS } }
336
+ return defaults()
86
337
  }
87
338
  }
88
339
 
89
340
  export function writePreferences(patch) {
90
- const order = patch?.handoff_order !== undefined ? requireHandoffOrder(patch?.handoff_order) : undefined
341
+ // Writing one of the two rewrites the other: the ladder is the shape Leg
342
+ // walks, `handoff_order` is the shape every older reader knows, and they may
343
+ // never disagree on disk.
344
+ const ladder = patch?.handoff_ladder !== undefined ? requireHandoffLadder(patch.handoff_ladder) : undefined
345
+ const order = patch?.handoff_order !== undefined ? requireHandoffOrder(patch.handoff_order) : undefined
346
+ const climbBack = patch?.climb_back !== undefined ? requireClimbBack(patch.climb_back) : undefined
347
+ const reserve = patch?.reserve !== undefined ? requireReserve(patch.reserve) : undefined
91
348
  mkdirSync(home(), { recursive: true })
92
349
  return withFileLock(preferencesFile() + '.lock', () => {
93
350
  const current = readPreferences()
94
351
  const next = { ...current }
95
- if (order !== undefined) next.handoff_order = order
352
+ // A ladder Leg could only read part of stays on disk exactly as the human
353
+ // wrote it. readPreferences drops the rung it cannot parse so the machine
354
+ // keeps walking the rest, but that reading is not a decision to delete
355
+ // anything: a save about may_spend must never be what removes a rung
356
+ // somebody typed into a file the docs call hand-editable.
357
+ const raw = ladder === undefined && order === undefined ? rawPreferences() : null
358
+ if (Array.isArray(raw?.handoff_ladder) && raw.handoff_ladder.length && !validHandoffLadder(raw.handoff_ladder)) {
359
+ next.handoff_ladder = raw.handoff_ladder
360
+ if (validHandoffOrder(raw.handoff_order)) next.handoff_order = [...raw.handoff_order]
361
+ }
362
+ if (ladder !== undefined) { next.handoff_ladder = ladder; next.handoff_order = orderFromLadder(ladder) }
363
+ if (order !== undefined) { next.handoff_order = order; if (ladder === undefined) next.handoff_ladder = ladderFromOrder(order) }
364
+ if (climbBack !== undefined) next.climb_back = climbBack
365
+ if (reserve !== undefined) next.reserve = reserve
366
+ if (patch?.may_spend !== undefined) next.may_spend = Boolean(patch.may_spend)
96
367
  if (patch?.auto_approve !== undefined) next.auto_approve = Boolean(patch.auto_approve)
368
+ if (patch?.notify_terminal !== undefined) next.notify_terminal = Boolean(patch.notify_terminal)
369
+ if (patch?.notify_board !== undefined) next.notify_board = Boolean(patch.notify_board)
97
370
  if (patch?.harness !== undefined) next.harness = requireHarness(patch.harness, current.harness)
98
371
  writeJsonAtomic(preferencesFile(), next)
99
372
  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