@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
@@ -33,6 +33,11 @@
33
33
  needs_approval: 0, waiting_human: 0, failed: 1, killed: 1, running: 2,
34
34
  handing_off: 2, paused: 3, queued: 4, backlog: 5, done: 6,
35
35
  }
36
+ // C.1: liveness decides the surface. A live card is a row in the Background
37
+ // panel; a finished one collapses into the one ledger line.
38
+ const LIVE_STATUSES = ['backlog', 'queued', 'running', 'handing_off', 'needs_approval', 'waiting_human', 'paused']
39
+ const FINISHED_STATUSES = ['done', 'failed', 'killed']
40
+ const isLive = (card) => LIVE_STATUSES.includes(card.status)
36
41
  // 6.5 G10: the button order is fixed and never reflows by availability
37
42
  const ACTION_ORDER = ['approve', 'enqueue', 'resume', 'pause', 'handoff_now', 'rerun', 'reassign', 'kill']
38
43
  const ACTION_LABELS = {
@@ -58,6 +63,12 @@
58
63
  }
59
64
  const AGENT_IDS = ['claude', 'codex', 'agy', 'grok']
60
65
  const DEFAULT_BIND = '127.0.0.1:4747'
66
+ // The version these page files shipped with. The server answers /api/health
67
+ // with the version of the PROCESS, and the two drift apart the moment a
68
+ // release lands on disk under a board that was started before it: the page
69
+ // then draws controls the process has no routes for (an empty agent select,
70
+ // no buckets). test/files-version.test.mjs pins this to package.json.
71
+ const FILES_VERSION = '0.13.0'
61
72
  const TIMELINE_CAP = 12
62
73
  // mirrors LOOPBACK in src/auth.mjs; state.bind is "<host>:<port>" and an IPv6
63
74
  // host arrives bracketed
@@ -101,6 +112,17 @@
101
112
  healthKnown: false,
102
113
  bindKnown: false,
103
114
  lastHello: null,
115
+ // C.2: the one-line entry infers its repo from the most recently focused
116
+ // terminal, published by sessions.js on the leg:sessions window event.
117
+ sessions: [],
118
+ // one entry per repo with a live terminal: { repo, repo_name, branch }
119
+ repoTrunks: [],
120
+ preferences: null,
121
+ // { claude: [{id, label, default}], codex: [...], agy: [...], grok: [...] }
122
+ // from /api/models. null until it answers; an agent missing from it offers
123
+ // its provider default and nothing else, which is what a bare `leg <agent>`
124
+ // already does.
125
+ models: null,
104
126
  }
105
127
  // a card push while an agent writes its log only moves these two
106
128
  const VOLATILE_CARD_FIELDS = ['last_event', 'elapsed_ms']
@@ -216,20 +238,19 @@
216
238
  }
217
239
 
218
240
  // an idle card has no run to measure, and a run whose start did not parse is
219
- // not a zero-length run: both print the placeholder rather than a number
220
- // A running card counts up from its current run. A card that has finished
221
- // still owes the reader the number: created_at to updated_at is what it took,
222
- // and `--:--` in that column was three of the five demo stills saying nothing.
223
- // Only a card that has never started keeps the placeholder.
241
+ // not a zero-length run: both print the placeholder rather than a number.
242
+ // A running card counts up from its current run, in the column the terminal
243
+ // rows above hold their run clock in. A live card BETWEEN runs has no run
244
+ // clock at all: created_at to updated_at is the card's whole age, which for
245
+ // a card that sat in the backlog for a day reads `24:05:00` in a column that
246
+ // means "this run", so it is labelled for what it is instead: how long it
247
+ // has been sitting in the state it is in.
224
248
  function runElapsed(card) {
225
249
  const from = card.active_run ? Date.parse(card.active_run.started_at) : NaN
226
250
  if (Number.isFinite(from)) return elapsedClock(Date.now() - from)
227
251
  if (!card.runs_count) return '--:--'
228
- const started = Date.parse(card.created_at)
229
- const ended = Date.parse(card.updated_at)
230
- return Number.isFinite(started) && Number.isFinite(ended) && ended >= started
231
- ? elapsedClock(ended - started)
232
- : '--:--'
252
+ const since = Date.parse(card.updated_at)
253
+ return Number.isFinite(since) && since <= Date.now() ? `idle ${agoShort(Date.now() - since)}` : '--:--'
233
254
  }
234
255
 
235
256
  function clockAt(ms) {
@@ -301,6 +322,21 @@
301
322
  window.dispatchEvent(new CustomEvent('baton:sessions', { detail }))
302
323
  }
303
324
 
325
+ // the entry line's repo inference reads the latest sessions payload without
326
+ // owning the terminals region: sessions.js still renders it, this file only
327
+ // keeps a copy for "the most recently focused terminal's repo". Guarded the
328
+ // same way window.legMessage is above: the pure-logic test harness passes a
329
+ // window stub with no addEventListener at all.
330
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
331
+ window.addEventListener('leg:sessions', (e) => {
332
+ state.sessions = (e.detail && e.detail.sessions) || []
333
+ // each repo's own default branch, read once per push by the server: the
334
+ // entry line's `on <branch>` and the trunk it posts come from here
335
+ state.repoTrunks = (e.detail && e.detail.trunk) || []
336
+ renderEntryLine()
337
+ })
338
+ }
339
+
304
340
  function connectSse() {
305
341
  if (state.es) { try { state.es.close() } catch { /* ignore */ } }
306
342
  const request = ++state.sseRequest
@@ -395,6 +431,7 @@
395
431
  state.isOwner = !(data.you && data.you.role && data.you.role !== 'owner')
396
432
  state.healthKnown = true
397
433
  renderBoardFacts(data)
434
+ versionSkew(data.version)
398
435
  renderTokenMeta()
399
436
  // a guest is told who they are by health, which is open to them
400
437
  if (data.you && data.you.role && data.you.role !== 'owner') { guestMode(); return false }
@@ -416,60 +453,15 @@
416
453
  }
417
454
  }
418
455
 
419
- // ---- 6.12 step 8: one row per card, no columns ----
420
- // Background tasks are the third ledger cell: a count, a line of detail and a
421
- // disclosure, the same shape as finished terminals and what landed. Nothing
422
- // headless is what the board is for, so the rows live behind the button.
456
+ // ---- 6.12 step 6: liveness split, cards reborn ----
457
+ // C.1: a live card is a row in the Background panel, right under Terminals.
458
+ // A finished one falls into one ledger cell. `cardsOpen` now gates the
459
+ // finished-cards drawer, not a background-tasks drawer: there is no drawer
460
+ // for live cards, they are always on the page.
423
461
  let cardsOpen = false
424
462
  function toggleEmptyState() {
425
463
  const empty = document.getElementById('empty-state')
426
- const list = document.getElementById('columns')
427
- const panel = document.getElementById('cards-drawer')
428
- const hasCards = state.cards.size > 0
429
- empty.hidden = hasCards
430
- list.hidden = !hasCards
431
- if (panel) panel.hidden = !(hasCards && cardsOpen)
432
- }
433
-
434
- function countCards(...statuses) {
435
- let n = 0
436
- for (const c of state.cards.values()) if (statuses.includes(c.status)) n += 1
437
- return n
438
- }
439
-
440
- // G14: a verdict carries its volume
441
- function cardsMeta() {
442
- const total = state.cards.size
443
- if (!total) return ''
444
- const running = countCards('running', 'handing_off')
445
- const queued = countCards('queued')
446
- const backlog = countCards('backlog')
447
- const waiting = countCards('waiting_human', 'needs_approval')
448
- const finished = countCards('done', 'failed', 'killed')
449
- const parts = []
450
- if (running) parts.push(`${running} running`)
451
- if (queued) parts.push(`${queued} queued`)
452
- if (backlog) parts.push(`${backlog} in backlog`)
453
- if (waiting) parts.push(`${waiting} waiting on you`)
454
- if (finished) parts.push(`${finished} finished`)
455
- return parts.length ? parts.join(', ') : `${total} cards, nothing is running`
456
- }
457
-
458
- function renderCardsMeta() {
459
- const meta = document.querySelector('#board .region-meta')
460
- const head = document.getElementById('cards-head')
461
- const slot = document.querySelector('#board .ledger-actions')
462
- const total = state.cards.size
463
- if (meta) meta.textContent = total ? cardsMeta() : 'Nothing is queued. Leg starts the next login only when a terminal hands off.'
464
- if (head) head.textContent = total ? `${total} background task${total === 1 ? '' : 's'}` : 'No background tasks'
465
- if (!slot) return
466
- const existing = document.getElementById('cards-toggle')
467
- if (!total) { if (existing) existing.remove(); return }
468
- const label = cardsOpen ? `Hide the ${total}` : `View ${total} card${total === 1 ? '' : 's'}`
469
- if (existing) { existing.textContent = label; existing.setAttribute('aria-expanded', cardsOpen ? 'true' : 'false'); return }
470
- const btn = el('button', { type: 'button', class: 'btn btn-secondary', id: 'cards-toggle', 'aria-expanded': cardsOpen ? 'true' : 'false', 'aria-controls': 'cards-drawer' }, [label])
471
- btn.addEventListener('click', () => { cardsOpen = !cardsOpen; toggleEmptyState(); renderCardsMeta() })
472
- slot.appendChild(btn)
464
+ if (empty) empty.hidden = state.cards.size > 0
473
465
  }
