@ucsandman/legcli 0.7.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 (184) hide show
  1. package/CHANGELOG.md +560 -0
  2. package/LICENSE +58 -0
  3. package/NOTICE +4 -0
  4. package/README.md +558 -0
  5. package/bin/fake-agent.mjs +143 -0
  6. package/bin/leg.mjs +449 -0
  7. package/docs/DECISIONS.md +119 -0
  8. package/docs/DEMO.md +214 -0
  9. package/docs/DEVIATIONS.md +182 -0
  10. package/docs/ERRORS.md +326 -0
  11. package/docs/README.md +40 -0
  12. package/docs/REUSE.md +289 -0
  13. package/docs/ROADMAP-v2.md +55 -0
  14. package/docs/VOCABULARY.md +243 -0
  15. package/docs/adapters.md +300 -0
  16. package/docs/board-guide.md +579 -0
  17. package/docs/cli-contracts.md +401 -0
  18. package/docs/concepts.md +426 -0
  19. package/docs/configuration.md +220 -0
  20. package/docs/faq.md +206 -0
  21. package/docs/getting-started.md +251 -0
  22. package/docs/real-run.md +80 -0
  23. package/docs/review-2026-09-14.md +72 -0
  24. package/docs/screenshots/board-400px.png +0 -0
  25. package/docs/screenshots/board-details-open.png +0 -0
  26. package/docs/screenshots/board-done.png +0 -0
  27. package/docs/screenshots/board-drawer.png +0 -0
  28. package/docs/screenshots/board-empty.png +0 -0
  29. package/docs/screenshots/board-handoff.png +0 -0
  30. package/docs/screenshots/board-running.png +0 -0
  31. package/docs/screenshots/demo-1-claude-running.png +0 -0
  32. package/docs/screenshots/demo-2-limit-hit.png +0 -0
  33. package/docs/screenshots/demo-3-handoff-bundle.png +0 -0
  34. package/docs/screenshots/demo-4-codex-running.png +0 -0
  35. package/docs/screenshots/demo-5-done.png +0 -0
  36. package/docs/screenshots/floor-final-1280.png +0 -0
  37. package/docs/screenshots/floor-final-400.png +0 -0
  38. package/docs/screenshots/floor-landing.png +0 -0
  39. package/docs/screenshots/floor.png +0 -0
  40. package/docs/screenshots/share-guest-1280.png +0 -0
  41. package/docs/screenshots/share-owner-1280.png +0 -0
  42. package/docs/screenshots/terminals-1280.png +0 -0
  43. package/docs/screenshots/thumb-done.jpg +0 -0
  44. package/docs/screenshots/thumb-handoff-details.jpg +0 -0
  45. package/docs/screenshots/thumb-limit-hit.jpg +0 -0
  46. package/docs/screenshots/youtube-thumbnail.jpg +0 -0
  47. package/fixtures/limits/agy/agy-resource-exhausted.json +11 -0
  48. package/fixtures/limits/claude/claude-budget-limit.json +11 -0
  49. package/fixtures/limits/claude/claude-max-turns.json +11 -0
  50. package/fixtures/limits/claude/claude-model-limit.json +11 -0
  51. package/fixtures/limits/claude/claude-session-limit.json +11 -0
  52. package/fixtures/limits/claude/claude-weekly-limit.json +11 -0
  53. package/fixtures/limits/codex/codex-quota-exceeded.json +11 -0
  54. package/fixtures/limits/codex/codex-rate-limit-exceeded.json +11 -0
  55. package/fixtures/limits/codex/codex-skills-notice.json +11 -0
  56. package/fixtures/limits/codex/codex-usage-limit-reached.json +11 -0
  57. package/fixtures/limits/codex/codex-usage-limit.json +11 -0
  58. package/fixtures/limits/generic/generic-429.json +11 -0
  59. package/fixtures/limits/generic/generic-overloaded.json +11 -0
  60. package/fixtures/limits/generic/generic-quota.json +11 -0
  61. package/fixtures/limits/generic/generic-rate-limit.json +11 -0
  62. package/fixtures/limits/generic/generic-resource-exhausted.json +11 -0
  63. package/fixtures/limits/generic/generic-usage-limit.json +11 -0
  64. package/fixtures/limits/grok/grok-not-logged-in.json +11 -0
  65. package/fixtures/limits/negative/auth-source-set.json +11 -0
  66. package/fixtures/limits/negative/compile-error.json +11 -0
  67. package/fixtures/limits/negative/empty-stdout-exit-0.json +11 -0
  68. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +1 -0
  69. package/fixtures/live/agy/cmd.txt +1 -0
  70. package/fixtures/live/agy/err.log +0 -0
  71. package/fixtures/live/agy/out.log +1 -0
  72. package/fixtures/live/agy/parsed.json +5 -0
  73. package/fixtures/live/agy/run.json +18 -0
  74. package/fixtures/live/agy/supervisor.log +2 -0
  75. package/fixtures/live/claude/cmd.txt +1 -0
  76. package/fixtures/live/claude/err.log +0 -0
  77. package/fixtures/live/claude/limit-rate_limit.json +21 -0
  78. package/fixtures/live/claude/out.log +1 -0
  79. package/fixtures/live/claude/parsed.json +11 -0
  80. package/fixtures/live/claude/run.json +18 -0
  81. package/fixtures/live/claude/supervisor.log +2 -0
  82. package/fixtures/live/codex/cmd.txt +1 -0
  83. package/fixtures/live/codex/err.log +1 -0
  84. package/fixtures/live/codex/last.md +2 -0
  85. package/fixtures/live/codex/limit-usage_limit_exceeded.json +25 -0
  86. package/fixtures/live/codex/out.log +8 -0
  87. package/fixtures/live/codex/parsed.json +15 -0
  88. package/fixtures/live/codex/run.json +18 -0
  89. package/fixtures/live/codex/supervisor.log +2 -0
  90. package/fixtures/live/grok/cmd.txt +1 -0
  91. package/fixtures/live/grok/err.log +32 -0
  92. package/fixtures/live/grok/out.log +7 -0
  93. package/fixtures/live/grok/parsed.json +5 -0
  94. package/fixtures/live/grok/run.json +18 -0
  95. package/fixtures/live/grok/supervisor.log +2 -0
  96. package/fixtures/verified.json +34 -0
  97. package/package.json +68 -0
  98. package/scripts/board-shots.mjs +69 -0
  99. package/scripts/build-docs-site.mjs +308 -0
  100. package/scripts/check-claims.mjs +150 -0
  101. package/scripts/clean-clone-check.sh +42 -0
  102. package/scripts/license-sign.mjs +23 -0
  103. package/scripts/limits-table.mjs +38 -0
  104. package/scripts/live-limits.mjs +36 -0
  105. package/scripts/privacy-check.mjs +63 -0
  106. package/scripts/probe.mjs +92 -0
  107. package/scripts/seed-fake-cards.mjs +34 -0
  108. package/scripts/seed-floor-board.mjs +69 -0
  109. package/scripts/seed-wes-board.mjs +91 -0
  110. package/scripts/stripe-setup.mjs +99 -0
  111. package/scripts/vercel-env.mjs +40 -0
  112. package/src/accounts.mjs +119 -0
  113. package/src/adapters/agy.mjs +56 -0
  114. package/src/adapters/claude.mjs +54 -0
  115. package/src/adapters/codex.mjs +73 -0
  116. package/src/adapters/common.mjs +32 -0
  117. package/src/adapters/fake.mjs +48 -0
  118. package/src/adapters/grok.mjs +51 -0
  119. package/src/adapters/index.mjs +27 -0
  120. package/src/adapters/resolve.mjs +40 -0
  121. package/src/attach.mjs +592 -0
  122. package/src/auth.mjs +85 -0
  123. package/src/board/board.css +543 -0
  124. package/src/board/board.js +1307 -0
  125. package/src/board/favicon.svg +1 -0
  126. package/src/board/floor.html +127 -0
  127. package/src/board/floor.js +670 -0
  128. package/src/board/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
  129. package/src/board/fonts/OFL-azeret-mono.txt +94 -0
  130. package/src/board/fonts/atkinson-board.woff2 +0 -0
  131. package/src/board/fonts/azeret-board.woff2 +0 -0
  132. package/src/board/index.html +251 -0
  133. package/src/board/sessions.js +1451 -0
  134. package/src/bundle.mjs +103 -0
  135. package/src/cards.mjs +114 -0
  136. package/src/chain.mjs +264 -0
  137. package/src/commands.mjs +79 -0
  138. package/src/contract.mjs +73 -0
  139. package/src/env.mjs +19 -0
  140. package/src/fsx.mjs +72 -0
  141. package/src/git-snapshot.mjs +152 -0
  142. package/src/handoff.mjs +171 -0
  143. package/src/hook.mjs +49 -0
  144. package/src/land.mjs +106 -0
  145. package/src/launcher.mjs +272 -0
  146. package/src/leases.mjs +86 -0
  147. package/src/ledger.mjs +369 -0
  148. package/src/license.mjs +168 -0
  149. package/src/limits.mjs +97 -0
  150. package/src/live-capture.mjs +69 -0
  151. package/src/mergequeue.mjs +225 -0
  152. package/src/orchestrator.mjs +365 -0
  153. package/src/pipeline.mjs +100 -0
  154. package/src/preferences.mjs +47 -0
  155. package/src/presets.mjs +23 -0
  156. package/src/ratelimit.mjs +49 -0
  157. package/src/redact.mjs +47 -0
  158. package/src/resume.mjs +354 -0
  159. package/src/runner.mjs +438 -0
  160. package/src/scheduler.mjs +118 -0
  161. package/src/server.mjs +845 -0
  162. package/src/session-detail.mjs +129 -0
  163. package/src/sessions.mjs +202 -0
  164. package/src/share.mjs +163 -0
  165. package/src/stations/agent.mjs +42 -0
  166. package/src/stations/build.mjs +9 -0
  167. package/src/stations/human.mjs +5 -0
  168. package/src/stations/land.mjs +6 -0
  169. package/src/stations/plan.mjs +8 -0
  170. package/src/stations/pr.mjs +31 -0
  171. package/src/stations/review.mjs +8 -0
  172. package/src/stations/test.mjs +27 -0
  173. package/src/store.mjs +90 -0
  174. package/src/sync/dashclaw.mjs +70 -0
  175. package/src/sync/index.mjs +54 -0
  176. package/src/sync/workboard.mjs +54 -0
  177. package/src/taps/agy.mjs +64 -0
  178. package/src/taps/claude-usage.mjs +67 -0
  179. package/src/taps/claude.mjs +170 -0
  180. package/src/taps/codex.mjs +286 -0
  181. package/src/trust.mjs +323 -0
  182. package/src/usage.mjs +179 -0
  183. package/src/wait.mjs +30 -0
  184. package/src/worktree.mjs +207 -0
