@ucsandman/legcli 0.11.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 (56) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +95 -65
  3. package/bin/leg.mjs +123 -14
  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 +41 -0
  8. package/docs/ROADMAP-v2.md +50 -5
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/board-guide.md +401 -66
  11. package/docs/cli-contracts.md +199 -5
  12. package/docs/concepts.md +167 -19
  13. package/docs/configuration.md +58 -0
  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/settings-ladder-1280.png +0 -0
  25. package/docs/screenshots/terminals-1280.png +0 -0
  26. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  27. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  28. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  29. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  30. package/fixtures/live/claude/resume-model-probe.json +20 -0
  31. package/fixtures/live/claude/usage-oauth.json +87 -0
  32. package/fixtures/verified.json +1 -1
  33. package/package.json +3 -2
  34. package/scripts/seed-fake-cards.mjs +59 -6
  35. package/scripts/seed-wes-board.mjs +81 -12
  36. package/src/accounts.mjs +6 -1
  37. package/src/attach.mjs +328 -46
  38. package/src/audit.mjs +1 -1
  39. package/src/board/board.css +119 -9
  40. package/src/board/board.js +482 -106
  41. package/src/board/index.html +67 -7
  42. package/src/board/sessions.js +1347 -117
  43. package/src/buckets.mjs +101 -0
  44. package/src/cards.mjs +9 -1
  45. package/src/chain.mjs +13 -0
  46. package/src/hook.mjs +7 -1
  47. package/src/ledger.mjs +10 -2
  48. package/src/orchestrator.mjs +13 -4
  49. package/src/preferences.mjs +214 -5
  50. package/src/scheduler.mjs +24 -1
  51. package/src/server.mjs +546 -47
  52. package/src/sessions.mjs +17 -1
  53. package/src/taps/claude-usage.mjs +91 -2
  54. package/src/taps/claude.mjs +144 -5
  55. package/src/taps/codex.mjs +23 -3
  56. package/src/usage.mjs +407 -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 = {
@@ -101,6 +106,12 @@
101
106
  healthKnown: false,
102
107
  bindKnown: false,
103
108
  lastHello: null,
109
+ // C.2: the one-line entry infers its repo from the most recently focused
110
+ // terminal, published by sessions.js on the leg:sessions window event.
111
+ sessions: [],
112
+ // one entry per repo with a live terminal: { repo, repo_name, branch }
113
+ repoTrunks: [],
114
+ preferences: null,
104
115
  }
105
116
  // a card push while an agent writes its log only moves these two
106
117
  const VOLATILE_CARD_FIELDS = ['last_event', 'elapsed_ms']
@@ -216,20 +227,19 @@
216
227
  }
217
228
 
218
229
  // 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.
230
+ // not a zero-length run: both print the placeholder rather than a number.
231
+ // A running card counts up from its current run, in the column the terminal
232
+ // rows above hold their run clock in. A live card BETWEEN runs has no run
233
+ // clock at all: created_at to updated_at is the card's whole age, which for
234
+ // a card that sat in the backlog for a day reads `24:05:00` in a column that
235
+ // means "this run", so it is labelled for what it is instead: how long it
236
+ // has been sitting in the state it is in.
224
237
  function runElapsed(card) {
225
238
  const from = card.active_run ? Date.parse(card.active_run.started_at) : NaN
226
239
  if (Number.isFinite(from)) return elapsedClock(Date.now() - from)
227
240
  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
- : '--:--'
241
+ const since = Date.parse(card.updated_at)
242
+ return Number.isFinite(since) && since <= Date.now() ? `idle ${agoShort(Date.now() - since)}` : '--:--'
233
243
  }
234
244
 
235
245
  function clockAt(ms) {
@@ -301,6 +311,21 @@
301
311
  window.dispatchEvent(new CustomEvent('baton:sessions', { detail }))
302
312
  }
303
313
 
314
+ // the entry line's repo inference reads the latest sessions payload without
315
+ // owning the terminals region: sessions.js still renders it, this file only
316
+ // keeps a copy for "the most recently focused terminal's repo". Guarded the
317
+ // same way window.legMessage is above: the pure-logic test harness passes a
318
+ // window stub with no addEventListener at all.
319
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
320
+ window.addEventListener('leg:sessions', (e) => {
321
+ state.sessions = (e.detail && e.detail.sessions) || []
322
+ // each repo's own default branch, read once per push by the server: the
323
+ // entry line's `on <branch>` and the trunk it posts come from here
324
+ state.repoTrunks = (e.detail && e.detail.trunk) || []
325
+ renderEntryLine()
326
+ })
327
+ }
328
+
304
329
  function connectSse() {
305
330
  if (state.es) { try { state.es.close() } catch { /* ignore */ } }
306
331
  const request = ++state.sseRequest
@@ -416,60 +441,15 @@
416
441
  }