474
466
 
475
467
  function rowRank(card) {
@@ -487,11 +479,102 @@
487
479
  return [...state.cards.values()].sort((a, b) => rowRank(a) - rowRank(b) || startedAt(a) - startedAt(b))
488
480
  }
489
481
 
482
+ function liveCards() { return orderedCards().filter((c) => isLive(c)) }
483
+
484
+ // C.5: finished cards, newest first, for the ledger's [View] drawer.
485
+ function finishedCards() {
486
+ return [...state.cards.values()]
487
+ .filter((c) => FINISHED_STATUSES.includes(c.status))
488
+ .sort((a, b) => (Date.parse(b.updated_at) || 0) - (Date.parse(a.updated_at) || 0))
489
+ }
490
+
491
+ // C.5: "1 running, 1 waiting on you", the same predicate as needsYou on the
492
+ // terminals region. A zero clause is omitted rather than printed as 0.
493
+ function backgroundMeta(live) {
494
+ const running = live.filter((c) => ['running', 'handing_off'].includes(c.status)).length
495
+ const waiting = live.filter((c) => ['needs_approval', 'waiting_human'].includes(c.status)).length
496
+ const parts = []
497
+ if (running) parts.push(`${running} running`)
498
+ if (waiting) parts.push(`${waiting} waiting on you`)
499
+ return parts.join(', ')
500
+ }
501
+
502
+ function renderBackgroundRegion(live) {
503
+ const section = document.getElementById('background')
504
+ if (section) section.hidden = live.length === 0
505
+ const meta = document.querySelector('#background .region-meta')
506
+ if (meta) meta.textContent = backgroundMeta(live)
507
+ }
508
+
509
+ // C.1, C.5: "3 finished cards, 2 done, 1 failed, last 11:02 PM", one line,
510
+ // never a count of the live cards (they are their own region now).
511
+ function renderFinishedLedger() {
512
+ const finished = finishedCards()
513
+ const head = document.getElementById('cards-head')
514
+ const meta = document.querySelector('#board .region-meta')
515
+ if (head) {
516
+ if (!finished.length) head.textContent = 'No finished cards'
517
+ else {
518
+ const done = finished.filter((c) => c.status === 'done').length
519
+ const failed = finished.filter((c) => c.status === 'failed').length
520
+ const killed = finished.filter((c) => c.status === 'killed').length
521
+ const parts = []
522
+ if (done) parts.push(`${done} done`)
523
+ if (failed) parts.push(`${failed} failed`)
524
+ if (killed) parts.push(`${killed} killed`)
525
+ head.textContent = `${plural(finished.length, 'finished card')}, ${parts.join(', ')}, last ${clock(finished[0].updated_at)}`
526
+ }
527
+ }
528
+ if (meta) meta.textContent = ''
529
+ const slot = document.querySelector('#board .ledger-actions')
530
+ if (slot) {
531
+ let view = document.getElementById('cards-view-toggle')
532
+ if (!finished.length) { if (view) view.remove() } else {
533
+ if (!view) {
534
+ view = el('button', { type: 'button', class: 'btn btn-secondary', id: 'cards-view-toggle', 'aria-controls': 'cards-drawer' }, ['View'])
535
+ view.addEventListener('click', () => { cardsOpen = !cardsOpen; renderFinishedLedger() })
536
+ slot.appendChild(view)
537
+ }
538
+ view.textContent = cardsOpen ? 'Hide' : 'View'
539
+ view.setAttribute('aria-expanded', cardsOpen ? 'true' : 'false')
540
+ }
541
+ }
542
+ const panel = document.getElementById('cards-drawer')
543
+ if (panel) panel.hidden = !(cardsOpen && finished.length)
544
+ renderFinishedList(finished)
545
+ }
546
+
547
+ // C.5: each drawer row is one line, never the full interactive row a live
548
+ // card gets: "done after 3 runs, landed 7f3a2c1" or "failed at station
549
+ // build after 2 runs: <last event summary>".
550
+ function finishedLine(card) {
551
+ const runs = plural(card.runs_count || 0, 'run')
552
+ const last = card.last_event ? card.last_event.summary : 'no events yet'
553
+ if (card.status === 'done') {
554
+ const landed = card.land && card.land.state === 'landed' && card.land.sha ? `landed ${String(card.land.sha).slice(0, 7)}` : last
555
+ return `done after ${runs}, ${landed}`
556
+ }
557
+ if (card.status === 'failed') return `failed at station ${card.station} after ${runs}: ${last}`
558
+ return `killed after ${runs}`
559
+ }
560
+
561
+ function renderFinishedList(finished) {
562
+ const list = document.getElementById('columns')
563
+ if (!list) return
564
+ list.textContent = ''
565
+ for (const card of finished) {
566
+ list.appendChild(el('div', { class: 'finished-line' }, [
567
+ el('span', { class: 'row-meta mono' }, [card.title || card.card_id]),
568
+ el('span', { class: 'finished-names' }, [finishedLine(card)]),
569
+ ]))
570
+ }
571
+ }
572
+
490
573
  // Re-appending every row costs one DOM move each and can move a node out from
491
574
  // under the cursor, so it happens only when the order string actually changed.
492
575
  function orderRows() {
493
- const list = document.getElementById('columns')
494
- const order = orderedCards().map((c) => c.card_id)
576
+ const list = document.getElementById('background-grid')
577
+ const order = liveCards().map((c) => c.card_id)
495
578
  const key = order.join(',')
496
579
  if (key === state.rowOrder) return
497
580
  state.rowOrder = key
@@ -502,19 +585,29 @@
502
585
  if (state.drawerId) placeDetail(state.drawerId)
503
586
  }
504
587
 
588
+ // called after anything that can change which cards are live, finished, or
589
+ // how many of each: cheap text/count work, safe to run in full every time.
590
+ function refreshCardMeta() {
591
+ const live = liveCards()
592
+ renderBackgroundRegion(live)
593
+ renderFinishedLedger()
594
+ placeEntryLine(live.length > 0)
595
+ renderEntryLine()
596
+ toggleEmptyState()
597
+ }
598
+
505
599
  function renderBoard() {
506
- const list = document.getElementById('columns')
600
+ const grid = document.getElementById('background-grid')
507
601
  // the detail region is a child of this list while a row is expanded: park it
508
602
  // back on the body so the wipe below does not take it out of the document
509
603
  document.body.appendChild(document.getElementById('drawer'))
510
- list.textContent = ''
604
+ if (grid) grid.textContent = ''
511
605
  state.cardNodes = new Map()
512
- const order = orderedCards()
513
- for (const card of order) renderRow(card)
514
- state.rowOrder = order.map((c) => c.card_id).join(',')
606
+ const live = liveCards()
607
+ for (const card of live) renderRow(card)
608
+ state.rowOrder = live.map((c) => c.card_id).join(',')
515
609
  if (state.drawerId) placeDetail(state.drawerId)
516
- renderCardsMeta()
517
- toggleEmptyState()
610
+ refreshCardMeta()
518
611
  }
519
612
 
520
613
  // the board is pushed a card for every write under its directory, log bytes
@@ -541,14 +634,22 @@
541
634
  return cache.expanded ? 200 : 8
542
635
  }
543
636
 