@@ -0,0 +1,1307 @@
1
+ // Leg board: vanilla JS, no build step. Talks to /api/* (see src/server.mjs)
2
+ // and /api/events (SSE). Keeps one row per card_id, so a live push patches one
3
+ // row instead of re-rendering the list. Design: .design/BOARD-DESIGN.md. Ids,
4
+ // class names and source shapes: .design/BUILD-CONTRACT.md section 6.2. Three
5
+ // of those shapes are pinned by test/board-updates.test.mjs, which runs this
6
+ // file as source:
7
+ // 1. The export line at the bottom stays ONE line with no nested braces: the
8
+ // test rewrites it with /module\.exports = \{[^}]+\}/ and takes the FIRST
9
+ // match, so that text must not appear earlier in the file either.
10
+ // 2. upsertCard, scheduleDrawerRefresh, fetchCards, ensureLogLoaded and the
11
+ // 'hello' listener keep their headers and the calls named in the contract.
12
+ // 3. Every id is looked up by exactly the string the test's fake DOM
13
+ // registers. That fake node has no insertBefore, no querySelector that
14
+ // finds anything and no focus(), which is why this file appends rather
15
+ // than inserts and reads its own nodes back through `root.slots`.
16
+ (function () {
17
+ 'use strict'
18
+
19
+ // 6.3: the nine shipped status words, unchanged, each beside the mark tone it
20
+ // takes. The word carries the meaning; the 7px mark is aria-hidden.
21
+ const STATUS_LABELS = {
22
+ running: 'running', handing_off: 'handing off', waiting_human: 'waiting human',
23
+ needs_approval: 'needs approval', paused: 'paused', queued: 'queued',
24
+ backlog: 'backlog', done: 'done', failed: 'failed', killed: 'killed',
25
+ }
26
+ const STATUS_TONE = {
27
+ running: 'run', handing_off: 'warn', waiting_human: 'warn', needs_approval: 'warn',
28
+ paused: 'idle', queued: 'idle', backlog: 'idle', done: 'idle', failed: 'danger', killed: 'danger',
29
+ }
30
+ // rows are ordered needs-you first, then the verdicts you have to answer, then
31
+ // work in flight, then the queue, then what is finished
32
+ const ROW_RANK = {
33
+ needs_approval: 0, waiting_human: 0, failed: 1, killed: 1, running: 2,
34
+ handing_off: 2, paused: 3, queued: 4, backlog: 5, done: 6,
35
+ }
36
+ // 6.5 G10: the button order is fixed and never reflows by availability
37
+ const ACTION_ORDER = ['approve', 'enqueue', 'resume', 'pause', 'handoff_now', 'rerun', 'reassign', 'kill']
38
+ const ACTION_LABELS = {
39
+ enqueue: 'Run', pause: 'Pause', resume: 'Resume', kill: 'Kill', reassign: 'Reassign',
40
+ handoff_now: 'Hand off now', approve: 'Approve', rerun: 'Rerun',
41
+ }
42
+ const ACTION_PATHS = {
43
+ enqueue: 'run', pause: 'pause', resume: 'resume', kill: 'kill', reassign: 'reassign',
44
+ handoff_now: 'handoff', approve: 'approve', rerun: 'rerun',
45
+ }
46
+ const ACTION_CLASS = { approve: 'btn-primary', enqueue: 'btn-primary', kill: 'btn-danger' }
47
+ // the permission modes come off /api/adapters as each CLI's own enum, in three
48
+ // spellings at once (acceptEdits, accept-edits, workspace-write), which is the
49
+ // one field in the dialog that decides what an agent may do to the repo. The
50
+ // value sent is still the enum; only the label the reader picks from is ours.
51
+ const MODE_LABELS = {
52
+ acceptEdits: 'edit files without asking',
53
+ 'accept-edits': 'edit files without asking',
54
+ auto_edit: 'edit files without asking',
55
+ 'workspace-write': 'write inside the worktree',
56
+ 'read-only': 'read only',
57
+ plan: 'plan only',
58
+ }
59
+ const AGENT_IDS = ['claude', 'codex', 'agy']
60
+ const DEFAULT_BIND = '127.0.0.1:4747'
61
+ const TIMELINE_CAP = 12
62
+ // mirrors LOOPBACK in src/auth.mjs; state.bind is "<host>:<port>" and an IPv6
63
+ // host arrives bracketed
64
+ const LOOPBACK_HOSTS = ['127.0.0.1', '::1', 'localhost', '::ffff:127.0.0.1']
65
+
66
+ const state = {
67
+ cards: new Map(),
68
+ // the fake DOM in test/board-updates.test.mjs pre-populates this map before
69
+ // it calls fetchCards/upsertCard; the seven kanban columns it named are gone
70
+ // from the page, so nothing else writes to it
71
+ columnEls: new Map(),
72
+ cardNodes: new Map(),
73
+ rowOrder: '',
74
+ logState: new Map(),
75
+ drawerId: null,
76
+ drawerTimer: null,
77
+ drawerRequest: 0,
78
+ drawerDetail: null,
79
+ drawerEvents: [],
80
+ timelineEl: null,
81
+ timelineCap: TIMELINE_CAP,
82
+ adapters: null,
83
+ es: null,
84
+ retryMs: 1000,
85
+ sseRequest: 0,
86
+ authRequest: 0,
87
+ cardsRequest: 0,
88
+ boardRevision: 0,
89
+ logRequests: new Map(),
90
+ nextLogRequest: 0,
91
+ bind: DEFAULT_BIND,
92
+ // health answers "can anything but this machine reach the board": a
93
+ // loopback bind with share off is local only, and a token there is a field
94
+ // nobody can ever have a use for
95
+ shareOn: false,
96
+ isOwner: true,
97
+ // state.bind is DEFAULT_BIND until health says otherwise, so a board that
98
+ // 401s the first request would otherwise name 127.0.0.1:4747 to someone
99
+ // looking at a different server entirely. A guest's health is redacted and
100
+ // carries no bind at all, so answering is not the same as knowing where.
101
+ healthKnown: false,
102
+ bindKnown: false,
103
+ lastHello: null,
104
+ }
105
+ // a card push while an agent writes its log only moves these two
106
+ const VOLATILE_CARD_FIELDS = ['last_event', 'elapsed_ms']
107
+ const DRAWER_REFRESH_MS = 1000
108
+ const LOG_REFRESH_MS = 5000
109
+
110
+ // ---- helpers ----
111
+ // a shared link carries the person's token: keep it, then take it out of the
112
+ // address bar so it is not in a screenshot or the next copied URL
113
+ try {
114
+ const fromLink = new URL(location.href).searchParams.get('token')
115
+ if (fromLink) {
116
+ localStorage.setItem('legToken', fromLink)
117
+ const clean = new URL(location.href)
118
+ clean.searchParams.delete('token')
119
+ history.replaceState(null, '', clean.pathname + clean.search + clean.hash)
120
+ }
121
+ } catch {}
122
+ function getToken() { return localStorage.getItem('legToken') || localStorage.getItem('batonToken') || '' }
123
+
124
+ async function api(path, opts = {}) {
125
+ const headers = { 'Content-Type': 'application/json' }
126
+ const token = getToken()
127
+ if (token) headers.Authorization = `Bearer ${token}`
128
+ const res = await fetch(path, {
129
+ method: opts.method || 'GET',
130
+ headers,
131
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
132
+ })
133
+ const text = await res.text()
134
+ let data = null
135
+ if (text) { try { data = JSON.parse(text) } catch { data = null } }
136
+ if (!res.ok) throw new Error((data && data.error) || `${res.status} ${res.statusText}`)
137
+ return data
138
+ }
139
+
140
+ function el(tag, attrs, children) {
141
+ const node = document.createElement(tag)
142
+ for (const [k, v] of Object.entries(attrs || {})) {
143
+ if (v == null || v === false) continue
144
+ if (k === 'class') node.className = v
145
+ else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2), v)
146
+ else if (v === true) node.setAttribute(k, '')
147
+ else node.setAttribute(k, v)
148
+ }
149
+ for (const c of [].concat(children || [])) {
150
+ if (c == null) continue
151
+ node.appendChild(typeof c === 'string' || typeof c === 'number' ? document.createTextNode(String(c)) : c)
152
+ }
153
+ return node
154
+ }
155
+
156
+ // ---- 6.9 the system message ----
157
+ // One implementation for the whole page, published as window.batonMessage so
158
+ // sessions.js reaches it instead of writing a second one into the same box.
159
+ // One message at a time, deduped by exact text. A result that belongs to a
160
+ // terminal or a card is written into that object's sentence slot, not here.
161
+ // Errors never auto-dismiss, which is why `tone` defaults to danger: a bare
162
+ // call is an error, and an error the reader has not seen does not disappear.
163
+ const MESSAGE_WORD = { ok: 'done', warn: 'warning', danger: 'error' }
164
+ const message = { text: null, node: null, timer: null }
165
+
166
+ function dismissMessage() {
167
+ if (message.timer) { clearTimeout(message.timer); message.timer = null }
168
+ if (message.node) message.node.remove()
169
+ message.node = null
170
+ message.text = null
171
+ }
172
+
173
+ function toast(msg, tone = 'danger') {
174
+ const text = String(msg)
175
+ const box = document.getElementById('toast')
176
+ if (!box || text === message.text) return
177
+ dismissMessage()
178
+ const word = MESSAGE_WORD[tone] || MESSAGE_WORD.danger
179
+ const item = el('div', { class: `sysmsg-item tone-${tone}` }, [
180
+ el('span', { class: 'status-word' }, [el('span', { class: `mark tone-${tone}`, 'aria-hidden': 'true' }), word]),
181
+ el('span', {}, [text]),
182
+ ])
183
+ if (tone === 'danger') {
184
+ item.appendChild(el('button', { type: 'button', class: 'btn btn-danger', 'aria-label': 'Dismiss message', onclick: dismissMessage }, ['Dismiss']))
185
+ } else {
186
+ message.timer = setTimeout(dismissMessage, 6000)
187
+ }
188
+ box.appendChild(item)
189
+ message.node = item
190
+ message.text = text
191
+ }
192
+ if (typeof window !== 'undefined') window.legMessage = toast;
193
+ if (typeof window !== 'undefined') window.batonMessage = toast;
194
+
195
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
196
+
197
+ function truncate(str, n) {
198
+ const s = String(str || '')
199
+ return s.length > n ? `${s.slice(0, n - 1)}…` : s
200
+ }
201
+
202
+ // src/board/sessions.js OWNS THE TIME GRAMMAR FOR THE BOARD. elapsedClock()
203
+ // and clockAt() below are its functions character for character, because the
204
+ // three board files cannot import from each other and one fact must never
205
+ // print in two formats: `404:30` on a background card two regions under
206
+ // `06:44:30` on a terminal card was the same run measured two ways.
207
+ // G12: fixed form, zero padded, so a column of elapsed clocks aligns on its
208
+ // colon whether the terminal has run four minutes or four hours.
209
+ function elapsedClock(ms) {
210
+ const s = Math.max(0, Math.floor(ms / 1000))
211
+ const pad = (n) => String(n).padStart(2, '0')
212
+ const mm = pad(Math.floor(s / 60) % 60)
213
+ const ss = pad(s % 60)
214
+ const h = Math.floor(s / 3600)
215
+ return h ? `${pad(h)}:${mm}:${ss}` : `${mm}:${ss}`
216
+ }
217
+
218
+ // 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.
224
+ function runElapsed(card) {
225
+ const from = card.active_run ? Date.parse(card.active_run.started_at) : NaN
226
+ if (Number.isFinite(from)) return elapsedClock(Date.now() - from)
227
+ 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
+ : '--:--'
233
+ }
234
+
235
+ function clockAt(ms) {
236
+ if (!Number.isFinite(ms)) return 'unknown'
237
+ const d = new Date(ms)
238
+ const out = Math.abs(ms - Date.now())
239
+ if (out < 20 * 3600000) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
240
+ if (out < 6 * 86400000) return d.toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })
241
+ return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
242
+ }
243
+
244
+ // times are local and the head says so once, in .head-caption
245
+ function clock(ts) {
246
+ const at = ts ? Date.parse(ts) : NaN
247
+ return Number.isFinite(at) ? clockAt(at) : 'an unknown time'
248
+ }
249
+
250
+ function formatLastEvent(last) {
251
+ if (!last) return 'no events yet'
252
+ return truncate(`${last.type}: ${last.summary}`, 90)
253
+ }
254
+
255
+ function formatActor(actor) {
256
+ if (!actor) return 'baton'
257
+ if (actor.type === 'human') return `human:${actor.id || 'local'}`
258
+ if (actor.type === 'agent') return `agent:${actor.adapter || '?'}`
259
+ return 'baton'
260
+ }
261
+
262
+ function baseAdapterName(name) { return String(name || '').replace(/^fake-/, '') }
263
+ function agentClass(name) {
264
+ const base = baseAdapterName(name)
265
+ return AGENT_IDS.includes(base) ? base : 'fake'
266
+ }
267
+
268
+ // Kept by name: docs/board-guide.md references it. It returns the leg's state
269
+ // as a word now, because section 8 of the design deletes the check, the cross
270
+ // and the arrow from the copy, and the subset fonts carry none of them.
271
+ function stateGlyph(s) {
272
+ if (s === 'done') return 'done'
273
+ if (s === 'handed') return 'handed off'
274
+ if (s === 'failed') return 'failed'
275
+ if (s === 'active') return 'running'
276
+ return 'pending'
277
+ }
278
+
279
+ // ---- SSE ----
280
+ function setSseState(s, waitMs) {
281
+ const rule = document.getElementById('sse-dot')
282
+ const text = document.getElementById('sse-text')
283
+ const banner = document.getElementById('banner')
284
+ rule.className = `sse-rule is-${s}`
285
+ text.textContent = s === 'live' ? 'live'
286
+ : s === 'reconnecting' ? `reconnecting, next attempt in ${Math.round((waitMs || state.retryMs) / 1000)}s`
287
+ : 'connecting'
288
+ banner.hidden = s === 'live'
289
+ if (banner.hidden) return
290
+ banner.textContent = s === 'reconnecting' && state.lastHello
291
+ ? `Reconnecting to Leg. Last reading ${clock(state.lastHello)}.`
292
+ : `${s === 'reconnecting' ? 'Reconnecting' : 'Connecting'} to Leg on ${state.bind}.`
293
+ }
294
+
295
+ function connectSse() {
296
+ if (state.es) { try { state.es.close() } catch { /* ignore */ } }
297
+ const request = ++state.sseRequest
298
+ setSseState('connecting')
299
+ const token = getToken()
300
+ const url = token ? `/api/events?token=${encodeURIComponent(token)}` : '/api/events'
301
+ const es = new EventSource(url)
302
+ state.es = es
303
+ es.addEventListener('hello', (e) => {
304
+ if (state.es !== es || request !== state.sseRequest) return
305
+ state.retryMs = 1000
306
+ state.lastHello = new Date().toISOString()
307
+ setSseState('live')
308
+ const data = JSON.parse(e.data)
309
+ state.boardRevision += 1
310
+ state.cards = new Map(data.cards.map((c) => [c.card_id, c]))
311
+ renderBoard()
312
+ // nothing between the drop and this hello was replayed: an open detail
313
+ // region is as old as the gap
314
+ scheduleDrawerRefresh()
315
+ if (data.sessions) window.dispatchEvent(new CustomEvent('leg:sessions', { detail: data.sessions })); window.dispatchEvent(new CustomEvent('baton:sessions', { detail: data.sessions }));
316
+ })
317
+ es.addEventListener('sessions', (e) => { if (state.es === es && request === state.sseRequest) window.dispatchEvent(new CustomEvent('leg:sessions', { detail: JSON.parse(e.data) })); window.dispatchEvent(new CustomEvent('baton:sessions', { detail: JSON.parse(e.data) })); })
318
+ es.addEventListener('card', (e) => { if (state.es === es && request === state.sseRequest) upsertCard(JSON.parse(e.data)) })
319
+ es.addEventListener('removed', (e) => { if (state.es === es && request === state.sseRequest) dropCard(JSON.parse(e.data).card_id) })
320
+ es.addEventListener('event', (e) => { if (state.es === es && request === state.sseRequest) onLedgerEvent(JSON.parse(e.data)) })
321
+ es.addEventListener('health', (e) => { if (state.es === es && request === state.sseRequest) renderScheduler(JSON.parse(e.data).scheduler) })
322
+ es.onopen = () => { if (state.es === es && request === state.sseRequest) fetchCards() }
323
+ es.onerror = () => {
324
+ if (state.es !== es || request !== state.sseRequest) return
325
+ const wait = state.retryMs || 1000
326
+ setSseState('reconnecting', wait)
327
+ try { es.close() } catch { /* ignore */ }
328
+ setTimeout(() => { if (request === state.sseRequest) connectSse() }, wait)
329
+ state.retryMs = Math.min(wait * 2, 15000)
330
+ }
331
+ }
332
+
333
+ // ---- the page's own status lines ----
334
+ function renderScheduler(sched) {
335
+ if (!sched) return
336
+ document.getElementById('sched-status').textContent =
337
+ `scheduler ${sched.running ? 'running' : 'stopped'}, ${sched.max_concurrent} max`
338
+ }
339
+
340
+ // a guest on a shared board has no pipeline side: take it off the page
341
+ // instead of shouting 403 at them
342
+ const ownerOnly = (err) => /belongs to the owner of this machine/.test(err.message)
343
+ // `node`, not `el`: the loop variable shadowed this file's own el() DOM
344
+ // factory, so any line added inside these two loops that builds a node would
345
+ // fail against a name that is in scope everywhere else in the file
346
+ function guestMode() {
347
+ document.body.classList.add('guest')
348
+ state.isOwner = false
349
+ renderTokenMeta()
350
+ for (const node of [document.getElementById('board'), document.getElementById('new-card-btn'), document.querySelector('.masthead a[href="/floor"]')]) if (node) node.hidden = true
351
+ }
352
+
353
+ function ownerMode() {
354
+ document.body.classList.remove('guest')
355
+ for (const node of [document.getElementById('board'), document.getElementById('new-card-btn'), document.querySelector('.masthead a[href="/floor"]')]) if (node) node.hidden = false
356
+ }
357
+
358
+ const isGuest = () => document.body.classList.contains('guest')
359
+
360
+ async function fetchCards() {
361
+ if (isGuest()) return
362
+ const request = ++state.cardsRequest
363
+ const revision = state.boardRevision
364
+ try {
365
+ const data = await api('/api/cards')
366
+ if (request !== state.cardsRequest || revision !== state.boardRevision || isGuest()) return
367
+ state.cards = new Map(data.cards.map((c) => [c.card_id, c]))
368
+ renderBoard()
369
+ scheduleDrawerRefresh()
370
+ } catch (err) {
371
+ if (ownerOnly(err)) return guestMode()
372
+ toast(err.message)
373
+ }
374
+ }
375
+
376
+ async function loadHealth() {
377
+ const request = ++state.authRequest
378
+ try {
379
+ const data = await api('/api/health')
380
+ if (request !== state.authRequest) return false
381
+ if (data.bind) { state.bind = `${data.bind}:${data.port}`; state.bindKnown = true }
382
+ state.shareOn = !!(data.you && data.you.share && data.you.share.on)
383
+ state.isOwner = !(data.you && data.you.role && data.you.role !== 'owner')
384
+ state.healthKnown = true
385
+ renderBoardFacts(data)
386
+ renderTokenMeta()
387
+ // a guest is told who they are by health, which is open to them
388
+ if (data.you && data.you.role && data.you.role !== 'owner') { guestMode(); return false }
389
+ ownerMode()
390
+ renderScheduler(data.scheduler)
391
+ return true
392
+ } catch (err) {
393
+ if (request !== state.authRequest) return false
394
+ if (ownerOnly(err)) return guestMode()
395
+ // health is the only thing that can say this board is local; without it
396
+ // the token field stays on screen, or a viewer whose token is missing or
397
+ // wrong has no way to type one
398
+ state.isOwner = false
399
+ state.healthKnown = false
400
+ state.bindKnown = false
401
+ renderTokenMeta()
402
+ toast(err.message)
403
+ return false
404
+ }
405
+ }
406
+
407
+ // ---- 6.12 step 8: one row per card, no columns ----
408
+ // Background tasks are the third ledger cell: a count, a line of detail and a
409
+ // disclosure, the same shape as finished terminals and what landed. Nothing
410
+ // headless is what the board is for, so the rows live behind the button.
411
+ let cardsOpen = false
412
+ function toggleEmptyState() {
413
+ const empty = document.getElementById('empty-state')
414
+ const list = document.getElementById('columns')
415
+ const panel = document.getElementById('cards-drawer')
416
+ const hasCards = state.cards.size > 0
417
+ empty.hidden = hasCards
418
+ list.hidden = !hasCards
419
+ if (panel) panel.hidden = !(hasCards && cardsOpen)
420
+ }
421
+
422
+ function countCards(...statuses) {
423
+ let n = 0
424
+ for (const c of state.cards.values()) if (statuses.includes(c.status)) n += 1
425
+ return n
426
+ }
427
+
428
+ // G14: a verdict carries its volume
429
+ function cardsMeta() {
430
+ const total = state.cards.size
431
+ if (!total) return ''
432
+ const running = countCards('running', 'handing_off')
433
+ const queued = countCards('queued')
434
+ const backlog = countCards('backlog')
435
+ const waiting = countCards('waiting_human', 'needs_approval')
436
+ const finished = countCards('done', 'failed', 'killed')
437
+ const parts = []
438
+ if (running) parts.push(`${running} running`)
439
+ if (queued) parts.push(`${queued} queued`)
440
+ if (backlog) parts.push(`${backlog} in backlog`)
441
+ if (waiting) parts.push(`${waiting} waiting on you`)
442
+ if (finished) parts.push(`${finished} finished`)
443
+ return parts.length ? parts.join(', ') : `${total} cards, nothing is running`
444
+ }
445
+
446
+ function renderCardsMeta() {
447
+ const meta = document.querySelector('#board .region-meta')
448
+ const head = document.getElementById('cards-head')
449
+ const slot = document.querySelector('#board .ledger-actions')
450
+ const total = state.cards.size
451
+ if (meta) meta.textContent = total ? cardsMeta() : 'Nothing is queued. Leg starts the next login only when a terminal hands off.'
452
+ if (head) head.textContent = total ? `${total} background task${total === 1 ? '' : 's'}` : 'No background tasks'
453
+ if (!slot) return
454
+ const existing = document.getElementById('cards-toggle')
455
+ if (!total) { if (existing) existing.remove(); return }
456
+ const label = cardsOpen ? `Hide the ${total}` : `View ${total} card${total === 1 ? '' : 's'}`
457
+ if (existing) { existing.textContent = label; existing.setAttribute('aria-expanded', cardsOpen ? 'true' : 'false'); return }
458
+ const btn = el('button', { type: 'button', class: 'btn btn-secondary', id: 'cards-toggle', 'aria-expanded': cardsOpen ? 'true' : 'false', 'aria-controls': 'cards-drawer' }, [label])
459
+ btn.addEventListener('click', () => { cardsOpen = !cardsOpen; toggleEmptyState(); renderCardsMeta() })
460
+ slot.appendChild(btn)
461
+ }
462
+
463
+ function rowRank(card) {
464
+ const r = ROW_RANK[card.status]
465
+ return r === undefined ? 7 : r
466
+ }
467
+
468
+ function startedAt(card) {
469
+ const at = (card.active_run && card.active_run.started_at) || card.updated_at || card.created_at
470
+ const ms = at ? Date.parse(at) : NaN
471
+ return Number.isNaN(ms) ? 0 : ms
472
+ }
473
+
474
+ function orderedCards() {
475
+ return [...state.cards.values()].sort((a, b) => rowRank(a) - rowRank(b) || startedAt(a) - startedAt(b))
476
+ }
477
+
478
+ // Re-appending every row costs one DOM move each and can move a node out from
479
+ // under the cursor, so it happens only when the order string actually changed.
480
+ function orderRows() {
481
+ const list = document.getElementById('columns')
482
+ const order = orderedCards().map((c) => c.card_id)
483
+ const key = order.join(',')
484
+ if (key === state.rowOrder) return
485
+ state.rowOrder = key
486
+ for (const id of order) {
487
+ const row = state.cardNodes.get(id)
488
+ if (row) list.appendChild(row)
489
+ }
490
+ if (state.drawerId) placeDetail(state.drawerId)
491
+ }
492
+
493
+ function renderBoard() {
494
+ const list = document.getElementById('columns')
495
+ // the detail region is a child of this list while a row is expanded: park it
496
+ // back on the body so the wipe below does not take it out of the document
497
+ document.body.appendChild(document.getElementById('drawer'))
498
+ list.textContent = ''
499
+ state.cardNodes = new Map()
500
+ const order = orderedCards()
501
+ for (const card of order) renderRow(card)
502
+ state.rowOrder = order.map((c) => c.card_id).join(',')
503
+ if (state.drawerId) placeDetail(state.drawerId)
504
+ renderCardsMeta()
505
+ toggleEmptyState()
506
+ }
507
+
508
+ // the board is pushed a card for every write under its directory, log bytes
509
+ // included, so a push is only worth two API calls when the drawer's own
510
+ // content moved with it
511
+ function cardSignature(card) {
512
+ const stable = {}
513
+ for (const [k, v] of Object.entries(card)) if (!VOLATILE_CARD_FIELDS.includes(k)) stable[k] = v
514
+ return JSON.stringify(stable)
515
+ }
516
+
517
+ function drawerRefreshNeeded(prev, next) {
518
+ return !prev || cardSignature(prev) !== cardSignature(next)
519
+ }
520
+
521
+ // the cached tail belongs to one run: a new run replaces it, and a running
522
+ // one is re-read at most every LOG_REFRESH_MS so it does not freeze
523
+ function staleLogTail(cache, card, now) {
524
+ if (!cache) return 0
525
+ const run = card.active_run ? card.active_run.run : null
526
+ const otherRun = cache.runs_count !== card.runs_count || cache.run !== run
527
+ const growing = Boolean(card.active_run) && now - cache.at >= LOG_REFRESH_MS
528
+ if (!otherRun && !growing) return 0
529
+ return cache.expanded ? 200 : 8
530
+ }
531
+
532
+ function upsertCard(card) {
533
+ state.boardRevision += 1
534
+ const prev = state.cards.get(card.card_id)
535
+ const tail = staleLogTail(state.logState.get(card.card_id), card, Date.now())
536
+ renderRow(card)
537
+ orderRows()
538
+ renderCardsMeta()
539
+ toggleEmptyState()
540
+ if (tail) ensureLogLoaded(card.card_id, tail)
541
+ if (state.drawerId === card.card_id && drawerRefreshNeeded(prev, card)) scheduleDrawerRefresh()
542
+ }
543
+
544
+ function dropCard(id) {
545
+ state.boardRevision += 1
546
+ state.cards.delete(id)
547
+ state.logState.delete(id)
548
+ const root = state.cardNodes.get(id)
549
+ if (root) { root.remove(); state.cardNodes.delete(id) }
550
+ state.rowOrder = ''
551
+ renderCardsMeta()
552
+ toggleEmptyState()
553
+ if (state.drawerId === id) closeDrawer()
554
+ }
555
+
556
+ function onLedgerEvent(ev) {
557
+ const card = state.cards.get(ev.card_id)
558
+ if (card) {
559
+ card.last_event = { ts: ev.ts, type: ev.type, summary: ev.summary, actor: ev.actor }
560
+ const root = state.cardNodes.get(ev.card_id)
561
+ if (root && root.slots) {
562
+ const said = cardSentence(card)
563
+ root.slots.sentence.className = `sentence tone-${said.tone}`
564
+ root.slots.sentence.textContent = said.text
565
+ }
566
+ }
567
+ if (state.drawerId === ev.card_id) appendDrawerEvent(ev)
568
+ }
569
+
570
+ function tickElapsed() {
571
+ for (const [id, card] of state.cards) {
572
+ if (!card.active_run) continue
573
+ const root = state.cardNodes.get(id)
574
+ if (!root || !root.slots) continue
575
+ root.slots.elapsed.textContent = runElapsed(card)
576
+ }
577
+ }
578
+
579
+ // ---- the row ----
580
+ // 6.3: a 7px square and one of the nine shipped words. The mark is decoration;
581
+ // deleting it loses nothing, which is why it is aria-hidden.
582
+ function statusWord(card) {
583
+ let label = STATUS_LABELS[card.status] || card.status
584
+ if (card.status === 'running' && card.station_kind === 'land') label = 'landing'
585
+ else if (card.status === 'waiting_human' && card.pr_url) label = 'PR open'
586
+ return el('span', { class: 'status-word' }, [
587
+ el('span', { class: `mark tone-${STATUS_TONE[card.status] || 'idle'}`, 'aria-hidden': 'true' }),
588
+ label,
589
+ ])
590
+ }
591
+
592
+ // R2 line 2, exactly one sentence, the highest-ranked thing true about the
593
+ // card. Every branch names a station, a time, a count or the server's own
594
+ // reason, so the reader can check it.
595
+ function cardSentence(card) {
596
+ const last = card.last_event
597
+ if (card.bounce_reason && ['queued', 'running', 'handing_off', 'needs_approval'].includes(card.status)) {
598
+ const attempt = card.land_attempts ? ` on attempt ${card.land_attempts}` : ''
599
+ return { tone: 'danger', text: `Land bounced${attempt}: ${card.bounce_reason}. The worktree still holds every commit; nothing was lost.` }
600
+ }
601
+ if (card.status === 'queued' && last && last.type === 'blocked_by') return { tone: 'warn', text: last.summary }
602
+ if (['failed', 'killed'].includes(card.status)) {
603
+ return { tone: 'danger', text: `${card.status} at station ${card.station} after ${plural(card.runs_count || 0, 'run')}: ${last ? last.summary : 'no events yet'}` }
604
+ }
605
+ if (['waiting_human', 'needs_approval'].includes(card.status)) {
606
+ return { tone: 'warn', text: `waiting on you at station ${card.station} since ${clock(card.updated_at)}` }
607
+ }
608
+ if (card.status === 'done') return { tone: 'ok', text: `done after ${plural(card.runs_count || 0, 'run')}, ${last ? last.summary : 'no events yet'}` }
609
+ return { tone: 'muted', text: `${formatLastEvent(last)}${last ? `, ${clock(last.ts)}` : ''}` }
610
+ }
611
+
612
+ function shortWorktree(card) {
613
+ if (!card.worktree) return ''
614
+ const parts = String(card.worktree).split(/[\\/]/).filter(Boolean)
615
+ let i = parts.lastIndexOf('.leg-worktrees'); if (i === -1) i = parts.lastIndexOf('.baton-worktrees');
616
+ return i > 0 ? parts.slice(i - 1).join('/') : parts.slice(-2).join('/')
617
+ }
618
+
619
+ // 6.11: flat inline tokens, middot-separated by CSS, each agent name printed
620
+ // beside its own colour so identity never rides on hue alone.
621
+ function buildChainRail(card) {
622
+ const rail = el('div', { class: 'chain-rail' })
623
+ for (const entry of card.chain_view || []) {
624
+ rail.appendChild(el('span', { class: `chip chip-id-${agentClass(entry.adapter)}` }, [entry.adapter]))
625
+ const tone = entry.state === 'done' ? 'chip-state-ok' : entry.state === 'failed' ? 'chip-state-bad' : 'chip'
626
+ rail.appendChild(el('span', { class: `chip ${tone}` }, [stateGlyph(entry.state)]))
627
+ }
628
+ return rail
629
+ }
630
+
631
+ function buildLeases(card) {
632
+ const leases = card.leases && card.leases.length ? card.leases : ['**']
633
+ const blocked = card.status === 'queued' && card.last_event && card.last_event.type === 'blocked_by'
634
+ const wrap = el('div', { class: 'leases' }, leases.map((l) => el('span', { class: 'chip' }, [l])))
635
+ if (blocked) wrap.appendChild(el('span', { class: 'chip chip-state-warn' }, ['blocked by lease']))
636
+ return wrap
637
+ }
638
+
639
+ // 6.5: no confirm() anywhere on this board. The buttons swap for a sentence
640
+ // and two controls on --e4, focus moves to Cancel, Escape cancels. sessions.js
641
+ // carries a copy of this function: these two files have no module system, so a
642
+ // change here is a prompt to change the copy there.
643
+ let openConfirm = null
644
+ function confirmRow(question, verb, onYes, onCancel) {
645
+ const cancel = el('button', { type: 'button', class: 'btn btn-secondary', onclick: () => { openConfirm = null; onCancel() } }, ['Cancel'])
646
+ const row = el('div', { class: 'confirm-row' }, [
647
+ el('span', {}, [question]),
648
+ el('button', { type: 'button', class: 'btn btn-danger', onclick: () => { openConfirm = null; onYes() } }, [verb]),
649
+ cancel,
650
+ ])
651
+ openConfirm = onCancel
652
+ row.cancelBtn = cancel
653
+ return row
654
+ }
655
+
656
+ async function runAction(card, action) {
657
+ try {
658
+ await api(`/api/cards/${encodeURIComponent(card.card_id)}/${ACTION_PATHS[action]}`, { method: 'POST', body: {} })
659
+ } catch (err) {
660
+ toast(err.message)
661
+ }
662
+ }
663
+
664
+ async function removeCardAction(card) {
665
+ try {
666
+ await api(`/api/cards/${encodeURIComponent(card.card_id)}`, { method: 'DELETE' })
667
+ dropCard(card.card_id)
668
+ } catch (err) {
669
+ toast(err.message)
670
+ }
671
+ }
672
+
673
+ function askRemove(card, wrap) {
674
+ const name = card.title || card.card_id
675
+ const row = confirmRow(`Remove ${name}? Its card record, events and runs are deleted; the worktree is kept.`, 'Remove', () => removeCardAction(card), () => renderRow(card))
676
+ wrap.textContent = ''
677
+ wrap.appendChild(row)
678
+ row.cancelBtn.focus()
679
+ }
680
+
681
+ function buildActions(card) {
682
+ const wrap = el('div', { class: 'row-actions' })
683
+ const available = card.actions || []
684
+ for (const action of ACTION_ORDER) {
685
+ if (!available.includes(action) || !ACTION_LABELS[action]) continue
686
+ const label = ACTION_LABELS[action]
687
+ const cls = `btn ${ACTION_CLASS[action] || 'btn-secondary'}`
688
+ const run = action === 'reassign' ? () => openReassign(card, wrap) : () => runAction(card, action)
689
+ wrap.appendChild(el('button', { type: 'button', class: cls, 'aria-label': `${label} ${card.title || card.card_id}`, onclick: run }, [label]))
690
+ }
691
+ if (['done', 'failed', 'killed'].includes(card.status)) {
692
+ wrap.appendChild(el('button', { type: 'button', class: 'btn btn-danger', 'aria-label': `Remove ${card.title || card.card_id}`, onclick: () => askRemove(card, wrap) }, ['Remove']))
693
+ }
694
+ return wrap
695
+ }
696
+
697
+ // the reassign picker replaces the buttons in place, like the confirm row
698
+ async function openReassign(card, wrap) {
699
+ if (!state.adapters) {
700
+ try { state.adapters = (await api('/api/adapters')).adapters } catch (err) { toast(err.message); return }
701
+ }
702
+ const adapterSelect = el('select', { 'aria-label': `Reassign adapter for ${card.title || card.card_id}` })
703
+ const modeSelect = el('select', { 'aria-label': `Reassign mode for ${card.title || card.card_id}` })
704
+ for (const a of state.adapters) adapterSelect.appendChild(el('option', { value: a.name }, [a.name]))
705
+ if (card.active_adapter) adapterSelect.value = card.active_adapter
706
+ function populateModes() {
707
+ modeSelect.textContent = ''
708
+ const adapter = (state.adapters || []).find((a) => a.name === adapterSelect.value)
709
+ for (const m of adapter ? adapter.modes.allowed : []) modeSelect.appendChild(el('option', { value: m }, [m]))
710
+ if (adapter && adapter.modes.default) modeSelect.value = adapter.modes.default
711
+ }
712
+ adapterSelect.addEventListener('change', populateModes)
713
+ populateModes()
714
+ const apply = el('button', { type: 'button', class: 'btn btn-primary', onclick: async () => {
715
+ try {
716
+ await api(`/api/cards/${encodeURIComponent(card.card_id)}/reassign`, { method: 'POST', body: { adapter: adapterSelect.value, mode: modeSelect.value } })
717
+ renderRow(card)
718
+ } catch (err) { toast(err.message) }
719
+ } }, ['Apply'])
720
+ const cancel = el('button', { type: 'button', class: 'btn btn-secondary', onclick: () => renderRow(card) }, ['Cancel'])
721
+ wrap.textContent = ''
722
+ wrap.appendChild(el('div', { class: 'reassign-picker' }, [adapterSelect, modeSelect, apply, cancel]))
723
+ }
724
+
725
+ // 5.1: R1 who, R2 what, R3 where, R4 when-act-verdict. No object type moves a
726
+ // field to a different x, which is why a card row reads down the same four
727
+ // columns as an account and a terminal.
728
+ function buildRow(card) {
729
+ const said = cardSentence(card)
730
+ const chain = (card.chain_view && card.chain_view.length ? card.chain_view : card.chain) || []
731
+ const agent = card.active_adapter || (chain[0] ? chain[0].adapter : null)
732
+ const title = el('button', {
733
+ type: 'button', class: 'row-title', 'aria-expanded': state.drawerId === card.card_id ? 'true' : 'false',
734
+ onclick: () => expandRow(card.card_id),
735
+ }, [card.title || truncate(card.task, 60) || card.card_id])
736
+ const sentence = el('p', { class: `sentence tone-${said.tone}` }, [said.text])
737
+ const elapsed = el('span', { class: 'elapsed' }, [runElapsed(card)])
738
+ const cells = [
739
+ el('div', { class: 'r1' }, [
740
+ el('span', { class: agent ? `chip chip-id-${agentClass(agent)}` : 'chip' }, [agent || 'no agent']),
741
+ card.station && card.station !== '-' ? el('span', { class: 'chip' }, [card.station]) : null,
742
+ statusWord(card),
743
+ ]),
744
+ el('div', { class: 'r2' }, [title, sentence]),
745
+ el('div', { class: 'r3' }, [
746
+ el('p', { class: 'row-meta mono' }, [`${card.repo_name || 'no repo'}@${card.trunk || 'main'}`]),
747
+ card.worktree ? el('p', { class: 'row-meta mono', title: card.worktree }, [shortWorktree(card)]) : null,
748
+ buildChainRail(card),
749
+ buildLeases(card),
750
+ ]),
751
+ el('div', { class: 'r4' }, [elapsed, buildActions(card)]),
752
+ ]
753
+ return { cells, slots: { title, sentence, elapsed } }
754
+ }
755
+
756
+ function renderRow(card) {
757
+ state.cards.set(card.card_id, card)
758
+ let root = state.cardNodes.get(card.card_id)
759
+ if (!root) {
760
+ root = el('article', { class: 'row', 'data-card-id': card.card_id })
761
+ state.cardNodes.set(card.card_id, root)
762
+ document.getElementById('columns').appendChild(root)
763
+ state.rowOrder = ''
764
+ } else {
765
+ while (root.firstChild) root.removeChild(root.firstChild)
766
+ }
767
+ const built = buildRow(card)
768
+ for (const cell of built.cells) root.appendChild(cell)
769
+ root.slots = built.slots
770
+ return root
771
+ }
772
+
773
+ // ---- 6.6 the detail region: in flow under its row, never an overlay ----
774
+ async function ensureLogLoaded(id, tail) {
775
+ const cardAtRequest = state.cards.get(id)
776
+ if (!cardAtRequest) return
777
+ const runAtRequest = cardAtRequest.active_run ? cardAtRequest.active_run.run : null
778
+ const pending = state.logRequests.get(id)
779
+ if (pending && pending.tail >= tail && pending.run === runAtRequest && pending.runs_count === cardAtRequest.runs_count) return pending.promise
780
+ const request = { id: ++state.nextLogRequest, tail, run: runAtRequest, runs_count: cardAtRequest.runs_count }
781
+ const path = `/api/cards/${encodeURIComponent(id)}/log?tail=${tail}${runAtRequest == null ? '' : `&run=${encodeURIComponent(runAtRequest)}`}`
782
+ const promise = api(path).then((data) => {
783
+ const card = state.cards.get(id)
784
+ if (!card || state.logRequests.get(id) !== request || card.runs_count !== cardAtRequest.runs_count || (card.active_run ? card.active_run.run : null) !== runAtRequest) return
785
+ state.logState.set(id, {
786
+ lines: data.lines || [],
787
+ expanded: tail > 8,
788
+ at: Date.now(),
789
+ runs_count: card.runs_count,
790
+ run: runAtRequest,
791
+ })
792
+ if (state.drawerId === id) repaintDrawer()
793
+ }).catch((err) => {
794
+ if (state.logRequests.get(id) === request) toast(err.message)
795
+ }).finally(() => {
796
+ if (state.logRequests.get(id) === request) state.logRequests.delete(id)
797
+ })
798
+ request.promise = promise
799
+ state.logRequests.set(id, request)
800
+ return promise
801
+ }
802
+
803
+ function detailSection(title, note, body) {
804
+ return el('section', { class: 'detail-section' }, [
805
+ el('h3', { class: 'detail-heading' }, [title, note ? el('span', { class: 'detail-sub' }, [note]) : null]),
806
+ ].concat(body))
807
+ }
808
+
809
+ function kvRow(key, value) {
810
+ return [el('span', { class: 'kv-key' }, [key]), el('span', { class: 'kv-val' }, [value])]
811
+ }
812
+
813
+ function copyToClipboard(text) {
814
+ if (navigator.clipboard && navigator.clipboard.writeText) {
815
+ navigator.clipboard.writeText(text).catch(() => fallbackCopy(text))
816
+ } else {
817
+ fallbackCopy(text)
818
+ }
819
+ }
820
+
821
+ function fallbackCopy(text) {
822
+ const ta = document.createElement('textarea')
823
+ ta.value = text
824
+ ta.style.position = 'fixed'
825
+ ta.style.opacity = '0'
826
+ document.body.appendChild(ta)
827
+ ta.select()
828
+ try { document.execCommand('copy') } catch { /* ignore */ }
829
+ document.body.removeChild(ta)
830
+ }
831
+
832
+ // G11: the machine strings in full, never shortened. The row prints the short
833
+ // form; this is where the reader comes for the one they can paste.
834
+ function whereBlock(card, bundle) {
835
+ const rows = []
836
+ .concat(kvRow('repo', card.repo || 'no repo'))
837
+ .concat(kvRow('trunk', card.trunk || 'main'))
838
+ .concat(kvRow('worktree', card.worktree || 'no worktree yet'))
839
+ .concat(kvRow('leases', (card.leases && card.leases.length ? card.leases : ['**']).join(', ')))
840
+ if (bundle && bundle.path) rows.push(...kvRow('bundle', bundle.path))
841
+ const copy = el('button', { type: 'button', class: 'btn btn-secondary', onclick: () => copyToClipboard(card.worktree || card.repo || '') }, ['Copy worktree path'])
842
+ return [el('div', { class: 'kv' }, rows), copy]
843
+ }
844
+
845
+ function eventRow(e) {
846
+ return el('div', { class: 'turn' }, [
847
+ el('span', { class: 'turn-when' }, [clock(e.ts)]),
848
+ el('span', { class: 'turn-role' }, [`${e.type}, ${formatActor(e.actor)}`]),
849
+ el('p', {}, [e.summary]),
850
+ ])
851
+ }
852
+
853
+ // capped by count, and the cap names its volume: no nested scrollbar, the page
854
+ // just grows when the reader asks for more
855
+ function paintTimeline() {
856
+ const box = state.timelineEl
857
+ if (!box) return
858
+ box.textContent = ''
859
+ const all = state.drawerEvents
860
+ const shown = all.slice(-state.timelineCap).reverse()
861
+ for (const e of shown) box.appendChild(eventRow(e))
862
+ if (all.length > shown.length) {
863
+ box.appendChild(el('p', { class: 'cap-line' }, [
864
+ `showing ${shown.length} of ${plural(all.length, 'event')}`,
865
+ el('button', { type: 'button', class: 'btn btn-text', onclick: () => { state.timelineCap += 40; paintTimeline() } }, ['show 40 more']),
866
+ ]))
867
+ }
868
+ }
869
+
870
+ function appendDrawerEvent(ev) {
871
+ state.drawerEvents.push(ev)
872
+ paintTimeline()
873
+ }
874
+
875
+ function renderDrawer(detail) {
876
+ state.drawerDetail = detail
877
+ const content = document.getElementById('drawer-content')
878
+ content.textContent = ''
879
+ const card = detail.card || {}
880
+ const id = card.card_id || state.drawerId
881
+ content.appendChild(el('h2', { class: 'detail-heading' }, [card.title || id]))
882
+
883
+ content.appendChild(detailSection('Task', `card ${id}`, el('p', { class: 'drawer-task' }, [card.task || 'no task recorded'])))
884
+ content.appendChild(detailSection('Where', 'full paths, never shortened', whereBlock(card, detail.bundle)))
885
+
886
+ const stations = card.pipeline || []
887
+ const at = stations.findIndex((s) => s.name === card.station) + 1
888
+ const pipeline = []
889
+ for (const st of stations) pipeline.push(...kvRow(st.name, `${st.kind}${st.name === card.station ? ', this station' : ''}`))
890
+ content.appendChild(detailSection('Pipeline', at ? `station ${at} of ${stations.length}` : `${plural(stations.length, 'station')}, none started`, el('div', { class: 'kv' }, pipeline)))
891
+
892
+ const runs = detail.runs || []
893
+ const runRows = []
894
+ 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'}`))
895
+ content.appendChild(detailSection('Runs', plural(runs.length, 'run'), el('div', { class: 'kv' }, runRows.length ? runRows : kvRow('runs', 'no runs yet'))))
896
+
897
+ state.drawerEvents = detail.events || []
898
+ state.timelineEl = el('div', { class: 'drawer-timeline' })
899
+ content.appendChild(detailSection('Timeline', 'newest first', state.timelineEl))
900
+ paintTimeline()
901
+
902
+ const cache = state.logState.get(id)
903
+ const lines = cache ? cache.lines : []
904
+ content.appendChild(detailSection('Log', lines.length ? `last ${plural(lines.length, 'line')}` : 'no output yet',
905
+ el('pre', { class: 'log-pre' }, [lines.length ? lines.join('\n') : (card.runs_count ? 'the log for this run is empty' : 'no runs yet')])))
906
+ }
907
+
908
+ function repaintDrawer() {
909
+ if (state.drawerDetail) renderDrawer(state.drawerDetail)
910
+ }
911
+
912
+ function refreshDrawerAfterUpdate() {
913
+ // the card summary changed while its row is expanded; the station marker and
914
+ // the run list go stale with it, so refetch the detail
915
+ if (state.drawerId) openDrawer(state.drawerId)
916
+ }
917
+
918
+ // openDrawer costs two requests, one of them a 64 KB log read: a burst of
919
+ // pushes (or a reconnect, which refetches by two paths) gets one refresh
920
+ function scheduleDrawerRefresh() {
921
+ if (!state.drawerId || state.drawerTimer) return
922
+ state.drawerTimer = setTimeout(() => {
923
+ state.drawerTimer = null
924
+ refreshDrawerAfterUpdate()
925
+ }, DRAWER_REFRESH_MS)
926
+ }
927
+
928
+ // The detail region is MOVED under the row being expanded instead of floating
929
+ // over it, so the page keeps exactly one scroll container. A row that has not
930
+ // been rendered yet has nowhere to put it, and so does the stub DOM the tests
931
+ // run against, in which case it stays where the markup left it.
932
+ function placeDetail(id) {
933
+ const row = state.cardNodes.get(id)
934
+ const detail = document.getElementById('drawer')
935
+ if (row && typeof row.after === 'function') row.after(detail)
936
+ return detail
937
+ }
938
+
939
+ function markRow(id, expanded) {
940
+ const root = id ? state.cardNodes.get(id) : null
941
+ if (!root || !root.slots) return null
942
+ if (expanded) root.classList.add('is-selected')
943
+ else root.classList.remove('is-selected')
944
+ root.slots.title.setAttribute('aria-expanded', expanded ? 'true' : 'false')
945
+ return root
946
+ }
947
+
948
+ async function openDrawer(id) {
949
+ const previous = state.drawerId
950
+ if (previous && previous !== id) markRow(previous, false)
951
+ state.drawerId = id
952
+ state.timelineCap = TIMELINE_CAP
953
+ const request = ++state.drawerRequest
954
+ const drawer = document.getElementById('drawer')
955
+ drawer.hidden = false
956
+ drawer.setAttribute('aria-hidden', 'false')
957
+ placeDetail(id)
958
+ markRow(id, true)
959
+ try {
960
+ const [detail, log] = await Promise.all([
961
+ api(`/api/cards/${encodeURIComponent(id)}`),
962
+ api(`/api/cards/${encodeURIComponent(id)}/log?tail=2000`),
963
+ ])
964
+ if (state.drawerId !== id || request !== state.drawerRequest) return
965
+ const card = state.cards.get(id)
966
+ state.logState.set(id, {
967
+ lines: log.lines || [],
968
+ expanded: true,
969
+ at: Date.now(),
970
+ runs_count: card ? card.runs_count : null,
971
+ run: card && card.active_run ? card.active_run.run : null,
972
+ })
973
+ renderDrawer(detail)
974
+ } catch (err) {
975
+ toast(err.message)
976
+ }
977
+ }
978
+
979
+ function closeDrawer() {
980
+ const open = state.drawerId
981
+ state.drawerId = null
982
+ state.drawerRequest += 1
983
+ state.drawerDetail = null
984
+ state.timelineEl = null
985
+ const drawer = document.getElementById('drawer')
986
+ drawer.hidden = true
987
+ drawer.setAttribute('aria-hidden', 'true')
988
+ return markRow(open, false)
989
+ }
990
+
991
+ // only one region is expanded at a time, and the control that opened it gets
992
+ // the focus back when it collapses
993
+ function expandRow(id) {
994
+ if (state.drawerId === id) return collapseRow()
995
+ openDrawer(id)
996
+ }
997
+
998
+ function collapseRow() {
999
+ const root = closeDrawer()
1000
+ if (root && root.slots) root.slots.title.focus()
1001
+ }
1002
+
1003
+ // ---- new card dialog ----
1004
+ function newCardDialogEls() {
1005
+ return {
1006
+ dialog: document.getElementById('new-card-dialog'),
1007
+ form: document.getElementById('new-card-form'),
1008
+ error: document.getElementById('new-card-error'),
1009
+ repo: document.getElementById('nc-repo'),
1010
+ task: document.getElementById('nc-task'),
1011
+ firstAgent: document.getElementById('nc-first-agent'),
1012
+ firstControls: document.getElementById('nc-first-controls'),
1013
+ testAdapter: document.getElementById('nc-test-adapter'),
1014
+ fallbackSummary: document.getElementById('nc-fallback-summary'),
1015
+ pipeline: document.getElementById('nc-pipeline'),
1016
+ customPipeline: document.getElementById('nc-custom-pipeline'),
1017
+ chainRows: document.getElementById('nc-chain-rows'),
1018
+ addRowBtn: document.getElementById('nc-add-row'),
1019
+ leases: document.getElementById('nc-leases'),
1020
+ trunk: document.getElementById('nc-trunk'),
1021
+ landMode: document.getElementById('nc-land-mode'),
1022
+ testCommand: document.getElementById('nc-test-command'),
1023
+ title: document.getElementById('nc-title'),
1024
+ queue: document.getElementById('nc-queue'),
1025
+ cancel: document.getElementById('nc-cancel'),
1026
+ }
1027
+ }
1028
+
1029
+ function adapterLabel(adapter) { return adapter.fake ? `${adapter.name} (test/demo)` : adapter.name }
1030
+
1031
+ // the agents already named in this dialog: the first agent and every fallback
1032
+ // row already added. A fallback set to one of these can never fire.
1033
+ function chosenAdapters(ui) {
1034
+ const used = [ui.testAdapter.value || ui.firstAgent.value]
1035
+ for (const row of ui.chainRows.children) if (row.fields) used.push(row.fields.adapterSelect.value)
1036
+ return used.filter(Boolean)
1037
+ }
1038
+
1039
+ function addChainRow(ui, { adapter: preferred = null, first = false } = {}) {
1040
+ const adapterSelect = el('select', { 'aria-label': 'Chain adapter' })
1041
+ const adapters = [...(state.adapters || [])].sort((a, b) => Number(a.fake) - Number(b.fake))
1042
+ for (const a of adapters) adapterSelect.appendChild(el('option', { value: a.name }, [adapterLabel(a)]))
1043
+ // Add fallback agent used to default to the first option in the list, which
1044
+ // is normally the agent already chosen as First agent: the summary line then
1045
+ // read "Baton tries claude, then agy, then claude", a fallback that cannot
1046
+ // fire. rebuildDefaultFallbacks already applies this filter.
1047
+ if (!preferred && !first) {
1048
+ const used = chosenAdapters(ui)
1049
+ preferred = adapters.filter((a) => !a.fake).map((a) => a.name).find((name) => !used.includes(name)) || null
1050
+ }
1051
+ if (preferred && adapters.some((a) => a.name === preferred)) adapterSelect.value = preferred
1052
+ const modeSelect = el('select', { 'aria-label': 'Chain mode' })
1053
+ const approveCheckbox = el('input', { type: 'checkbox', 'aria-label': 'Approve before this leg' })
1054
+ const approveLabel = el('label', {}, [approveCheckbox, ' approval before start'])
1055
+ const turnsInput = el('input', { type: 'number', min: '0', 'aria-label': 'Max turns', placeholder: 'max turns' })
1056
+ const fakeInput = el('input', { type: 'text', 'aria-label': 'Scripted test behavior', placeholder: 'test behavior' })
1057
+ const removeBtn = first ? null : el('button', { type: 'button', class: 'btn btn-danger', 'aria-label': 'Remove fallback agent' }, ['Remove'])
1058
+ const title = el('span', { class: 'fallback-row-title' }, [first ? `First: ${preferred}` : `Fallback ${ui.chainRows.children.length + 1}`])
1059
+ const row = el('div', { class: `chain-row${first ? '' : ' fallback-row'}` }, [title, adapterSelect, modeSelect, approveLabel, turnsInput, fakeInput, removeBtn])
1060
+ if (first) adapterSelect.hidden = true
1061
+ if (removeBtn) removeBtn.addEventListener('click', () => { row.remove(); refreshFallbackSummary(ui) })
1062
+
1063
+ function populateModes() {
1064
+ modeSelect.textContent = ''
1065
+ const adapter = (state.adapters || []).find((a) => a.name === adapterSelect.value)
1066
+ const allowed = adapter ? adapter.modes.allowed : []
1067
+ for (const m of allowed) modeSelect.appendChild(el('option', { value: m }, [MODE_LABELS[m] ? `${MODE_LABELS[m]} (${m})` : m]))
1068
+ if (adapter && adapter.modes.default) modeSelect.value = adapter.modes.default
1069
+ fakeInput.hidden = !(adapter && adapter.fake)
1070
+ }
1071
+ adapterSelect.addEventListener('change', populateModes)
1072
+ // the summary sentence names the fallback agents by row, so it has to
1073
+ // follow a row whose agent the reader changed after it was added
1074
+ if (!first) adapterSelect.addEventListener('change', () => refreshFallbackSummary(ui))
1075
+ populateModes()
1076
+
1077
+ row.fields = { adapterSelect, modeSelect, approveCheckbox, turnsInput, fakeInput }
1078
+ ;(first ? ui.firstControls : ui.chainRows).appendChild(row)
1079
+ refreshFallbackSummary(ui)
1080
+ return row
1081
+ }
1082
+
1083
+ function refreshFallbackSummary(ui) {
1084
+ const names = [...ui.chainRows.children].filter((row) => row.fields).map((row) => row.fields.adapterSelect.value)
1085
+ ui.fallbackSummary.textContent = names.length
1086
+ ? `If the first agent cannot continue, Leg tries ${names.join(', then ')} in this order.`
1087
+ : 'No fallback agent is set. Add one under Advanced options if another agent should take over.'
1088
+ }
1089
+
1090
+ function rebuildFirstControls(ui) {
1091
+ ui.firstControls.textContent = ''
1092
+ addChainRow(ui, { adapter: ui.testAdapter.value || ui.firstAgent.value, first: true })
1093
+ }
1094
+
1095
+ function rebuildDefaultFallbacks(ui) {
1096
+ ui.chainRows.textContent = ''
1097
+ const first = ui.firstAgent.value
1098
+ const real = (state.adapters || []).filter((a) => !a.fake).map((a) => a.name)
1099
+ const preferred = AGENT_IDS.filter((name) => real.includes(name) && name !== first)
1100
+ for (const adapter of preferred) addChainRow(ui, { adapter })
1101
+ refreshFallbackSummary(ui)
1102
+ }
1103
+
1104
+ async function openNewCardDialog() {
1105
+ if (!state.adapters) {
1106
+ try { state.adapters = (await api('/api/adapters')).adapters } catch (err) { toast(err.message); return }
1107
+ }
1108
+ const ui = newCardDialogEls()
1109
+ ui.form.reset()
1110
+ ui.error.hidden = true
1111
+ ui.error.textContent = ''
1112
+ ui.customPipeline.hidden = ui.pipeline.value !== 'custom'
1113
+ const real = (state.adapters || []).filter((a) => !a.fake)
1114
+ ui.firstAgent.textContent = ''
1115
+ for (const adapter of real) ui.firstAgent.appendChild(el('option', { value: adapter.name }, [adapter.name]))
1116
+ if (real.some((a) => a.name === 'claude')) ui.firstAgent.value = 'claude'
1117
+ ui.testAdapter.textContent = ''
1118
+ ui.testAdapter.appendChild(el('option', { value: '' }, ['Use the real first agent above']))
1119
+ for (const adapter of (state.adapters || []).filter((a) => a.fake)) ui.testAdapter.appendChild(el('option', { value: adapter.name }, [adapterLabel(adapter)]))
1120
+ ui.firstControls.textContent = ''
1121
+ ui.chainRows.textContent = ''
1122
+ rebuildFirstControls(ui)
1123
+ rebuildDefaultFallbacks(ui)
1124
+ ui.dialog.showModal()
1125
+ }
1126
+
1127
+ async function submitNewCard(e) {
1128
+ e.preventDefault()
1129
+ const ui = newCardDialogEls()
1130
+ const rows = [...ui.firstControls.children, ...ui.chainRows.children]
1131
+ .filter((row) => row.fields)
1132
+ .map((row) => ({
1133
+ adapter: row.fields.adapterSelect.value,
1134
+ mode: row.fields.modeSelect.value,
1135
+ approve: row.fields.approveCheckbox.checked,
1136
+ turns: row.fields.turnsInput.value.trim(),
1137
+ fake: row.fields.fakeInput.hidden ? '' : row.fields.fakeInput.value.trim(),
1138
+ }))
1139
+ .filter((r) => r.adapter)
1140
+
1141
+ const body = {
1142
+ repo: ui.repo.value.trim(),
1143
+ task: ui.task.value.trim(),
1144
+ chain: rows.map((r) => r.adapter).join(','),
1145
+ pipeline: ui.pipeline.value === 'custom' ? ui.customPipeline.value.trim() : ui.pipeline.value,
1146
+ leases: ui.leases.value.trim(),
1147
+ trunk: ui.trunk.value.trim() || 'main',
1148
+ land_mode: ui.landMode.value,
1149
+ test_command: ui.testCommand.value.trim(),
1150
+ title: ui.title.value.trim(),
1151
+ mode: rows.filter((r) => r.mode).map((r) => `${r.adapter}=${r.mode}`).join(','),
1152
+ approve: rows.filter((r) => r.approve).map((r) => r.adapter).join(','),
1153
+ maxTurns: rows.filter((r) => r.turns).map((r) => `${r.adapter}=${r.turns}`).join(','),
1154
+ fake_mode: rows.filter((r) => r.fake).map((r) => `${r.adapter}=${r.fake}`).join(','),
1155
+ queue: ui.queue.checked,
1156
+ }
1157
+ try {
1158
+ const data = await api('/api/cards', { method: 'POST', body })
1159
+ ui.dialog.close()
1160
+ upsertCard(data.card)
1161
+ } catch (err) {
1162
+ ui.error.hidden = false
1163
+ ui.error.textContent = err.message
1164
+ }
1165
+ }
1166
+
1167
+ function initNewCardDialog() {
1168
+ const ui = newCardDialogEls()
1169
+ ui.form.addEventListener('submit', submitNewCard)
1170
+ ui.cancel.addEventListener('click', () => ui.dialog.close())
1171
+ ui.pipeline.addEventListener('change', () => { ui.customPipeline.hidden = ui.pipeline.value !== 'custom' })
1172
+ ui.firstAgent.addEventListener('change', () => { ui.testAdapter.value = ''; rebuildFirstControls(ui); rebuildDefaultFallbacks(ui) })
1173
+ ui.testAdapter.addEventListener('change', () => rebuildFirstControls(ui))
1174
+ ui.addRowBtn.addEventListener('click', () => addChainRow(ui))
1175
+ }
1176
+
1177
+ // ---- 6.10 settings: the last region of the page, in flow ----
1178
+ // "<host>:<port>", possibly "[::1]:4747"
1179
+ function bindHost(bind) {
1180
+ const s = String(bind ?? '').trim()
1181
+ const bracketed = s.match(/^\[(.+)\]:\d+$/)
1182
+ if (bracketed) return bracketed[1].toLowerCase()
1183
+ return s.replace(/:\d+$/, '').toLowerCase()
1184
+ }
1185
+
1186
+ // A loopback bind with share off can only ever be reached from this machine,
1187
+ // and src/auth.mjs lets that request in with no token at all. Asking for one
1188
+ // there is a question with no answer, so the field is not drawn. Three things
1189
+ // put it back: a token already stored (which has to stay clearable), a guest
1190
+ // or a health call that never answered (neither has established local), and
1191
+ // any bind or share that lets a second machine in.
1192
+ function tokenPanel({ bind, shareOn, isOwner, token, healthKnown, bindKnown }) {
1193
+ const at = bindKnown ? ` to ${bind}` : ''
1194
+ // nothing has been heard back from the server: do not name an address that
1195
+ // is only this file's default, and do not claim the board is local
1196
+ if (!healthKnown) {
1197
+ return {
1198
+ hidden: false,
1199
+ meta: token ? 'That token was refused. Check it, or clear the field to sign out.' : 'This board wants a token before it will say anything.',
1200
+ }
1201
+ }
1202
+ const localOnly = bindKnown && LOOPBACK_HOSTS.includes(bindHost(bind)) && !shareOn && isOwner && !token
1203
+ if (localOnly) return { hidden: true, meta: `Local only, ${bind} answers this machine and nothing else` }
1204
+ // share on, no token, and the server still called us the owner: auth.mjs
1205
+ // recognised this browser by its loopback address. Saying "unauthenticated"
1206
+ // there was wrong, and it was the line the owner read on their own board.
1207
+ if (shareOn && isOwner && !token) {
1208
+ return { hidden: false, meta: `Signed in as the owner from this machine. A token is only needed from somewhere else.` }
1209
+ }
1210
+ return {
1211
+ hidden: false,
1212
+ meta: token
1213
+ ? `API token set, sent${at} as a bearer token`
1214
+ : `No API token set, requests reach${at || ' this board'} unauthenticated`,
1215
+ }
1216
+ }
1217
+
1218
+ function renderTokenMeta() {
1219
+ const panel = tokenPanel({ bind: state.bind, shareOn: state.shareOn, isOwner: state.isOwner, token: getToken(), healthKnown: state.healthKnown, bindKnown: state.bindKnown })
1220
+ const field = document.getElementById('token-field')
1221
+ if (field) field.hidden = panel.hidden
1222
+ const meta = document.querySelector('.region-settings .region-meta')
1223
+ if (meta) meta.textContent = panel.meta
1224
+ }
1225
+
1226
+ function renderBoardFacts(health) {
1227
+ const box = document.querySelector('.region-settings .board-facts')
1228
+ if (!box) return
1229
+ box.textContent = ''
1230
+ const you = health.you || {}
1231
+ const share = you.share || { on: false, people: 0 }
1232
+ const sched = health.scheduler
1233
+ const lines = [
1234
+ `leg ${health.version}, bound to ${state.bind}`,
1235
+ `signed in as ${you.name || 'local'}, ${you.role || 'owner'}`,
1236
+ share.on ? `share on, ${share.people === 1 ? '1 person' : `${share.people} people`}` : 'share off, nobody invited',
1237
+ ]
1238
+ // a guest is sent neither the scheduler nor the paths: print nothing rather
1239
+ // than a number this board was not told
1240
+ if (sched) lines.push(sched.running ? `scheduler running, pid ${sched.pid}, ${sched.max_concurrent} max` : `scheduler stopped, ${sched.max_concurrent} max`)
1241
+ if (health.home) lines.push(`board home ${health.home}`)
1242
+ for (const line of lines) box.appendChild(el('p', {}, [line]))
1243
+ }
1244
+
1245
+ function initSettings() {
1246
+ const input = document.getElementById('token-input')
1247
+ input.value = getToken()
1248
+ input.addEventListener('change', async () => {
1249
+ const v = input.value.trim()
1250
+ if (v) { localStorage.setItem('legToken', v); localStorage.setItem('batonToken', v); }
1251
+ else { localStorage.removeItem('legToken'); localStorage.removeItem('batonToken'); }
1252
+ renderTokenMeta()
1253
+ state.boardRevision += 1
1254
+ state.cardsRequest += 1
1255
+ state.sseRequest += 1
1256
+ if (state.es) { try { state.es.close() } catch { /* ignore */ } }
1257
+ state.es = null
1258
+ const owner = await loadHealth()
1259
+ if (owner) fetchCards()
1260
+ connectSse()
1261
+ })
1262
+ const show = document.getElementById('token-show')
1263
+ if (show) {
1264
+ show.addEventListener('click', () => {
1265
+ const hidden = input.type !== 'text'
1266
+ input.type = hidden ? 'text' : 'password'
1267
+ show.textContent = hidden ? 'Hide' : 'Show'
1268
+ })
1269
+ }
1270
+ const disclosure = document.querySelector('.region-settings .disclosure')
1271
+ const body = document.getElementById('settings-body')
1272
+ if (disclosure && body) {
1273
+ disclosure.addEventListener('click', () => {
1274
+ const opening = body.hidden
1275
+ body.hidden = !opening
1276
+ disclosure.setAttribute('aria-expanded', opening ? 'true' : 'false')
1277
+ disclosure.textContent = opening ? 'Hide settings' : 'Show settings'
1278
+ })
1279
+ }
1280
+ renderTokenMeta()
1281
+ }
1282
+
1283
+ // ---- init ----
1284
+ async function init() {
1285
+ initSettings()
1286
+ initNewCardDialog()
1287
+ document.getElementById('new-card-btn').addEventListener('click', () => openNewCardDialog())
1288
+ document.getElementById('empty-new-card-btn').addEventListener('click', () => openNewCardDialog())
1289
+ document.getElementById('drawer-close').addEventListener('click', () => collapseRow())
1290
+ document.addEventListener('keydown', (e) => {
1291
+ if (e.key !== 'Escape') return
1292
+ if (openConfirm) { const cancel = openConfirm; openConfirm = null; cancel(); return }
1293
+ if (state.drawerId) collapseRow()
1294
+ })
1295
+ // health first: it says whether this human owns the pipeline side at all
1296
+ await loadHealth()
1297
+ fetchCards()
1298
+ connectSse()
1299
+ setInterval(tickElapsed, 1000)
1300
+ }
1301
+
1302
+ document.addEventListener('DOMContentLoaded', init)
1303
+
1304
+ // test seam: node:test runs this file with a stub document and reads the
1305
+ // pure update decisions back out; in a browser there is no `module`
1306
+ if (typeof module !== 'undefined') module.exports = { drawerRefreshNeeded, staleLogTail, tokenPanel, bindHost }
1307
+ })()