417
442
  }
418
443
 
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.
444
+ // ---- 6.12 step 6: liveness split, cards reborn ----
445
+ // C.1: a live card is a row in the Background panel, right under Terminals.
446
+ // A finished one falls into one ledger cell. `cardsOpen` now gates the
447
+ // finished-cards drawer, not a background-tasks drawer: there is no drawer
448
+ // for live cards, they are always on the page.
423
449
  let cardsOpen = false
424
450
  function toggleEmptyState() {
425
451
  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)
452
+ if (empty) empty.hidden = state.cards.size > 0
473
453
  }
474
454
 
475
455
  function rowRank(card) {
@@ -487,11 +467,102 @@
487
467
  return [...state.cards.values()].sort((a, b) => rowRank(a) - rowRank(b) || startedAt(a) - startedAt(b))
488
468
  }
489
469
 
470
+ function liveCards() { return orderedCards().filter((c) => isLive(c)) }
471
+
472
+ // C.5: finished cards, newest first, for the ledger's [View] drawer.
473
+ function finishedCards() {
474
+ return [...state.cards.values()]
475
+ .filter((c) => FINISHED_STATUSES.includes(c.status))
476
+ .sort((a, b) => (Date.parse(b.updated_at) || 0) - (Date.parse(a.updated_at) || 0))
477
+ }
478
+
479
+ // C.5: "1 running, 1 waiting on you", the same predicate as needsYou on the
480
+ // terminals region. A zero clause is omitted rather than printed as 0.
481
+ function backgroundMeta(live) {
482
+ const running = live.filter((c) => ['running', 'handing_off'].includes(c.status)).length
483
+ const waiting = live.filter((c) => ['needs_approval', 'waiting_human'].includes(c.status)).length
484
+ const parts = []
485
+ if (running) parts.push(`${running} running`)
486
+ if (waiting) parts.push(`${waiting} waiting on you`)
487
+ return parts.join(', ')
488
+ }
489
+
490
+ function renderBackgroundRegion(live) {
491
+ const section = document.getElementById('background')
492
+ if (section) section.hidden = live.length === 0
493
+ const meta = document.querySelector('#background .region-meta')
494
+ if (meta) meta.textContent = backgroundMeta(live)
495
+ }
496
+
497
+ // C.1, C.5: "3 finished cards, 2 done, 1 failed, last 11:02 PM", one line,
498
+ // never a count of the live cards (they are their own region now).
499
+ function renderFinishedLedger() {
500
+ const finished = finishedCards()
501
+ const head = document.getElementById('cards-head')
502
+ const meta = document.querySelector('#board .region-meta')
503
+ if (head) {
504
+ if (!finished.length) head.textContent = 'No finished cards'
505
+ else {
506
+ const done = finished.filter((c) => c.status === 'done').length
507
+ const failed = finished.filter((c) => c.status === 'failed').length
508
+ const killed = finished.filter((c) => c.status === 'killed').length
509
+ const parts = []
510
+ if (done) parts.push(`${done} done`)
511
+ if (failed) parts.push(`${failed} failed`)
512
+ if (killed) parts.push(`${killed} killed`)
513
+ head.textContent = `${plural(finished.length, 'finished card')}, ${parts.join(', ')}, last ${clock(finished[0].updated_at)}`
514
+ }
515
+ }
516
+ if (meta) meta.textContent = ''
517
+ const slot = document.querySelector('#board .ledger-actions')
518
+ if (slot) {
519
+ let view = document.getElementById('cards-view-toggle')
520
+ if (!finished.length) { if (view) view.remove() } else {
521
+ if (!view) {
522
+ view = el('button', { type: 'button', class: 'btn btn-secondary', id: 'cards-view-toggle', 'aria-controls': 'cards-drawer' }, ['View'])
523
+ view.addEventListener('click', () => { cardsOpen = !cardsOpen; renderFinishedLedger() })
524
+ slot.appendChild(view)
525
+ }
526
+ view.textContent = cardsOpen ? 'Hide' : 'View'
527
+ view.setAttribute('aria-expanded', cardsOpen ? 'true' : 'false')
528
+ }
529
+ }
530
+ const panel = document.getElementById('cards-drawer')
531
+ if (panel) panel.hidden = !(cardsOpen && finished.length)
532
+ renderFinishedList(finished)
533
+ }
534
+
535
+ // C.5: each drawer row is one line, never the full interactive row a live
536
+ // card gets: "done after 3 runs, landed 7f3a2c1" or "failed at station
537
+ // build after 2 runs: <last event summary>".
538
+ function finishedLine(card) {
539
+ const runs = plural(card.runs_count || 0, 'run')
540
+ const last = card.last_event ? card.last_event.summary : 'no events yet'
541
+ if (card.status === 'done') {
542
+ const landed = card.land && card.land.state === 'landed' && card.land.sha ? `landed ${String(card.land.sha).slice(0, 7)}` : last
543
+ return `done after ${runs}, ${landed}`
544
+ }
545
+ if (card.status === 'failed') return `failed at station ${card.station} after ${runs}: ${last}`
546
+ return `killed after ${runs}`
547
+ }
548
+
549
+ function renderFinishedList(finished) {
550
+ const list = document.getElementById('columns')
551
+ if (!list) return
552
+ list.textContent = ''
553
+ for (const card of finished) {
554
+ list.appendChild(el('div', { class: 'finished-line' }, [
555
+ el('span', { class: 'row-meta mono' }, [card.title || card.card_id]),
556
+ el('span', { class: 'finished-names' }, [finishedLine(card)]),
557
+ ]))
558
+ }
559
+ }
560
+
490
561
  // Re-appending every row costs one DOM move each and can move a node out from