637
+ // a card that fell off liveness (running -> done, say) loses its row; the
638
+ // finished ledger picks it up on the next refreshCardMeta() instead
639
+ function removeLiveRow(id) {
640
+ const root = state.cardNodes.get(id)
641
+ if (root) { root.remove(); state.cardNodes.delete(id) }
642
+ state.rowOrder = ''
643
+ if (state.drawerId === id) closeDrawer()
644
+ }
645
+
544
646
  function upsertCard(card) {
545
647
  state.boardRevision += 1
546
648
  const prev = state.cards.get(card.card_id)
547
649
  const tail = staleLogTail(state.logState.get(card.card_id), card, Date.now())
548
- renderRow(card)
549
- orderRows()
550
- renderCardsMeta()
551
- toggleEmptyState()
650
+ state.cards.set(card.card_id, card)
651
+ if (isLive(card)) { renderRow(card); orderRows() } else { removeLiveRow(card.card_id) }
652
+ refreshCardMeta()
552
653
  if (tail) ensureLogLoaded(card.card_id, tail)
553
654
  if (state.drawerId === card.card_id && drawerRefreshNeeded(prev, card)) scheduleDrawerRefresh()
554
655
  }
@@ -557,12 +658,8 @@
557
658
  state.boardRevision += 1
558
659
  state.cards.delete(id)
559
660
  state.logState.delete(id)
560
- const root = state.cardNodes.get(id)
561
- if (root) { root.remove(); state.cardNodes.delete(id) }
562
- state.rowOrder = ''
563
- renderCardsMeta()
564
- toggleEmptyState()
565
- if (state.drawerId === id) closeDrawer()
661
+ removeLiveRow(id)
662
+ refreshCardMeta()
566
663
  }
567
664
 
568
665
  function onLedgerEvent(ev) {
@@ -618,16 +715,14 @@
618
715
  return { tone: 'warn', text: `waiting on you at station ${card.station} since ${clock(card.updated_at)}` }
619
716
  }
620
717
  if (card.status === 'done') return { tone: 'ok', text: `done after ${plural(card.runs_count || 0, 'run')}, ${last ? last.summary : 'no events yet'}` }
718
+ // C.3: a card runs -p --output-format json, mute until the leg exits. Once
719
+ // it has said anything real the generic branch below still carries it.
720
+ if (card.status === 'running' && last && (last.type === 'leg_started' || last.summary === 'leg started')) {
721
+ return { tone: 'muted', text: `no message until this leg ends, started ${clock(last.ts)}` }
722
+ }
621
723
  return { tone: 'muted', text: `${formatLastEvent(last)}${last ? `, ${clock(last.ts)}` : ''}` }
622
724
  }
623
725
 
624
- function shortWorktree(card) {
625
- if (!card.worktree) return ''
626
- const parts = String(card.worktree).split(/[\\/]/).filter(Boolean)
627
- let i = parts.lastIndexOf('.leg-worktrees'); if (i === -1) i = parts.lastIndexOf('.baton-worktrees');
628
- return i > 0 ? parts.slice(i - 1).join('/') : parts.slice(-2).join('/')
629
- }
630
-
631
726
  // 6.11: flat inline tokens, middot-separated by CSS, each agent name printed
632
727
  // beside its own colour so identity never rides on hue alone.
633
728
  function buildChainRail(card) {
@@ -690,11 +785,17 @@
690
785
  row.cancelBtn.focus()
691
786
  }
692
787
 
693
- function buildActions(card) {
694
- const wrap = el('div', { class: 'row-actions' })
788
+ // C.3: at most four buttons in the row's 2x2 grid, fixed order; anything
789
+ // left over moves into the expansion instead of a fifth slot.
790
+ function splitActions(card) {
695
791
  const available = card.actions || []
696
- for (const action of ACTION_ORDER) {
697
- if (!available.includes(action) || !ACTION_LABELS[action]) continue
792
+ const ordered = ACTION_ORDER.filter((a) => available.includes(a) && ACTION_LABELS[a])
793
+ return { shown: ordered.slice(0, 4), overflow: ordered.slice(4) }
794
+ }
795
+
796
+ function buildActions(card, actions) {
797
+ const wrap = el('div', { class: 'row-actions' })
798
+ for (const action of actions) {
698
799
  const label = ACTION_LABELS[action]
699
800
  const cls = `btn ${ACTION_CLASS[action] || 'btn-secondary'}`
700
801
  const run = action === 'reassign' ? () => openReassign(card, wrap) : () => runAction(card, action)
@@ -706,6 +807,82 @@
706
807
  return wrap
707
808
  }
708
809
 
810
+ // C.3: a short relative age ("6m ago"), for the work stat line only. The
811
+ // pinned time grammar in sessions.js (elapsedClock/clockAt) is unrelated:
812
+ // this is a duration since a timestamp, not an elapsed-run clock.
813
+ function agoShort(ms) {
814
+ const s = Math.max(0, Math.floor(ms / 1000))
815
+ if (s < 60) return `${s}s`
816
+ const m = Math.floor(s / 60)
817
+ if (m < 60) return `${m}m`
818
+ const h = Math.floor(m / 60)
819
+ if (h < 48) return `${h}h`
820
+ return `${Math.floor(h / 24)}d`
821
+ }
822
+
823
+ function agoSince(ts) {
824
+ const at = ts ? Date.parse(ts) : NaN
825
+ return Number.isFinite(at) ? agoShort(Date.now() - at) : 'an unknown time'
826
+ }
827
+
828
+ // C.3: the branch a live card works on is leg/<card_id> once it has a
829
+ // worktree (src/worktree.mjs branchName), else it has not started and the
830
+ // register falls back to the trunk it will branch from.
831
+ // A terminal's short id is the random tail of its session id. A card id has
832
+ // no hash on the end, only a slug, and `card-20260917-2300-seeded-live-card-3`
833
+ // ends in `3`: a bare digit identifies nothing on a board with four cards. So
834
+ // the short id is the last segment, grown leftwards until it is a token a
835
+ // reader can match back to the row (4 characters or more).
836
+ function cardShortId(id) {
837
+ const parts = String(id || '').split('-').filter(Boolean)
838
+ if (!parts.length) return ''
839
+ let out = parts[parts.length - 1]
840
+ for (let i = parts.length - 2; i >= 0 && out.length < 4; i--) out = `${parts[i]}-${out}`
841
+ return out
842
+ }
843
+
844
+ // The branch this card's checkout is really on, for a reader who is going to
845
+ // paste it into `git checkout`. A card that cut its own worktree is on
846
+ // `leg/<card-id>`; one that adopted a terminal's is on that TERMINAL's
847
+ // branch, which no reader can derive from the card id, so the server records
848
+ // it with the worktree (`worktree_branch`). Shortened for the row only when
849
+ // the full name does not fit, and the full name is on the element's title.
850
+ function cardBranch(card) {
851
+ if (card.worktree_branch) return card.worktree_branch
852
+ if (card.worktree) return `leg/${card.card_id}`
853
+ return card.trunk || 'main'
854
+ }
855
+
856
+ function cardAgent(card) { return (card.agent_model && card.agent_model.agent) || card.active_adapter || null }
857
+
858
+ function cardAgentModelText(card) {
859
+ const agent = cardAgent(card)
860
+ if (!agent) return null
861
+ const model = card.agent_model && card.agent_model.model
862
+ return model ? `${agent}/${model}` : agent
863
+ }
864
+
865
+ // C.3: "4 files, +212 -18, tests green 6m ago", built only from the parts
866
+ // the server actually measured. Nothing measured prints nothing.
867
+ function workStatLine(card) {
868
+ const parts = []
869
+ const w = card.work
870
+ if (w) {
871
+ if (Number.isFinite(w.files)) parts.push(plural(w.files, 'file'))
872
+ const ins = Number.isFinite(w.insertions) ? `+${w.insertions}` : ''
873
+ const del = Number.isFinite(w.deletions) ? `-${w.deletions}` : ''
874
+ const diff = [ins, del].filter(Boolean).join(' ')
875
+ if (diff) parts.push(diff)
876
+ }
877
+ if (card.tests) parts.push(`tests ${card.tests.state} ${agoSince(card.tests.at)} ago`)
878
+ if (card.land) {
879
+ if (card.land.state === 'bounced') parts.push(`land bounced: ${card.land.reason || 'unknown reason'}`)
880
+ else if (card.land.state === 'landed' && card.land.sha) parts.push(`landed ${String(card.land.sha).slice(0, 7)}`)
881
+ else if (card.land.state === 'failed') parts.push(`land failed${card.land.reason ? `: ${card.land.reason}` : ''}`)
882
+ }
883
+ return parts.join(', ')
884
+ }
885
+
709
886
  // the reassign picker replaces the buttons in place, like the confirm row
710
887
  async function openReassign(card, wrap) {
711
888
  if (!state.adapters) {
@@ -734,33 +911,37 @@
734
911
  wrap.appendChild(el('div', { class: 'reassign-picker' }, [adapterSelect, modeSelect, apply, cancel]))
735
912
  }
736
913
 
737
- // 5.1: R1 who, R2 what, R3 where, R4 when-act-verdict. No object type moves a
738
- // field to a different x, which is why a card row reads down the same four
739
- // columns as an account and a terminal.
914
+ // C.3: R1 register (state, station, repo/branch, agent/model), R2 title +
915
+ // sentence, R3 the work stat line (only what is measured), R4 elapsed, the
916
+ // short id, and at most four action buttons. A card row reads down the same
917
+ // four columns as a terminal (.row already carries that shape, see
918
+ // board.css's own comment on it: "the same shape as .term-row").
740
919
  function buildRow(card) {
741
920
  const said = cardSentence(card)
742
- const chain = (card.chain_view && card.chain_view.length ? card.chain_view : card.chain) || []
743
- const agent = card.active_adapter || (chain[0] ? chain[0].adapter : null)
921
+ const agentText = cardAgentModelText(card)
922
+ const agentBase = cardAgent(card)
744
923
  const title = el('button', {
745
924
  type: 'button', class: 'row-title', 'aria-expanded': state.drawerId === card.card_id ? 'true' : 'false',
746
925
  onclick: () => expandRow(card.card_id),
747
926
  }, [card.title || truncate(card.task, 60) || card.card_id])
748
927
  const sentence = el('p', { class: `sentence tone-${said.tone}` }, [said.text])
749
928
  const elapsed = el('span', { class: 'elapsed' }, [runElapsed(card)])
929
+ const statLine = workStatLine(card)
930
+ const { shown } = splitActions(card)
750
931
  const cells = [
751
932
  el('div', { class: 'r1' }, [
752
- el('span', { class: agent ? `chip chip-id-${agentClass(agent)}` : 'chip' }, [agent || 'no agent']),
753
- card.station && card.station !== '-' ? el('span', { class: 'chip' }, [card.station]) : null,
754
933
  statusWord(card),
934
+ card.station && card.station !== '-' ? el('span', { class: 'chip' }, [card.station]) : null,
935
+ el('span', { class: 'row-meta mono', title: `${card.repo_name || 'no repo'} on ${cardBranch(card)}` }, [`${card.repo_name || 'no repo'} on ${truncate(cardBranch(card), 32)}`]),
936
+ agentText ? el('span', { class: `chip chip-id-${agentClass(agentBase)}` }, [agentText]) : null,
755
937
  ]),
756
938
  el('div', { class: 'r2' }, [title, sentence]),
757
- el('div', { class: 'r3' }, [
758
- el('p', { class: 'row-meta mono' }, [`${card.repo_name || 'no repo'}@${card.trunk || 'main'}`]),
759
- card.worktree ? el('p', { class: 'row-meta mono', title: card.worktree }, [shortWorktree(card)]) : null,
760
- buildChainRail(card),
761
- buildLeases(card),
939
+ el('div', { class: 'r3' }, statLine ? [el('p', { class: 'row-meta' }, [statLine])] : []),
940
+ el('div', { class: 'r4' }, [
941
+ elapsed,
942
+ el('span', { class: 'row-meta mono', title: card.card_id }, [cardShortId(card.card_id)]),
943
+ buildActions(card, shown),
762
944
  ]),
763
- el('div', { class: 'r4' }, [elapsed, buildActions(card)]),
764
945
  ]
765
946
  return { cells, slots: { title, sentence, elapsed } }
766
947
  }
@@ -771,7 +952,7 @@
771
952
  if (!root) {
772
953
  root = el('article', { class: 'row', 'data-card-id': card.card_id })
773
954
  state.cardNodes.set(card.card_id, root)
774
- document.getElementById('columns').appendChild(root)
955
+ document.getElementById('background-grid').appendChild(root)
775
956
  state.rowOrder = ''
776
957
  } else {
777
958
  while (root.firstChild) root.removeChild(root.firstChild)
@@ -854,6 +1035,27 @@
854
1035
  return [el('div', { class: 'kv' }, rows), copy]
855
1036
  }
856
1037
 
1038
+ // C.4: card -> terminal. Take over pauses the card and hands back one
1039
+ // copyable command; a terminal cannot be opened from a browser tab, so the
1040
+ // board says that plainly rather than pretending it can.
1041
+ function buildTakeOver(card) {
1042
+ const box = el('div', { class: 'kv' })
1043
+ const btn = el('button', { type: 'button', class: 'btn btn-secondary', onclick: async () => {
1044
+ try {
1045
+ const data = await api(`/api/cards/${encodeURIComponent(card.card_id)}/take-over`, { method: 'POST', body: {} })
1046
+ box.textContent = ''
1047
+ box.appendChild(el('p', { class: 'sentence tone-muted' }, ['A terminal cannot be opened from a browser tab, so this is the one command Leg hands you.']))
1048
+ const row = el('div', { class: 'reassign-picker' }, [
1049
+ el('span', { class: 'row-meta mono' }, [data.command]),
1050
+ el('button', { type: 'button', class: 'btn btn-secondary', onclick: () => copyToClipboard(data.command) }, ['Copy']),
1051
+ ])
1052
+ box.appendChild(row)
1053
+ } catch (err) { toast(err.message) }
1054
+ } }, ['Take over'])
1055
+ box.appendChild(btn)
1056
+ return detailSection('Take over', 'pauses the card and starts an interactive terminal from its bundle', box)
1057
+ }
1058
+
857
1059
  function eventRow(e) {
858
1060
  return el('div', { class: 'turn' }, [
859
1061
  el('span', { class: 'turn-when' }, [clock(e.ts)]),
@@ -901,6 +1103,17 @@
901
1103
  for (const st of stations) pipeline.push(...kvRow(st.name, `${st.kind}${st.name === card.station ? ', this station' : ''}`))
902
1104
  content.appendChild(detailSection('Pipeline', at ? `station ${at} of ${stations.length}` : `${plural(stations.length, 'station')}, none started`, el('div', { class: 'kv' }, pipeline)))
903
1105
 
1106
+ // C.3: the chain rail, the leases and the pipeline above all moved out of
1107
+ // the row and into this expansion; the row itself carries only the model
1108
+ // token and the station name now.
1109
+ content.appendChild(detailSection('Chain', null, buildChainRail(card)))
1110
+ content.appendChild(detailSection('Leases', null, buildLeases(card)))
1111
+
1112
+ const { overflow } = splitActions(card)
1113
+ if (overflow.length) content.appendChild(detailSection('More actions', 'past the four on the row', buildActions(card, overflow)))
1114
+
1115
+ content.appendChild(buildTakeOver(card))
1116
+
904
1117
  const runs = detail.runs || []
905
1118
  const runRows = []
906
1119
  for (const r of runs) runRows.push(...kvRow(`run ${r.run}`, `${r.adapter}, ${r.outcome ?? r.status}, signal ${r.signal ?? 'none'}, exit ${r.exit_code ?? 'none'}`))
@@ -1012,22 +1225,71 @@
1012
1225
  if (root && root.slots) root.slots.title.focus()
1013
1226
  }
1014
1227
 
1228
+ // ---- 6.15 step 6: the one-line background entry (C.2) ----
1229
+ // src/board/entry.js OWNS THE ROW. It is on this page and on /floor, and one
1230
+ // sentence that starts work has to post one body from both, so the row lives
1231
+ // in a file both pages load and this file keeps only the names its own code
1232
+ // and its test seams call. `state` is handed over live: the row reads
1233
+ // sessions, cards, preferences, adapters and models on every render, never a
1234
+ // copy taken at mount.
1235
+ const entryUi = window.legEntry.create({
1236
+ el,
1237
+ api,
1238
+ toast,
1239
+ host: state,
1240
+ boxId: 'card-entry',
1241
+ isGuest,
1242
+ onCreated: (card) => upsertCard(card),
1243
+ // this page has the dialog markup, so More settings opens it in place
1244
+ onMoreSettings: () => openNewCardDialog(),
1245
+ // C.2: the row sits under the Background panel, and under Terminals when
1246
+ // there are no live cards to sit under
1247
+ moveUnder: { whenLive: 'background', whenEmpty: '.region-terminals' },
1248
+ })
1249
+ const entryState = entryUi.entryState
1250
+ const knownRepos = () => entryUi.knownRepos()
1251
+ const entryRepo = () => entryUi.entryRepo()
1252
+ const realAdapters = () => entryUi.realAdapters()
1253
+ const ladderAgents = () => entryUi.ladderAgents()
1254
+ const asRung = (agent, model) => entryUi.asRung(agent, model)
1255
+ const ladderLabel = (r) => entryUi.ladderLabel(r)
1256
+ const modelSelect = (agent, model, label, onPick) => entryUi.modelSelect(agent, model, label, onPick)
1257
+ const entryChain = () => entryUi.entryChain()
1258
+ const entryTrunk = (repo) => entryUi.entryTrunk(repo)
1259
+ const renderEntryLine = () => entryUi.renderEntryLine()
1260
+ const placeEntryLine = (hasLive) => entryUi.placeEntryLine(hasLive)
1261
+
1262
+ // sessions.js draws the ladder editor in Settings and needs the same model
1263
+ // catalog, but the two files share no module scope (both are plain scripts
1264
+ // served to the browser), so the one fetch is published here and read there.
1265
+ // Narrow on purpose: the catalog and nothing else of this file's state.
1266
+ function publishModels() {
1267
+ if (typeof window === 'undefined') return
1268
+ window.legBoard = { models: state.models }
1269
+ }
1270
+
1015
1271
  // ---- new card dialog ----
1272
+ // Two questions, side by side: WHAT the work is (task, repo, branch) and WHO
1273
+ // runs it. "Who runs it" is the same ladder the one-line entry row walks,
1274
+ // one row per rung and a model select on each, prefilled from preferences so
1275
+ // the dialog opens showing exactly what pressing Start on that row would
1276
+ // have done. Every other field the dialog ever had is under Advanced, and
1277
+ // every one of them still posts.
1016
1278
  function newCardDialogEls() {
1017
1279
  return {
1018
1280
  dialog: document.getElementById('new-card-dialog'),
1019
1281
  form: document.getElementById('new-card-form'),
1020
1282
  error: document.getElementById('new-card-error'),
1021
1283
  repo: document.getElementById('nc-repo'),
1284
+ repoKnown: document.getElementById('nc-repo-known'),
1022
1285
  task: document.getElementById('nc-task'),
1023
- firstAgent: document.getElementById('nc-first-agent'),
1024
- firstControls: document.getElementById('nc-first-controls'),
1025
1286
  testAdapter: document.getElementById('nc-test-adapter'),
1026
1287
  fallbackSummary: document.getElementById('nc-fallback-summary'),
1027
1288
  pipeline: document.getElementById('nc-pipeline'),
1028
1289
  customPipeline: document.getElementById('nc-custom-pipeline'),
1029
1290
  chainRows: document.getElementById('nc-chain-rows'),
1030
1291
  addRowBtn: document.getElementById('nc-add-row'),
1292
+ saveLadder: document.getElementById('nc-save-ladder'),
1031
1293
  leases: document.getElementById('nc-leases'),
1032
1294
  trunk: document.getElementById('nc-trunk'),
1033
1295
  landMode: document.getElementById('nc-land-mode'),
@@ -1040,139 +1302,266 @@
1040
1302
 
1041
1303
  function adapterLabel(adapter) { return adapter.fake ? `${adapter.name} (test/demo)` : adapter.name }
1042
1304
 
1043
- // the agents already named in this dialog: the first agent and every fallback
1044
- // row already added. A fallback set to one of these can never fire.
1045
- function chosenAdapters(ui) {
1046
- const used = [ui.testAdapter.value || ui.firstAgent.value]
1047
- for (const row of ui.chainRows.children) if (row.fields) used.push(row.fields.adapterSelect.value)
1048
- return used.filter(Boolean)
1049
- }
1050
-
1051
- function addChainRow(ui, { adapter: preferred = null, first = false } = {}) {
1052
- const adapterSelect = el('select', { 'aria-label': 'Chain adapter' })
1053
- const adapters = [...(state.adapters || [])].sort((a, b) => Number(a.fake) - Number(b.fake))
1054
- for (const a of adapters) adapterSelect.appendChild(el('option', { value: a.name }, [adapterLabel(a)]))
1055
- // Add fallback agent used to default to the first option in the list, which
1056
- // is normally the agent already chosen as First agent: the summary line then
1057
- // read "Leg tries claude, then agy, then claude", a fallback that cannot
1058
- // fire. rebuildDefaultFallbacks already applies this filter.
1059
- if (!preferred && !first) {
1060
- const used = chosenAdapters(ui)
1061
- preferred = adapters.filter((a) => !a.fake).map((a) => a.name).find((name) => !used.includes(name)) || null
1062
- }
1063
- if (preferred && adapters.some((a) => a.name === preferred)) adapterSelect.value = preferred
1064
- const modeSelect = el('select', { 'aria-label': 'Chain mode' })
1065
- const approveCheckbox = el('input', { type: 'checkbox', 'aria-label': 'Approve before this leg' })
1066
- const approveLabel = el('label', {}, [approveCheckbox, ' approval before start'])
1067
- const turnsInput = el('input', { type: 'number', min: '0', 'aria-label': 'Max turns', placeholder: 'max turns' })
1068
- const fakeInput = el('input', { type: 'text', 'aria-label': 'Scripted test behavior', placeholder: 'test behavior' })
1069
- const removeBtn = first ? null : el('button', { type: 'button', class: 'btn btn-danger', 'aria-label': 'Remove fallback agent' }, ['Remove'])
1070
- const title = el('span', { class: 'fallback-row-title' }, [first ? `First: ${preferred}` : `Fallback ${ui.chainRows.children.length + 1}`])
1071
- const row = el('div', { class: `chain-row${first ? '' : ' fallback-row'}` }, [title, adapterSelect, modeSelect, approveLabel, turnsInput, fakeInput, removeBtn])
1072
- if (first) adapterSelect.hidden = true
1073
- if (removeBtn) removeBtn.addEventListener('click', () => { row.remove(); refreshFallbackSummary(ui) })
1305
+ // The rows as DATA. The DOM used to be the record: a row's values were read
1306
+ // back off its own inputs, which works until rows can move, because moving a
1307
+ // row means rebuilding it and a rebuilt input is empty. Reorder, remove and
1308
+ // renumber are all list operations here, and the DOM is redrawn from the list.
1309
+ let ncRows = []
1074
1310
 
1075
- function populateModes() {
1076
- modeSelect.textContent = ''
1077
- const adapter = (state.adapters || []).find((a) => a.name === adapterSelect.value)
1078
- const allowed = adapter ? adapter.modes.allowed : []
1079
- for (const m of allowed) modeSelect.appendChild(el('option', { value: m }, [MODE_LABELS[m] ? `${MODE_LABELS[m]} (${m})` : m]))
1080
- if (adapter && adapter.modes.default) modeSelect.value = adapter.modes.default
1081
- fakeInput.hidden = !(adapter && adapter.fake)
1082
- }
1083
- adapterSelect.addEventListener('change', populateModes)
1084
- // the summary sentence names the fallback agents by row, so it has to
1085
- // follow a row whose agent the reader changed after it was added
1086
- if (!first) adapterSelect.addEventListener('change', () => refreshFallbackSummary(ui))
1087
- populateModes()
1311
+ function ncAdapter(name) { return (state.adapters || []).find((a) => a.name === name) || null }
1088
1312
 
1089
- row.fields = { adapterSelect, modeSelect, approveCheckbox, turnsInput, fakeInput }
1090
- ;(first ? ui.firstControls : ui.chainRows).appendChild(row)
1091
- refreshFallbackSummary(ui)
1092
- return row
1313
+ function ncRow(agent, model) {
1314
+ const adapter = ncAdapter(agent)
1315
+ return {
1316
+ agent: adapter ? adapter.name : agent,
1317
+ model: model || '',
1318
+ mode: adapter && adapter.modes ? (adapter.modes.default || '') : '',
1319
+ approve: false, turns: '', fake: '',
1320
+ }
1093
1321
  }
1094
1322
 
1095
- function refreshFallbackSummary(ui) {
1096
- const names = [...ui.chainRows.children].filter((row) => row.fields).map((row) => row.fields.adapterSelect.value)
1097
- ui.fallbackSummary.textContent = names.length
1098
- ? `If the first agent cannot continue, Leg tries ${names.join(', then ')} in this order.`
1099
- : 'No fallback agent is set. Add one under Advanced options if another agent should take over.'
1323
+ // the first installed agent no row already names: a fallback that repeats the
1324
+ // row above it can never fire
1325
+ function ncNextAgent() {
1326
+ const used = ncRows.map((r) => r.agent)
1327
+ const real = realAdapters()
1328
+ return real.find((a) => !used.includes(a)) || real[0] || ((state.adapters || [])[0] || {}).name || 'claude'
1100
1329
  }
1101
1330
 
1102
- function rebuildFirstControls(ui) {
1103
- ui.firstControls.textContent = ''
1104
- addChainRow(ui, { adapter: ui.testAdapter.value || ui.firstAgent.value, first: true })
1105
- }
1331
+ const ncLeg = (r) => (r.model ? `${r.agent}/${r.model}` : r.agent)
1106
1332
 
1107
- function rebuildDefaultFallbacks(ui) {
1333
+ function refreshFallbackSummary(ui) {
1334
+ const legs = ncRows.map(ncLeg)
1335
+ ui.fallbackSummary.textContent = legs.length > 1
1336
+ ? `Leg starts on ${legs[0]}, and tries ${legs.slice(1).join(', then ')} only when the row before it cannot continue.`
1337
+ : legs.length === 1
1338
+ ? `Leg runs ${legs[0]} and stops there. Add a fallback to hand the work on when it cannot continue.`
1339
+ : 'No agent is set. Add a row, or this card has nothing to run it.'
1340
+ }
1341
+
1342
+ // `focusKey` is the control the reader should still be on after the redraw.
1343
+ // Every row is destroyed and rebuilt here, so the button a keyboard user just
1344
+ // pressed Enter on is gone and focus falls to <body>: pressing Up twice meant
1345
+ // tabbing back through every control above it. Same idea as sessions.js's
1346
+ // takeFocus/putFocus pair (data-focus-key), but the intent is passed in rather
1347
+ // than read off document.activeElement, because the moved row's key is known
1348
+ // at the press and the row it lands on is a different index.
1349
+ function renderChainRows(ui, focusKey) {
1108
1350
  ui.chainRows.textContent = ''
1109
- const first = ui.firstAgent.value
1110
- const real = (state.adapters || []).filter((a) => !a.fake).map((a) => a.name)
1111
- const preferred = AGENT_IDS.filter((name) => real.includes(name) && name !== first)
1112
- for (const adapter of preferred) addChainRow(ui, { adapter })
1351
+ const keyed = new Map()
1352
+ ncRows.forEach((row, index) => {
1353
+ // every control on the row is named for the step it belongs to, so a
1354
+ // screen reader hears "Model for fallback 2" and not "Chain adapter"
1355
+ const step = index === 0 ? 'the first agent' : `fallback ${index}`
1356
+ const adapter = ncAdapter(row.agent)
1357
+ const box = el('div', { class: 'chain-row' })
1358
+ box.appendChild(el('span', { class: 'chain-num' }, [`${index + 1}.`]))
1359
+
1360
+ const who = el('select', { 'aria-label': `Provider for ${step}` })
1361
+ for (const a of [...(state.adapters || [])].sort((x, y) => Number(x.fake) - Number(y.fake))) who.appendChild(el('option', { value: a.name }, [adapterLabel(a)]))
1362
+ who.value = row.agent
1363
+ who.addEventListener('change', () => {
1364
+ row.agent = who.value
1365
+ // a model belongs to one provider: carrying gpt-5.6-luna over to claude
1366
+ // would post a model that CLI has never heard of
1367
+ row.model = ''
1368
+ const next = ncAdapter(row.agent)
1369
+ row.mode = next && next.modes ? (next.modes.default || '') : ''
1370
+ renderChainRows(ui)
1371
+ })
1372
+ box.appendChild(who)
1373
+
1374
+ box.appendChild(modelSelect(row.agent, row.model, `Model for ${step}`, (v) => { row.model = v; refreshFallbackSummary(ui) }))
1375
+
1376
+ const mode = el('select', { 'aria-label': `Permissions for ${step}` })
1377
+ for (const m of (adapter && adapter.modes ? adapter.modes.allowed : [])) mode.appendChild(el('option', { value: m }, [MODE_LABELS[m] ? `${MODE_LABELS[m]} (${m})` : m]))
1378
+ mode.value = row.mode || (adapter && adapter.modes ? adapter.modes.default : '')
1379
+ mode.addEventListener('change', () => { row.mode = mode.value })
1380
+ box.appendChild(mode)
1381
+
1382
+ const approve = el('input', { type: 'checkbox', 'aria-label': `Ask before ${step} starts` })
1383
+ approve.checked = row.approve
1384
+ approve.addEventListener('change', () => { row.approve = approve.checked })
1385
+ box.appendChild(el('label', { class: 'chain-toggle' }, [approve, ' ask before start']))
1386
+
1387
+ const turns = el('input', { type: 'number', min: '1', class: 'chain-turns', 'aria-label': `Max turns for ${step}`, placeholder: 'max turns', value: row.turns })
1388
+ turns.addEventListener('input', () => { row.turns = turns.value })
1389
+ box.appendChild(turns)
1390
+
1391
+ if (adapter && adapter.fake) {
1392
+ const fake = el('input', { type: 'text', class: 'chain-fake', 'aria-label': `Scripted behaviour for ${step}`, placeholder: 'test behavior', value: row.fake })
1393
+ fake.addEventListener('input', () => { row.fake = fake.value })
1394
+ box.appendChild(fake)
1395
+ }
1396
+
1397
+ const up = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-label': `Move ${step} earlier`, 'data-focus-key': `chain:${index}:up`, disabled: index === 0 ? '' : null }, ['Up'])
1398
+ const down = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-label': `Move ${step} later`, 'data-focus-key': `chain:${index}:down`, disabled: index === ncRows.length - 1 ? '' : null }, ['Down'])
1399
+ const drop = el('button', { type: 'button', class: 'btn btn-danger', 'aria-label': `Remove ${step}`, 'data-focus-key': `chain:${index}:remove`, disabled: ncRows.length < 2 ? '' : null }, ['Remove'])
1400
+ // the key names where the row LANDS, not where it was pressed
1401
+ up.addEventListener('click', () => { ncRows.splice(index - 1, 0, ncRows.splice(index, 1)[0]); renderChainRows(ui, `chain:${index - 1}:up`) })
1402
+ down.addEventListener('click', () => { ncRows.splice(index + 1, 0, ncRows.splice(index, 1)[0]); renderChainRows(ui, `chain:${index + 1}:down`) })
1403
+ drop.addEventListener('click', () => { ncRows.splice(index, 1); renderChainRows(ui, `chain:${Math.min(index, ncRows.length - 1)}:remove`) })
1404
+ box.append(up, down, drop)
1405
+ keyed.set(`chain:${index}:up`, up)
1406
+ keyed.set(`chain:${index}:down`, down)
1407
+ keyed.set(`chain:${index}:remove`, drop)
1408
+
1409
+ ui.chainRows.appendChild(box)
1410
+ })
1113
1411
  refreshFallbackSummary(ui)
1412
+ if (focusKey) restoreChainFocus(ui, keyed, focusKey)
1413
+ }
1414
+
1415
+ // A row moved to either end loses the button that moved it, and the last row
1416
+ // standing cannot be removed, so the focus goes to the nearest live control on
1417
+ // that row and, when the row itself is gone, to Add a fallback.
1418
+ function restoreChainFocus(ui, keyed, focusKey) {
1419
+ const at = focusKey.split(':')[1]
1420
+ let target = keyed.get(focusKey) || null
1421
+ if (!target || target.disabled) {
1422
+ target = [`chain:${at}:up`, `chain:${at}:down`, `chain:${at}:remove`]
1423
+ .map((k) => keyed.get(k))
1424
+ .find((node) => node && !node.disabled) || ui.addRowBtn || null
1425
+ }
1426
+ if (target && typeof target.focus === 'function') target.focus({ preventScroll: true })
1427
+ }
1428
+
1429
+ // "Save as my default ladder": the rows become preferences.handoff_ladder,
1430
+ // which the entry row and every new terminal read. A scripted test adapter is
1431
+ // never a rung and the server refuses one, so it is dropped here with a
1432
+ // sentence rather than sent and refused; two rows naming the same agent and
1433
+ // model are one rung, for the same reason.
1434
+ async function saveLadderFromRows(rows) {
1435
+ const real = realAdapters()
1436
+ // and of those, the agents a SAVED ladder may name. A custom adapter added
1437
+ // with `leg adapter add` is a real agent and runs a card, but preferences
1438
+ // takes a closed list and refuses the whole array over one rung it does not
1439
+ // know, so the claude rung beside a custom one was never written either.
1440
+ const saveable = ladderAgents()
1441
+ const dropped = []
1442
+ const seen = new Set()
1443
+ const ladder = []
1444
+ for (const r of rows) {
1445
+ if (!real.includes(r.agent)) continue
1446
+ if (!saveable.includes(r.agent)) { if (!dropped.includes(r.agent)) dropped.push(r.agent); continue }
1447
+ const key = `${r.agent}/${r.model || ''}`
1448
+ if (seen.has(key)) continue
1449
+ seen.add(key)
1450
+ ladder.push(asRung(r.agent, r.model || null))
1451
+ }
1452
+ const left = dropped.length ? ` ${dropped.join(', ')} ${dropped.length > 1 ? 'were' : 'was'} left off: the default ladder keeps only the agents Settings can express (${saveable.join(', ')}).` : ''
1453
+ if (!ladder.length) {
1454
+ toast(dropped.length
1455
+ ? `The default ladder was left alone: it would keep no rung at all.${left}`
1456
+ : 'The default ladder was left alone: a scripted test agent cannot be a rung.')
1457
+ return
1458
+ }
1459
+ try {
1460
+ const data = await api('/api/settings', { method: 'PATCH', body: { handoff_ladder: ladder } })
1461
+ state.preferences = data.preferences || state.preferences
1462
+ entryState.ladderStart = 0
1463
+ entryState.model = undefined
1464
+ renderEntryLine()
1465
+ toast(`Saved as your default ladder: ${ladder.map(ladderLabel).join(' then ')}.${left}`)
1466
+ } catch (err) {
1467
+ toast(`The card was created. The default ladder was not saved: ${err.message}`)
1468
+ }
1114
1469
  }
1115
1470
 
1116
1471
  async function openNewCardDialog() {
1117
1472
  if (!state.adapters) {
1118
1473
  try { state.adapters = (await api('/api/adapters')).adapters } catch (err) { toast(err.message); return }
1119
1474
  }
1475
+ // neither of these stops the dialog opening: without a catalog every model
1476
+ // select offers the provider default, and without preferences the rows fall
1477
+ // back to the agents that are installed
1478
+ if (!state.models) { try { state.models = (await api('/api/models')).models; publishModels() } catch { /* provider default only */ } }
1479
+ if (!state.preferences) { try { state.preferences = (await api('/api/settings')).preferences || null } catch { /* installed adapters only */ } }
1480
+
1120
1481
  const ui = newCardDialogEls()
1121
1482
  ui.form.reset()
1122
1483
  ui.error.hidden = true
1123
1484
  ui.error.textContent = ''
1485
+ // More settings is the same sentence with more fields, so the sentence
1486
+ // comes with it: on this page from the row above, and from /floor through
1487
+ // the #new-card hash, which is how that page reaches this dialog without a
1488
+ // second copy of its markup.
1489
+ if (entryState.task && entryState.task.trim()) ui.task.value = entryState.task
1490
+ // the workflow is one of the row's three nouns and it went the same way the
1491
+ // task does. form.reset() above puts the select back to the option marked
1492
+ // selected in the markup (build), so this has to run after it.
1493
+ if (entryState.pipeline) ui.pipeline.value = entryState.pipeline
1124
1494
  ui.customPipeline.hidden = ui.pipeline.value !== 'custom'
1125
- const real = (state.adapters || []).filter((a) => !a.fake)
1126
- ui.firstAgent.textContent = ''
1127
- for (const adapter of real) ui.firstAgent.appendChild(el('option', { value: adapter.name }, [adapter.name]))
1128
- if (real.some((a) => a.name === 'claude')) ui.firstAgent.value = 'claude'
1495
+
1496
+ // the repo and branch the entry row would have used, and every repo this
1497
+ // board has seen, so the commonest case is already filled in
1498
+ const repo = entryRepo()
1499
+ ui.repoKnown.textContent = ''
1500
+ for (const r of knownRepos()) ui.repoKnown.appendChild(el('option', { value: r.path }, [r.name]))
1501
+ ui.repoKnown.appendChild(el('option', { value: '' }, ['Another path, typed below']))
1502
+ ui.repoKnown.value = repo ? repo.path : ''
1503
+ ui.repo.value = repo ? repo.path : ''
1504
+ ui.trunk.value = entryTrunk(repo) || 'main'
1505
+
1129
1506
  ui.testAdapter.textContent = ''
1130
- ui.testAdapter.appendChild(el('option', { value: '' }, ['Use the real first agent above']))
1507
+ ui.testAdapter.appendChild(el('option', { value: '' }, ['Use the real first row above']))
1131
1508
  for (const adapter of (state.adapters || []).filter((a) => a.fake)) ui.testAdapter.appendChild(el('option', { value: adapter.name }, [adapterLabel(adapter)]))
1132
- ui.firstControls.textContent = ''
1133
- ui.chainRows.textContent = ''
1134
- rebuildFirstControls(ui)
1135
- rebuildDefaultFallbacks(ui)
1509
+
1510
+ const rungs = entryChain()
1511
+ ncRows = rungs.length ? rungs.map((r) => ncRow(r.agent, r.model || '')) : []
1512
+ if (!ncRows.length && (state.adapters || []).length) ncRows = [ncRow(ncNextAgent(), '')]
1513
+ ui.saveLadder.checked = false
1514
+ renderChainRows(ui)
1136
1515
  ui.dialog.showModal()
1137
1516
  }
1138
1517
 
1139
1518
  async function submitNewCard(e) {
1140
1519
  e.preventDefault()
1141
1520
  const ui = newCardDialogEls()
1142
- const rows = [...ui.firstControls.children, ...ui.chainRows.children]
1143
- .filter((row) => row.fields)
1144
- .map((row) => ({
1145
- adapter: row.fields.adapterSelect.value,
1146
- mode: row.fields.modeSelect.value,
1147
- approve: row.fields.approveCheckbox.checked,
1148
- turns: row.fields.turnsInput.value.trim(),
1149
- fake: row.fields.fakeInput.hidden ? '' : row.fields.fakeInput.value.trim(),
1150
- }))
1151
- .filter((r) => r.adapter)
1521
+ const fail = (msg) => { ui.error.hidden = false; ui.error.textContent = msg }
1522
+ const rows = ncRows.filter((r) => r.agent)
1523
+ if (!rows.length) return fail('Add at least one row under Who runs it: a card needs an agent to run it.')
1524
+ if (!ui.repo.value.trim()) return fail('Name the repository this card works in.')
1525
+ // One object per row, not a comma list plus four adapter-keyed strings.
1526
+ // The keyed form could only ever carry one mode, one turn limit and one
1527
+ // model PER ADAPTER, so a chain of claude/fable then claude/opus lost the
1528
+ // difference between its own two rows. src/pipeline.mjs normalizeChainEntry
1529
+ // takes every one of these fields per entry.
1530
+ const chain = rows.map((r) => ({
1531
+ adapter: r.agent,
1532
+ ...(r.model ? { model: r.model } : {}),
1533
+ ...(r.mode ? { mode: r.mode } : {}),
1534
+ ...(String(r.turns).trim() ? { maxTurns: Number(String(r.turns).trim()) } : {}),
1535
+ ...(r.approve ? { approve: true } : {}),
1536
+ ...(String(r.fake).trim() ? { fakeMode: String(r.fake).trim() } : {}),
1537
+ }))
1152
1538
 
1153
1539
  const body = {
1154
1540
  repo: ui.repo.value.trim(),
1155
1541
  task: ui.task.value.trim(),
1156
- chain: rows.map((r) => r.adapter).join(','),
1542
+ chain,
1157
1543
  pipeline: ui.pipeline.value === 'custom' ? ui.customPipeline.value.trim() : ui.pipeline.value,
1158
1544
  leases: ui.leases.value.trim(),
1159
1545
  trunk: ui.trunk.value.trim() || 'main',
1160
1546
  land_mode: ui.landMode.value,
1161
1547
  test_command: ui.testCommand.value.trim(),
1162
1548
  title: ui.title.value.trim(),
1163
- mode: rows.filter((r) => r.mode).map((r) => `${r.adapter}=${r.mode}`).join(','),
1164
- approve: rows.filter((r) => r.approve).map((r) => r.adapter).join(','),
1165
- maxTurns: rows.filter((r) => r.turns).map((r) => `${r.adapter}=${r.turns}`).join(','),
1166
- fake_mode: rows.filter((r) => r.fake).map((r) => `${r.adapter}=${r.fake}`).join(','),
1167
1549
  queue: ui.queue.checked,
1168
1550
  }
1169
1551
  try {
1170
1552
  const data = await api('/api/cards', { method: 'POST', body })
1553
+ // the card exists now: a ladder that will not save is a toast, never a
1554
+ // reason to leave the dialog open over a card that was already created
1555
+ if (ui.saveLadder.checked) await saveLadderFromRows(rows)
1171
1556
  ui.dialog.close()
1557
+ // the sentence was sent, so the row that carried it here is spent: a row
1558
+ // left armed makes the next Start post the same card a second time.
1559
+ // upsertCard redraws the row, so there is no render call to add.
1560
+ entryState.task = ''
1561
+ entryState.editing = null
1172
1562
  upsertCard(data.card)
1173
1563
  } catch (err) {
1174
- ui.error.hidden = false
1175
- ui.error.textContent = err.message
1564
+ fail(err.message)
1176
1565
  }
1177
1566
  }
1178
1567
 
@@ -1181,9 +1570,24 @@
1181
1570
  ui.form.addEventListener('submit', submitNewCard)
1182
1571
  ui.cancel.addEventListener('click', () => ui.dialog.close())
1183
1572
  ui.pipeline.addEventListener('change', () => { ui.customPipeline.hidden = ui.pipeline.value !== 'custom' })
1184
- ui.firstAgent.addEventListener('change', () => { ui.testAdapter.value = ''; rebuildFirstControls(ui); rebuildDefaultFallbacks(ui) })
1185
- ui.testAdapter.addEventListener('change', () => rebuildFirstControls(ui))
1186
- ui.addRowBtn.addEventListener('click', () => addChainRow(ui))
1573
+ // the picker fills the path field rather than replacing it: the path is
1574
+ // what gets posted, and a reader who wants a repo the board has never seen
1575
+ // types it in the same box
1576
+ ui.repoKnown.addEventListener('change', () => {
1577
+ if (!ui.repoKnown.value) { ui.repo.focus(); return }
1578
+ ui.repo.value = ui.repoKnown.value
1579
+ const known = knownRepos().find((r) => r.path === ui.repoKnown.value) || { path: ui.repoKnown.value, name: ui.repoKnown.value }
1580
+ ui.trunk.value = entryTrunk(known) || 'main'
1581
+ })
1582
+ // unchanged meaning: the scripted adapter replaces the agent on the first
1583
+ // row, and clearing it puts the first real agent back
1584
+ ui.testAdapter.addEventListener('change', () => {
1585
+ if (!ncRows.length) ncRows = [ncRow(ncNextAgent(), '')]
1586
+ const real = realAdapters()
1587
+ ncRows[0] = ncRow(ui.testAdapter.value || real[0] || ncRows[0].agent, '')
1588
+ renderChainRows(ui)
1589
+ })
1590
+ ui.addRowBtn.addEventListener('click', () => { ncRows.push(ncRow(ncNextAgent(), '')); renderChainRows(ui) })
1187
1591
  }
1188
1592
 
1189
1593
  // ---- 6.10 settings: the last region of the page, in flow ----
@@ -1235,6 +1639,15 @@
1235
1639
  if (meta) meta.textContent = panel.meta
1236
1640
  }
1237
1641
 
1642
+ // The process behind the page is older or newer than the page itself. Said
1643
+ // once, as an error that stays until dismissed, because everything the reader
1644
+ // sees from here on is drawn by files the process does not know about.
1645
+ function versionSkew(processVersion) {
1646
+ if (!processVersion || processVersion === FILES_VERSION) return false
1647
+ toast(`This board process runs leg ${processVersion} and the page files are ${FILES_VERSION}. Restart it to match: leg down && leg up`)
1648
+ return true
1649
+ }
1650
+
1238
1651
  function renderBoardFacts(health) {
1239
1652
  const box = document.querySelector('.region-settings .board-facts')
1240
1653
  if (!box) return
@@ -1242,8 +1655,9 @@
1242
1655
  const you = health.you || {}
1243
1656
  const share = you.share || { on: false, people: 0 }
1244
1657
  const sched = health.scheduler
1658
+ const skew = health.version && health.version !== FILES_VERSION ? `, page files ${FILES_VERSION}` : ''
1245
1659
  const lines = [
1246
- `leg ${health.version}, bound to ${state.bind}`,
1660
+ `leg ${health.version}${skew}, bound to ${state.bind}`,
1247
1661
  `signed in as ${you.name || 'local'}, ${you.role || 'owner'}`,
1248
1662
  share.on ? `share on, ${share.people === 1 ? '1 person' : `${share.people} people`}` : 'share off, nobody invited',
1249
1663
  ]
@@ -1292,6 +1706,39 @@
1292
1706
  renderTokenMeta()
1293
1707
  }
1294
1708
 
1709
+ // ---- what /floor sends over in the address bar ----
1710
+ // The floor starts cards from its own copy of the entry row, but the New card
1711
+ // dialog's markup exists once, on this page, so the floor's More settings and
1712
+ // its card titles are links here. `#new-card` opens the dialog, `#new-card=<task>`
1713
+ // opens it with the sentence the reader had already typed, and `#card=<id>`
1714
+ // expands that card's detail region. The hash is cleared once it is acted on:
1715
+ // a reload should not reopen a dialog the reader closed.
1716
+ async function openFromHash() {
1717
+ const hash = decodeURIComponent(String((location && location.hash) || '').replace(/^#/, ''))
1718
+ if (!hash) return
1719
+ const clear = () => { try { history.replaceState(null, '', location.pathname + location.search) } catch { /* a browser that refuses is still on the right page */ } }
1720
+ if (hash.startsWith('new-card')) {
1721
+ // `#new-card=<task>&pipeline=<p>` or `#new-card?pipeline=<p>`: the floor's
1722
+ // entry row sends both, and the dialog opens on what the reader chose
1723
+ const rest = hash.slice('new-card'.length)
1724
+ const m = rest.match(/[&?]pipeline=([a-z_-]+)$/)
1725
+ const task = rest.replace(/[&?]pipeline=[a-z_-]+$/, '').replace(/^=/, '')
1726
+ if (task) entryState.task = task
1727
+ // an unknown word is harmless: the dialog's select ignores a value it has no option for
1728
+ if (m) entryState.pipeline = m[1]
1729
+ if (task || m) renderEntryLine()
1730
+ clear()
1731
+ await openNewCardDialog()
1732
+ return
1733
+ }
1734
+ if (hash.startsWith('card=')) {
1735
+ const id = hash.slice('card='.length)
1736
+ clear()
1737
+ if (!state.cards.has(id)) await fetchCards()
1738
+ if (state.cards.has(id)) expandRow(id)
1739
+ }
1740
+ }
1741
+
1295
1742
  // ---- init ----
1296
1743
  async function init() {
1297
1744
  initSettings()
@@ -1305,10 +1752,27 @@
1305
1752
  if (state.drawerId) collapseRow()
1306
1753
  })
1307
1754
  // health first: it says whether this human owns the pipeline side at all
1308
- await loadHealth()
1755
+ const owner = await loadHealth()
1309
1756
  fetchCards()
1310
1757
  connectSse()
1311
1758
  setInterval(tickElapsed, 1000)
1759
+ // C.2: the ladder sentence on the entry line reads this; a guest never
1760
+ // sees the entry line, so there is nothing to fetch it for
1761
+ if (owner) {
1762
+ // three independent reads, so they go out together: the ladder the entry
1763
+ // line names, the adapters that are actually installed (the fallback when
1764
+ // there is no ladder at all), and the model catalog both selects use
1765
+ await Promise.all([
1766
+ api('/api/settings').then((d) => { state.preferences = d.preferences || null }).catch(() => {}),
1767
+ api('/api/adapters').then((d) => { state.adapters = d.adapters || null }).catch(() => {}),
1768
+ api('/api/models').then((d) => { state.models = d.models || null }).catch(() => {}),
1769
+ ])
1770
+ publishModels()
1771
+ renderEntryLine()
1772
+ // last, so the dialog opens over a page that already knows its ladder,
1773
+ // its repos and its cards
1774
+ await openFromHash()
1775
+ }
1312
1776
  }
1313
1777
 
1314
1778
  document.addEventListener('DOMContentLoaded', init)