491
562
  // under the cursor, so it happens only when the order string actually changed.
492
563
  function orderRows() {
493
- const list = document.getElementById('columns')
494
- const order = orderedCards().map((c) => c.card_id)
564
+ const list = document.getElementById('background-grid')
565
+ const order = liveCards().map((c) => c.card_id)
495
566
  const key = order.join(',')
496
567
  if (key === state.rowOrder) return
497
568
  state.rowOrder = key
@@ -502,19 +573,29 @@
502
573
  if (state.drawerId) placeDetail(state.drawerId)
503
574
  }
504
575
 
576
+ // called after anything that can change which cards are live, finished, or
577
+ // how many of each: cheap text/count work, safe to run in full every time.
578
+ function refreshCardMeta() {
579
+ const live = liveCards()
580
+ renderBackgroundRegion(live)
581
+ renderFinishedLedger()
582
+ placeEntryLine(live.length > 0)
583
+ renderEntryLine()
584
+ toggleEmptyState()
585
+ }
586
+
505
587
  function renderBoard() {
506
- const list = document.getElementById('columns')
588
+ const grid = document.getElementById('background-grid')
507
589
  // the detail region is a child of this list while a row is expanded: park it
508
590
  // back on the body so the wipe below does not take it out of the document
509
591
  document.body.appendChild(document.getElementById('drawer'))
510
- list.textContent = ''
592
+ if (grid) grid.textContent = ''
511
593
  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(',')
594
+ const live = liveCards()
595
+ for (const card of live) renderRow(card)
596
+ state.rowOrder = live.map((c) => c.card_id).join(',')
515
597
  if (state.drawerId) placeDetail(state.drawerId)
516
- renderCardsMeta()
517
- toggleEmptyState()
598
+ refreshCardMeta()
518
599
  }
519
600
 
520
601
  // the board is pushed a card for every write under its directory, log bytes
@@ -541,14 +622,22 @@
541
622
  return cache.expanded ? 200 : 8
542
623
  }
543
624
 
625
+ // a card that fell off liveness (running -> done, say) loses its row; the
626
+ // finished ledger picks it up on the next refreshCardMeta() instead
627
+ function removeLiveRow(id) {
628
+ const root = state.cardNodes.get(id)
629
+ if (root) { root.remove(); state.cardNodes.delete(id) }
630
+ state.rowOrder = ''
631
+ if (state.drawerId === id) closeDrawer()
632
+ }
633
+
544
634
  function upsertCard(card) {
545
635
  state.boardRevision += 1
546
636
  const prev = state.cards.get(card.card_id)
547
637
  const tail = staleLogTail(state.logState.get(card.card_id), card, Date.now())
548
- renderRow(card)
549
- orderRows()
550
- renderCardsMeta()
551
- toggleEmptyState()
638
+ state.cards.set(card.card_id, card)
639
+ if (isLive(card)) { renderRow(card); orderRows() } else { removeLiveRow(card.card_id) }
640
+ refreshCardMeta()
552
641
  if (tail) ensureLogLoaded(card.card_id, tail)
553
642
  if (state.drawerId === card.card_id && drawerRefreshNeeded(prev, card)) scheduleDrawerRefresh()
554
643
  }
@@ -557,12 +646,8 @@
557
646
  state.boardRevision += 1
558
647
  state.cards.delete(id)
559
648
  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()
649
+ removeLiveRow(id)
650
+ refreshCardMeta()
566
651
  }
567
652
 
568
653
  function onLedgerEvent(ev) {
@@ -618,16 +703,14 @@
618
703
  return { tone: 'warn', text: `waiting on you at station ${card.station} since ${clock(card.updated_at)}` }
619
704
  }
620
705
  if (card.status === 'done') return { tone: 'ok', text: `done after ${plural(card.runs_count || 0, 'run')}, ${last ? last.summary : 'no events yet'}` }
706
+ // C.3: a card runs -p --output-format json, mute until the leg exits. Once
707
+ // it has said anything real the generic branch below still carries it.
708
+ if (card.status === 'running' && last && (last.type === 'leg_started' || last.summary === 'leg started')) {
709
+ return { tone: 'muted', text: `no message until this leg ends, started ${clock(last.ts)}` }
710
+ }
621
711
  return { tone: 'muted', text: `${formatLastEvent(last)}${last ? `, ${clock(last.ts)}` : ''}` }
622
712
  }
623
713
 
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
714
  // 6.11: flat inline tokens, middot-separated by CSS, each agent name printed
632
715
  // beside its own colour so identity never rides on hue alone.
633
716
  function buildChainRail(card) {
@@ -690,11 +773,17 @@
690
773
  row.cancelBtn.focus()
691
774
  }
692
775
 
693
- function buildActions(card) {
694
- const wrap = el('div', { class: 'row-actions' })
776
+ // C.3: at most four buttons in the row's 2x2 grid, fixed order; anything
777
+ // left over moves into the expansion instead of a fifth slot.
778
+ function splitActions(card) {
695
779
  const available = card.actions || []
696
- for (const action of ACTION_ORDER) {
697
- if (!available.includes(action) || !ACTION_LABELS[action]) continue
780
+ const ordered = ACTION_ORDER.filter((a) => available.includes(a) && ACTION_LABELS[a])
781
+ return { shown: ordered.slice(0, 4), overflow: ordered.slice(4) }
782
+ }
783
+
784
+ function buildActions(card, actions) {
785
+ const wrap = el('div', { class: 'row-actions' })
786
+ for (const action of actions) {
698
787
  const label = ACTION_LABELS[action]
699
788
  const cls = `btn ${ACTION_CLASS[action] || 'btn-secondary'}`
700
789
  const run = action === 'reassign' ? () => openReassign(card, wrap) : () => runAction(card, action)
@@ -706,6 +795,82 @@
706
795
  return wrap
707
796
  }
708
797
 
798
+ // C.3: a short relative age ("6m ago"), for the work stat line only. The
799
+ // pinned time grammar in sessions.js (elapsedClock/clockAt) is unrelated:
800
+ // this is a duration since a timestamp, not an elapsed-run clock.
801
+ function agoShort(ms) {
802
+ const s = Math.max(0, Math.floor(ms / 1000))
803
+ if (s < 60) return `${s}s`
804
+ const m = Math.floor(s / 60)
805
+ if (m < 60) return `${m}m`
806
+ const h = Math.floor(m / 60)
807
+ if (h < 48) return `${h}h`
808
+ return `${Math.floor(h / 24)}d`
809
+ }
810
+
811
+ function agoSince(ts) {
812
+ const at = ts ? Date.parse(ts) : NaN
813
+ return Number.isFinite(at) ? agoShort(Date.now() - at) : 'an unknown time'
814
+ }
815
+
816
+ // C.3: the branch a live card works on is leg/<card_id> once it has a
817
+ // worktree (src/worktree.mjs branchName), else it has not started and the
818
+ // register falls back to the trunk it will branch from.
819
+ // A terminal's short id is the random tail of its session id. A card id has
820
+ // no hash on the end, only a slug, and `card-20260917-2300-seeded-live-card-3`
821
+ // ends in `3`: a bare digit identifies nothing on a board with four cards. So
822
+ // the short id is the last segment, grown leftwards until it is a token a
823
+ // reader can match back to the row (4 characters or more).
824
+ function cardShortId(id) {
825
+ const parts = String(id || '').split('-').filter(Boolean)
826
+ if (!parts.length) return ''
827
+ let out = parts[parts.length - 1]
828
+ for (let i = parts.length - 2; i >= 0 && out.length < 4; i--) out = `${parts[i]}-${out}`
829
+ return out
830
+ }
831
+
832
+ // The branch this card's checkout is really on, for a reader who is going to
833
+ // paste it into `git checkout`. A card that cut its own worktree is on
834
+ // `leg/<card-id>`; one that adopted a terminal's is on that TERMINAL's
835
+ // branch, which no reader can derive from the card id, so the server records
836
+ // it with the worktree (`worktree_branch`). Shortened for the row only when
837
+ // the full name does not fit, and the full name is on the element's title.
838
+ function cardBranch(card) {
839
+ if (card.worktree_branch) return card.worktree_branch
840
+ if (card.worktree) return `leg/${card.card_id}`
841
+ return card.trunk || 'main'
842
+ }
843
+
844
+ function cardAgent(card) { return (card.agent_model && card.agent_model.agent) || card.active_adapter || null }
845
+
846
+ function cardAgentModelText(card) {
847
+ const agent = cardAgent(card)
848
+ if (!agent) return null
849
+ const model = card.agent_model && card.agent_model.model
850
+ return model ? `${agent}/${model}` : agent
851
+ }
852
+
853
+ // C.3: "4 files, +212 -18, tests green 6m ago", built only from the parts
854
+ // the server actually measured. Nothing measured prints nothing.
855
+ function workStatLine(card) {
856
+ const parts = []
857
+ const w = card.work
858
+ if (w) {
859
+ if (Number.isFinite(w.files)) parts.push(plural(w.files, 'file'))
860
+ const ins = Number.isFinite(w.insertions) ? `+${w.insertions}` : ''
861
+ const del = Number.isFinite(w.deletions) ? `-${w.deletions}` : ''
862
+ const diff = [ins, del].filter(Boolean).join(' ')
863
+ if (diff) parts.push(diff)
864
+ }
865
+ if (card.tests) parts.push(`tests ${card.tests.state} ${agoSince(card.tests.at)} ago`)
866
+ if (card.land) {
867
+ if (card.land.state === 'bounced') parts.push(`land bounced: ${card.land.reason || 'unknown reason'}`)
868
+ else if (card.land.state === 'landed' && card.land.sha) parts.push(`landed ${String(card.land.sha).slice(0, 7)}`)
869
+ else if (card.land.state === 'failed') parts.push(`land failed${card.land.reason ? `: ${card.land.reason}` : ''}`)
870
+ }
871
+ return parts.join(', ')
872
+ }
873
+
709
874
  // the reassign picker replaces the buttons in place, like the confirm row
710
875
  async function openReassign(card, wrap) {
711
876
  if (!state.adapters) {
@@ -734,33 +899,37 @@
734
899
  wrap.appendChild(el('div', { class: 'reassign-picker' }, [adapterSelect, modeSelect, apply, cancel]))
735
900
  }
736
901
 
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.
902
+ // C.3: R1 register (state, station, repo/branch, agent/model), R2 title +
903
+ // sentence, R3 the work stat line (only what is measured), R4 elapsed, the
904
+ // short id, and at most four action buttons. A card row reads down the same
905
+ // four columns as a terminal (.row already carries that shape, see
906
+ // board.css's own comment on it: "the same shape as .term-row").
740
907
  function buildRow(card) {
741
908
  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)
909
+ const agentText = cardAgentModelText(card)
910
+ const agentBase = cardAgent(card)
744
911
  const title = el('button', {
745
912
  type: 'button', class: 'row-title', 'aria-expanded': state.drawerId === card.card_id ? 'true' : 'false',
746
913
  onclick: () => expandRow(card.card_id),
747
914
  }, [card.title || truncate(card.task, 60) || card.card_id])
748
915
  const sentence = el('p', { class: `sentence tone-${said.tone}` }, [said.text])
749
916
  const elapsed = el('span', { class: 'elapsed' }, [runElapsed(card)])
917
+ const statLine = workStatLine(card)
918
+ const { shown } = splitActions(card)
750
919
  const cells = [
751
920
  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
921
  statusWord(card),
922
+ card.station && card.station !== '-' ? el('span', { class: 'chip' }, [card.station]) : null,
923
+ 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)}`]),
924
+ agentText ? el('span', { class: `chip chip-id-${agentClass(agentBase)}` }, [agentText]) : null,
755
925
  ]),
756
926
  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),
927
+ el('div', { class: 'r3' }, statLine ? [el('p', { class: 'row-meta' }, [statLine])] : []),
928
+ el('div', { class: 'r4' }, [
929
+ elapsed,
930
+ el('span', { class: 'row-meta mono', title: card.card_id }, [cardShortId(card.card_id)]),
931
+ buildActions(card, shown),
762
932
  ]),
763
- el('div', { class: 'r4' }, [elapsed, buildActions(card)]),
764
933
  ]
765
934
  return { cells, slots: { title, sentence, elapsed } }
766
935
  }
@@ -771,7 +940,7 @@
771
940
  if (!root) {
772
941
  root = el('article', { class: 'row', 'data-card-id': card.card_id })
773
942
  state.cardNodes.set(card.card_id, root)
774
- document.getElementById('columns').appendChild(root)
943
+ document.getElementById('background-grid').appendChild(root)
775
944
  state.rowOrder = ''
776
945
  } else {
777
946
  while (root.firstChild) root.removeChild(root.firstChild)
@@ -854,6 +1023,27 @@
854
1023
  return [el('div', { class: 'kv' }, rows), copy]
855
1024
  }
856
1025
 
1026
+ // C.4: card -> terminal. Take over pauses the card and hands back one
1027
+ // copyable command; a terminal cannot be opened from a browser tab, so the
1028
+ // board says that plainly rather than pretending it can.
1029
+ function buildTakeOver(card) {
1030
+ const box = el('div', { class: 'kv' })
1031
+ const btn = el('button', { type: 'button', class: 'btn btn-secondary', onclick: async () => {
1032
+ try {
1033
+ const data = await api(`/api/cards/${encodeURIComponent(card.card_id)}/take-over`, { method: 'POST', body: {} })
1034
+ box.textContent = ''
1035
+ 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.']))
1036
+ const row = el('div', { class: 'reassign-picker' }, [
1037
+ el('span', { class: 'row-meta mono' }, [data.command]),
1038
+ el('button', { type: 'button', class: 'btn btn-secondary', onclick: () => copyToClipboard(data.command) }, ['Copy']),
1039
+ ])
1040
+ box.appendChild(row)
1041
+ } catch (err) { toast(err.message) }
1042
+ } }, ['Take over'])
1043
+ box.appendChild(btn)
1044
+ return detailSection('Take over', 'pauses the card and starts an interactive terminal from its bundle', box)
1045
+ }
1046
+
857
1047
  function eventRow(e) {
858
1048
  return el('div', { class: 'turn' }, [
859
1049
  el('span', { class: 'turn-when' }, [clock(e.ts)]),
@@ -901,6 +1091,17 @@
901
1091
  for (const st of stations) pipeline.push(...kvRow(st.name, `${st.kind}${st.name === card.station ? ', this station' : ''}`))
902
1092
  content.appendChild(detailSection('Pipeline', at ? `station ${at} of ${stations.length}` : `${plural(stations.length, 'station')}, none started`, el('div', { class: 'kv' }, pipeline)))
903
1093
 
1094
+ // C.3: the chain rail, the leases and the pipeline above all moved out of
1095
+ // the row and into this expansion; the row itself carries only the model
1096
+ // token and the station name now.
1097
+ content.appendChild(detailSection('Chain', null, buildChainRail(card)))
1098
+ content.appendChild(detailSection('Leases', null, buildLeases(card)))
1099
+
1100
+ const { overflow } = splitActions(card)
1101
+ if (overflow.length) content.appendChild(detailSection('More actions', 'past the four on the row', buildActions(card, overflow)))
1102
+
1103
+ content.appendChild(buildTakeOver(card))
1104
+
904
1105
  const runs = detail.runs || []
905
1106
  const runRows = []
906
1107
  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,6 +1213,175 @@
1012
1213
  if (root && root.slots) root.slots.title.focus()
1013
1214
  }
1014
1215
 
1216
+ // ---- 6.15 step 6: the one-line background entry (C.2) ----
1217
+ // Always visible to the owner, one row above or below the Background panel.
1218
+ // Its three nouns (repo, ladder, workflow) are inferred and are buttons that
1219
+ // swap for a select in place; nothing here persists past a page reload.
1220
+ let entryState = { task: '', repo: null, ladderStart: 0, pipeline: 'build', editing: null }
1221
+ const PIPELINE_WORDS = { build: 'build only', 'build-land': 'build and land', factory: 'plan, build, review and land' }
1222
+
1223
+ // Only a terminal that carries a `repo` is offered: /api/cards refuses a path
1224
+ // that is not a git repository root, so a terminal whose cwd is a plain
1225
+ // folder would put a value in this field that Start can only ever reject.
1226
+ function knownRepos() {
1227
+ const seen = new Map()
1228
+ for (const s of state.sessions || []) if (s.repo && !seen.has(s.repo)) seen.set(s.repo, s.repo_name || s.repo)
1229
+ for (const c of state.cards.values()) if (c.repo && !seen.has(c.repo)) seen.set(c.repo, c.repo_name || c.repo)
1230
+ return [...seen].map(([path, name]) => ({ path, name }))
1231
+ }
1232
+
1233
+ // C.2: the most recently focused terminal's repo, else the last card's, else
1234
+ // the first terminal's.
1235
+ function inferRepo() {
1236
+ const sessions = (state.sessions || []).filter((s) => s.repo)
1237
+ const byFocus = sessions.length ? [...sessions].sort((a, b) => (Date.parse(b.last_activity) || 0) - (Date.parse(a.last_activity) || 0))[0] : null
1238
+ if (byFocus) return { path: byFocus.repo, name: byFocus.repo_name || byFocus.repo }
1239
+ const cards = [...state.cards.values()].sort((a, b) => (Date.parse(b.updated_at) || 0) - (Date.parse(a.updated_at) || 0))
1240
+ if (cards[0] && cards[0].repo) return { path: cards[0].repo, name: cards[0].repo_name || cards[0].repo }
1241
+ if (sessions[0]) return { path: sessions[0].repo, name: sessions[0].repo_name || sessions[0].repo }
1242
+ return null
1243
+ }
1244
+
1245
+ function entryRepo() { return entryState.repo || inferRepo() }
1246
+
1247
+ // the saved ladder, skipping a credits/metered rung while may_spend is off:
1248
+ // a card never starts on a rung that bills without asking (-p mode does)
1249
+ function ladderRungs() {
1250
+ const prefs = state.preferences
1251
+ if (!prefs || !Array.isArray(prefs.handoff_ladder)) return []
1252
+ const maySpend = !!prefs.may_spend
1253
+ return prefs.handoff_ladder.filter((r) => maySpend || !['credits', 'metered'].includes(r.cost))
1254
+ }
1255
+
1256
+ function ladderLabel(r) { return r.model ? `${r.agent}/${r.model}` : r.agent }
1257
+ function ladderSentence(rungs) { return rungs.length ? rungs.map(ladderLabel).join(' then ') : 'no agent is configured' }
1258
+
1259
+ // Exactly what Start posts: one chain entry per rung, carrying that rung's
1260
+ // model, from the rung the reader picked downward. De-duplicated by
1261
+ // (adapter, model) and not by adapter, because `claude/fable` then
1262
+ // `claude/opus` are two real legs and a hand-off between them is the whole
1263
+ // point; two rungs that name the same adapter AND the same model are one leg
1264
+ // twice, and a hand-off from a leg to its own twin buys nothing.
1265
+ function entryChain() {
1266
+ const seen = new Set()
1267
+ const out = []
1268
+ for (const r of ladderRungs().slice(entryState.ladderStart || 0)) {
1269
+ const key = `${r.agent}/${r.model || ''}`
1270
+ if (seen.has(key)) continue
1271
+ seen.add(key)
1272
+ out.push(r)
1273
+ }
1274
+ return out
1275
+ }
1276
+
1277
+ // The branch a card in this repo should be cut from. The sessions payload
1278
+ // carries the repo's own default branch (the server reads origin/HEAD, then
1279
+ // main/master/trunk, then the current branch) and each terminal's branch, so
1280
+ // neither the sentence nor the body has to assume `main` in a repo whose
1281
+ // default is `master` or `develop`: Start could only ever fail there.
1282
+ function entryTrunk(repo) {
1283
+ if (!repo) return null
1284
+ const known = (state.repoTrunks || []).find((t) => t.repo === repo.path || t.repo_name === repo.name)
1285
+ if (known && known.branch) return known.branch
1286
+ for (const s of state.sessions || []) {
1287
+ if (s.repo !== repo.path) continue
1288
+ const b = (s.worktree && s.worktree.base) || (!s.worktree && s.branch)
1289
+ if (b) return b
1290
+ }
1291
+ return null
1292
+ }
1293
+
1294
+ async function submitEntry() {
1295
+ const repo = entryRepo()
1296
+ const task = (entryState.task || '').trim()
1297
+ if (!task || !repo) return
1298
+ const rungs = entryChain()
1299
+ const trunk = entryTrunk(repo)
1300
+ const body = {
1301
+ repo: repo.path, task,
1302
+ chain: rungs.map((r) => ({ adapter: r.agent, ...(r.model ? { model: r.model } : {}) })),
1303
+ ...(trunk ? { trunk } : {}),
1304
+ pipeline: entryState.pipeline, queue: true,
1305
+ }
1306
+ try {
1307
+ const data = await api('/api/cards', { method: 'POST', body })
1308
+ entryState.task = ''
1309
+ entryState.editing = null
1310
+ upsertCard(data.card)
1311
+ } catch (err) { toast(err.message) }
1312
+ }
1313
+
1314
+ function nounSelect(options, current, onPick) {
1315
+ const select = el('select', { 'aria-label': 'Change' })
1316
+ for (const o of options) select.appendChild(el('option', { value: o.value }, [o.label]))
1317
+ select.value = current
1318
+ select.addEventListener('change', () => { onPick(select.value); entryState.editing = null; renderEntryLine() })
1319
+ return select
1320
+ }
1321
+
1322
+ function nounButton(text, key) {
1323
+ return el('button', { type: 'button', class: 'btn btn-text', onclick: () => { entryState.editing = key; renderEntryLine() } }, [text])
1324
+ }
1325
+
1326
+ function renderEntryLine() {
1327
+ const box = document.getElementById('card-entry')
1328
+ if (!box) return
1329
+ if (isGuest()) { box.hidden = true; return }
1330
+ box.hidden = false
1331
+ box.textContent = ''
1332
+ const repo = entryRepo()
1333
+ const task = el('input', { type: 'text', placeholder: 'Describe the task', 'aria-label': 'Task to run in the background', value: entryState.task })
1334
+ const reason = el('span', { class: 'field-help' }, ['Describe the task to start it.'])
1335
+ reason.hidden = Boolean(entryState.task.trim())
1336
+ const start = el('button', { type: 'button', class: 'btn btn-primary', disabled: entryState.task.trim() ? null : '' }, ['Start'])
1337
+ task.addEventListener('input', () => { entryState.task = task.value; start.disabled = !task.value.trim(); reason.hidden = Boolean(task.value.trim()) })
1338
+ start.addEventListener('click', () => submitEntry())
1339
+ // `.confirm-row` is the board's existing sentence-plus-controls strip: a
1340
+ // flex row with a gap on the raised surface, which is exactly this line.
1341
+ box.appendChild(el('div', { class: 'confirm-row' }, [el('span', {}, ['Run in the background:']), task, start, reason]))
1342
+
1343
+ const line = el('p', { class: 'field-help' })
1344
+ line.appendChild(document.createTextNode('in '))
1345
+ if (entryState.editing === 'repo') {
1346
+ line.appendChild(nounSelect(knownRepos().map((r) => ({ value: r.path, label: r.name })), repo ? repo.path : '', (v) => { entryState.repo = knownRepos().find((r) => r.path === v) || null }))
1347
+ } else {
1348
+ line.appendChild(nounButton(repo ? repo.name : 'no repo', 'repo'))
1349
+ }
1350
+ line.appendChild(document.createTextNode(` on ${entryTrunk(repo) || 'main'}, with `))
1351
+ const rungs = ladderRungs()
1352
+ if (entryState.editing === 'ladder') {
1353
+ line.appendChild(nounSelect(rungs.map((r, i) => ({ value: String(i), label: ladderLabel(r) })), String(entryState.ladderStart || 0), (v) => { entryState.ladderStart = Number(v) }))
1354
+ } else {
1355
+ // the sentence names the legs Start posts, models and all
1356
+ line.appendChild(nounButton(ladderSentence(entryChain()), 'ladder'))
1357
+ }
1358
+ line.appendChild(document.createTextNode(', '))
1359
+ if (entryState.editing === 'pipeline') {
1360
+ line.appendChild(nounSelect(Object.keys(PIPELINE_WORDS).map((v) => ({ value: v, label: PIPELINE_WORDS[v] })), entryState.pipeline, (v) => { entryState.pipeline = v }))
1361
+ } else {
1362
+ line.appendChild(nounButton(PIPELINE_WORDS[entryState.pipeline] || 'build only', 'pipeline'))
1363
+ }
1364
+ line.appendChild(document.createTextNode('. '))
1365
+ line.appendChild(el('button', { type: 'button', class: 'btn btn-text', onclick: () => openNewCardDialog() }, ['More settings']))
1366
+ box.appendChild(line)
1367
+ }
1368
+
1369
+ // C.2: always visible under the Background panel; when there are no live
1370
+ // cards the panel is hidden, so the entry moves to sit under Terminals
1371
+ // instead. The fake DOM in the tests has no after(), so this is a no-op
1372
+ // there and a real move in the browser.
1373
+ function placeEntryLine(hasLive) {
1374
+ const entry = document.getElementById('card-entry')
1375
+ if (!entry) return
1376
+ if (hasLive) {
1377
+ const bg = document.getElementById('background')
1378
+ if (bg && typeof bg.appendChild === 'function') bg.appendChild(entry)
1379
+ } else {
1380
+ const terms = document.querySelector('.region-terminals')
1381
+ if (terms && typeof terms.after === 'function') terms.after(entry)
1382
+ }
1383
+ }
1384
+
1015
1385
  // ---- new card dialog ----
1016
1386
  function newCardDialogEls() {
1017
1387
  return {
@@ -1305,10 +1675,16 @@
1305
1675
  if (state.drawerId) collapseRow()
1306
1676
  })
1307
1677
  // health first: it says whether this human owns the pipeline side at all
1308
- await loadHealth()
1678
+ const owner = await loadHealth()
1309
1679
  fetchCards()
1310
1680
  connectSse()
1311
1681
  setInterval(tickElapsed, 1000)
1682
+ // C.2: the ladder sentence on the entry line reads this; a guest never
1683
+ // sees the entry line, so there is nothing to fetch it for
1684
+ if (owner) {
1685
+ try { state.preferences = (await api('/api/settings')).preferences || null } catch { /* entry line falls back to "no agent is configured" */ }
1686
+ renderEntryLine()
1687
+ }
1312
1688
  }
1313
1689
 
1314
1690
  document.addEventListener('DOMContentLoaded', init)