@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
package/src/server.mjs ADDED
@@ -0,0 +1,845 @@
1
+ #!/usr/bin/env node
2
+ // server — the board's HTTP + SSE backend. Ledger-backed: every handler reads
3
+ // card.json / events-*.jsonl on demand (no module-level card store), so a
4
+ // restart shows the same board and a second process sees the same truth.
5
+ // BATON_BIND (127.0.0.1) + BATON_PORT (4747) + BATON_TOKEN are the
6
+ // multiplayer seams (src/auth.mjs).
7
+ import http from 'node:http'
8
+ import { spawnSync } from 'node:child_process'
9
+ import { existsSync, readFileSync, statSync, rmSync, watch as fsWatch, mkdirSync, openSync, fstatSync, readSync, closeSync } from 'node:fs'
10
+ import { join, dirname, resolve, extname, sep } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+ import { checkBind, authorize, remoteAddress, presentedToken, isLoopback, isLoopbackRequest, tokenMatches } from './auth.mjs'
13
+ import { readShare, isOn as shareIsOn, sharePath, identify, personNamed } from './share.mjs'
14
+ import { createLimiter } from './ratelimit.mjs'
15
+ import { realPath, canonPath } from './fsx.mjs'
16
+ import { listCards, readCard, readRuns, readEvents, cardDir, home } from './store.mjs'
17
+ import { humanAction } from './orchestrator.mjs'
18
+ import { createCard, CardInputError } from './cards.mjs'
19
+ import { IllegalTransition, availableActions } from './chain.mjs'
20
+ import { held } from './leases.mjs'
21
+ import { PRESETS } from './presets.mjs'
22
+ import { names as adapterNames, get as getAdapter, isFake } from './adapters/index.mjs'
23
+ import { createScheduler, schedulerStatus, MAX_CONCURRENT } from './scheduler.mjs'
24
+ import { remove as removeWorktree, worktreeDirty } from './worktree.mjs'
25
+ import { scrub } from './runner.mjs'
26
+ import { resolveChb } from './handoff.mjs'
27
+ import { listSessions, readSession, readEvents as readSessionEvents, requestControl, removeSession, overlaps, isActive, sessionsRoot, reapLost, readLand, readLandings, readRequests, writeRequests, appendEvent as appendSessionEvent, updateSession, HANDOFF_ORDER_CAPABILITY } from './sessions.mjs'
28
+ import { sessionDetail, sessionDiff, DiffInputError } from './session-detail.mjs'
29
+ import { refreshPointers } from './resume.mjs'
30
+ import { landSession, landBlocker, landingNow, pruneSessionWorktree } from './land.mjs'
31
+ import { readUsage, recordUsage, usageIsStale, candidates, isAvailable } from './usage.mjs'
32
+ import { readAccounts, envFor, LAYOUT } from './accounts.mjs'
33
+ import { readCodexUsage } from './taps/codex.mjs'
34
+ import { readPreferences, writePreferences, normalizeHandoffOrder, requireHandoffOrder } from './preferences.mjs'
35
+
36
+ const SELF = fileURLToPath(import.meta.url)
37
+ const BOARD_DIR = join(dirname(SELF), 'board')
38
+ const VERSION = JSON.parse(readFileSync(join(dirname(SELF), '..', 'package.json'), 'utf8')).version
39
+ const DEFAULT_ORDER = ['plan', 'build', 'review', 'test', 'land']
40
+ const TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json; charset=utf-8', '.woff2': 'font/woff2' }
41
+
42
+ const log = (msg) => { const q = process.env.LEG_QUIET ?? process.env.BATON_QUIET; if (q !== '1') process.stdout.write(`[board] ${new Date().toISOString()} ${msg}\n`) }
43
+
44
+ // ---- read models ----
45
+ function lastEventOf(id) {
46
+ const evs = readEvents(id)
47
+ return evs.length ? evs[evs.length - 1] : null
48
+ }
49
+
50
+ export function columnsFor(cards) {
51
+ const names = new Set()
52
+ for (const c of cards) for (const s of c.pipeline ?? []) names.add(s.name)
53
+ const ordered = [...DEFAULT_ORDER.filter((n) => names.has(n)), ...[...names].filter((n) => !DEFAULT_ORDER.includes(n)).sort()]
54
+ return ['backlog', 'queued', ...ordered, 'done', 'failed']
55
+ }
56
+
57
+ export function columnOf(card) {
58
+ if (['backlog', 'queued', 'done'].includes(card.status)) return card.status
59
+ if (['failed', 'killed'].includes(card.status)) return 'failed'
60
+ return card.station
61
+ }
62
+
63
+ export function summarize(card) {
64
+ const st = (card.pipeline ?? []).find((s) => s.name === card.station) ?? null
65
+ const last = lastEventOf(card.card_id)
66
+ const runs = readRuns(card.card_id)
67
+ const activeRun = runs.find((r) => ['launching', 'running'].includes(r.status)) ?? null
68
+ // Once a station is over (done/failed) card.leg is reset, so the rail is
69
+ // rebuilt from the legs that actually started at this station.
70
+ const terminal = ['done', 'failed', 'killed'].includes(card.status)
71
+ const startedLegs = terminal && st?.kind === 'agent'
72
+ ? readEvents(card.card_id).filter((ev) => ev.type === 'leg_started' && ev.station === card.station).map((ev) => ev.leg)
73
+ : []
74
+ const lastLeg = startedLegs.length ? Math.max(...startedLegs) : card.leg
75
+ // card.leg is reset when the station ends, so a finished card read its adapter
76
+ // off chain[0] and reported the agent that STARTED the work as the one that
77
+ // did it. On a handoff card that is the wrong name on the finished row.
78
+ const entry = st?.kind === 'agent' ? st.chain[terminal ? lastLeg : card.leg] ?? null : null
79
+ const legState = (i) => {
80
+ if (terminal) {
81
+ if (!startedLegs.includes(i)) return 'pending'
82
+ if (i < lastLeg) return 'handed'
83
+ return card.status === 'done' ? 'done' : 'failed'
84
+ }
85
+ if (i < card.leg) return 'handed'
86
+ if (i > card.leg) return 'pending'
87
+ return ['running', 'handing_off'].includes(card.status) ? 'active' : 'pending'
88
+ }
89
+ return {
90
+ ...card,
91
+ column: columnOf(card),
92
+ station_kind: st?.kind ?? null,
93
+ active_adapter: entry?.adapter ?? null,
94
+ active_mode: entry?.mode ?? null,
95
+ chain_view: st?.kind === 'agent' ? st.chain.map((e, i) => ({
96
+ adapter: e.adapter, mode: e.mode ?? null, approve: Boolean(e.approve),
97
+ // a leg before the last one ended in a handoff (only the last leg can complete a station)
98
+ state: legState(i),
99
+ })) : [],
100
+ actions: availableActions(card),
101
+ last_event: last ? { ts: last.ts, type: last.type, summary: last.summary, actor: last.actor } : null,
102
+ runs_count: runs.length,
103
+ active_run: activeRun ? { run: activeRun.run, adapter: activeRun.adapter, started_at: activeRun.started_at } : null,
104
+ elapsed_ms: activeRun ? Date.now() - Date.parse(activeRun.started_at) : null,
105
+ repo_name: card.repo ? card.repo.split(/[\\/]/).filter(Boolean).pop() : null,
106
+ }
107
+ }
108
+
109
+ const LOG_TAIL_BYTES = 65536
110
+
111
+ // Read at most the last `maxBytes` of a file without loading the whole thing
112
+ // (run logs can grow large; the board only ever shows a tail of them).
113
+ function readFileTail(path, maxBytes = LOG_TAIL_BYTES) {
114
+ const fd = openSync(path, 'r')
115
+ try {
116
+ const size = fstatSync(fd).size
117
+ const start = Math.max(0, size - maxBytes)
118
+ const len = size - start
119
+ if (!len) return ''
120
+ const buf = Buffer.alloc(len)
121
+ readSync(fd, buf, 0, len, start)
122
+ return buf.toString('utf8')
123
+ } finally {
124
+ closeSync(fd)
125
+ }
126
+ }
127
+
128
+ function logTail(id, run, tail = 200) {
129
+ const runs = readRuns(id)
130
+ const r = run ? runs.find((x) => x.run === run) : runs[runs.length - 1]
131
+ if (!r) return { run: null, lines: [] }
132
+ const dir = join(cardDir(id), 'runs', String(r.run))
133
+ const lines = []
134
+ for (const f of ['out.log', 'err.log']) {
135
+ const p = join(dir, f)
136
+ if (!existsSync(p)) continue
137
+ const text = readFileTail(p).trim()
138
+ if (text) lines.push(...text.split('\n').map((l) => `${f === 'err.log' ? 'stderr ' : ''}${l}`))
139
+ }
140
+ return { run: r.run, adapter: r.adapter, status: r.status, outcome: r.outcome ?? null, lines: scrub(lines.slice(-tail).join('\n')).split('\n') }
141
+ }
142
+
143
+ function floor(cards) {
144
+ const running = cards.filter((c) => ['running', 'handing_off'].includes(c.status)).map((c) => {
145
+ const s = summarize(c)
146
+ return { card_id: c.card_id, title: c.title, station: c.station, status: c.status, adapter: s.active_adapter, leg: c.leg, leases: c.leases?.length ? c.leases : ['**'], last_event: s.last_event, since: s.active_run?.started_at ?? c.updated_at, elapsed_ms: s.elapsed_ms, repo_name: s.repo_name }
147
+ })
148
+ const waiting = cards.filter((c) => ['waiting_human', 'needs_approval', 'paused'].includes(c.status)).map((c) => ({ card_id: c.card_id, title: c.title, station: c.station, status: c.status, since: c.updated_at, actions: availableActions(c) }))
149
+ const queued = cards.filter((c) => c.status === 'queued').map((c) => {
150
+ const last = lastEventOf(c.card_id)
151
+ return { card_id: c.card_id, title: c.title, station: c.station, leases: c.leases?.length ? c.leases : ['**'], blocked_by: last?.type === 'blocked_by' ? last.summary : null }
152
+ })
153
+ return {
154
+ running, waiting, queued,
155
+ leases: held(cards),
156
+ scheduler: { ...schedulerStatus(), max_concurrent: MAX_CONCURRENT },
157
+ repos: [...new Set(cards.map((c) => c.repo))],
158
+ counts: { running: running.length, queued: queued.length, waiting: waiting.length, done: cards.filter((c) => c.status === 'done').length },
159
+ }
160
+ }
161
+
162
+ function trunk(cards, sinceMs) {
163
+ const cutoff = Date.now() - sinceMs
164
+ const landed = []
165
+ for (const c of cards) {
166
+ for (const e of readEvents(c.card_id)) {
167
+ if (e.type !== 'landed') continue
168
+ if (Date.parse(e.ts) < cutoff) continue
169
+ landed.push({ ts: e.ts, card_id: c.card_id, title: c.title, summary: e.summary, body: e.body ?? null, actor: e.actor, station: e.station })
170
+ }
171
+ }
172
+ landed.sort((a, b) => (a.ts < b.ts ? 1 : -1))
173
+ return { since_ms: sinceMs, landed }
174
+ }
175
+
176
+ function parseSince(s) {
177
+ const m = /^(\d+)(m|h|d)$/.exec(String(s ?? '1h'))
178
+ if (!m) return 3600000
179
+ return parseInt(m[1], 10) * { m: 60000, h: 3600000, d: 86400000 }[m[2]]
180
+ }
181
+
182
+ let toolsCache = null
183
+ // Is this agent actually here? Ask its adapter to resolve the binary the
184
+ // runner would spawn (codex's native exe, an npm entry, a BATON_<AGENT>_BIN
185
+ // override) instead of running the bare name, which on Windows needs a shell
186
+ // and told the board "no codex" while the runner could start it fine.
187
+ export async function detectTools({ refresh = false } = {}) {
188
+ if (toolsCache && !refresh) return toolsCache
189
+ const probe = (bin, args = ['--version']) => {
190
+ const r = spawnSync(bin, args, { windowsHide: true, encoding: 'utf8', timeout: 8000 })
191
+ return !r.error && r.status === 0
192
+ }
193
+ const agents = {}
194
+ for (const name of ['claude', 'codex', 'agy']) {
195
+ try {
196
+ const { bin, viaNode, entry } = (await getAdapter(name)).resolve()
197
+ const target = viaNode ? (entry ?? bin) : bin
198
+ agents[name] = /[\\/]/.test(target) ? existsSync(target) : probe(target)
199
+ } catch { agents[name] = false }
200
+ }
201
+ let chb = false
202
+ try { resolveChb(); chb = true } catch {}
203
+ toolsCache = { ...agents, grok: probe('grok'), chb, git: probe('git') }
204
+ return toolsCache
205
+ }
206
+
207
+ async function adaptersInfo() {
208
+ const out = []
209
+ for (const n of adapterNames()) {
210
+ const a = await getAdapter(n)
211
+ out.push({ name: n, modes: a.modes, fake: isFake(n), stdin: a.stdin })
212
+ }
213
+ return out
214
+ }
215
+
216
+ // ---- sessions (baton claude|codex|agy) ----
217
+ const trunkCache = new Map()
218
+ function trunkFor(repo) {
219
+ const hit = trunkCache.get(repo)
220
+ if (hit && Date.now() - hit.at < 15000) return hit.data
221
+ const g = (args) => { const r = spawnSync('git', args, { cwd: repo, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } }); return r.status === 0 ? r.stdout.trim() : null }
222
+ let branch = null
223
+ for (const b of ['main', 'master', 'trunk']) if (g(['rev-parse', '--verify', '--quiet', b]) !== null) { branch = b; break }
224
+ const log = branch ? g(['log', '--format=%h%x1f%s%x1f%cr%x1f%an', '-6', branch]) : null
225
+ const data = {
226
+ repo, repo_name: repo.split(/[\\/]/).filter(Boolean).pop(), branch,
227
+ commits: (log ?? '').split('\n').filter(Boolean).map((l) => { const [sha, subject, when, author] = l.split('\x1f'); return { sha, subject, when, author } }),
228
+ }
229
+ trunkCache.set(repo, { at: Date.now(), data })
230
+ return data
231
+ }
232
+
233
+ // Each trunk commit a Land put there says who landed it (landings.jsonl).
234
+ function withLandings(t, landings) {
235
+ if (!landings.length) return t
236
+ return {
237
+ ...t,
238
+ commits: t.commits.map((c) => {
239
+ const l = landings.findLast((x) => (x.commits?.length ? x.commits : [x.sha]).some((sha) => String(sha).startsWith(c.sha)))
240
+ return l ? { ...c, landed_by: { agent: l.agent, account: l.account, session_id: l.session_id, by: l.by, at: l.ts } } : c
241
+ }),
242
+ }
243
+ }
244
+
245
+ // What another human sees of a terminal that is not theirs: that it is there,
246
+ // nothing it has said, read or written. No task, no paths, no file names, no
247
+ // limit text, no bundle, no events.
248
+ function redactSession(s) {
249
+ return {
250
+ session_id: s.session_id, agent: s.agent, account: s.account, status: s.status, active: s.active,
251
+ started_at: s.started_at, elapsed_ms: s.elapsed_ms, turns: s.turns, repo_name: s.repo_name, branch: s.branch,
252
+ owner: s.owner ?? null, limits: s.limits ?? null, lineage: s.lineage ?? null,
253
+ warning: s.warning ? { window: s.warning.window, pct: s.warning.pct, resets_at: s.warning.resets_at } : null,
254
+ limit: s.limit ? { reason: s.limit.reason, resets_at: s.limit.resets_at ?? null } : null,
255
+ waiting: s.waiting ?? null,
256
+ worktree: s.worktree ? { branch: s.worktree.branch, base: s.worktree.base } : null,
257
+ // the branch is already on the worktree chip: naming it again costs nothing
258
+ // and is what the board's land line reads
259
+ land: s.land ? { state: s.land.state, branch: s.land.branch ?? null, base: s.land.base ?? null, sha: s.land.sha ?? null, reason: s.land.reason ?? null } : null,
260
+ task: null, cwd: null, files: [], overlap: [], requests: [], hidden: true,
261
+ land_blocker: `read-only: this terminal belongs to ${s.owner ?? 'someone else'}`,
262
+ }
263
+ }
264
+
265
+ function visibleSessionFile(file) {
266
+ const value = String(file ?? '')
267
+ return !value.includes('*** Begin Patch') && !value.includes('*** End Patch')
268
+ }
269
+
270
+ export function sessionsView({ viewer = null, share = null } = {}) {
271
+ const shared = Boolean(share && shareIsOn(share))
272
+ const list = reapLost(listSessions())
273
+ const ov = overlaps(list)
274
+ const configuredAccounts = readAccounts()
275
+ const sessions = list.map((s) => {
276
+ const land = readLand(s.session_id)
277
+ const handoffOrder = normalizeHandoffOrder(s.handoff_order)
278
+ const chain = candidates({ agent: s.agent, account: s.account, accounts: configuredAccounts, order: handoffOrder })
279
+ const preferredNext = chain[0] ?? null
280
+ const availabilityKnown = Boolean(s.installed)
281
+ const eligibleNext = availabilityKnown ? (chain.find((next) => s.installed[next.agent] !== false && isAvailable(readUsage(next.agent, next.account))) ?? null) : null
282
+ return {
283
+ ...s,
284
+ handoff_order: handoffOrder,
285
+ chain,
286
+ preferred_next: preferredNext,
287
+ eligible_next: eligibleNext,
288
+ handoff_availability_known: availabilityKnown,
289
+ can_edit_handoff_order: s.runtime_capabilities?.includes(HANDOFF_ORDER_CAPABILITY) ?? false,
290
+ active: isActive(s),
291
+ overlap: ov.get(s.session_id) ?? [],
292
+ elapsed_ms: Date.now() - Date.parse(s.started_at),
293
+ // Older attached Codex processes can retain one parser mistake where an
294
+ // apply_patch source block was stored as a file name. Keep history on
295
+ // disk and hide only unmistakable patch envelopes in the board view.
296
+ files: [...new Set([...(s.files_touched ?? []), ...(s.files_dirty ?? [])])].filter(visibleSessionFile),
297
+ // a 'landing' left behind by a board restart is no longer in flight
298
+ land: land?.state === 'landing' && !landingNow(s.session_id) ? { ...land, state: 'interrupted' } : land,
299
+ land_blocker: s.worktree ? landBlocker(s) : null,
300
+ }
301
+ })
302
+ const accounts = []
303
+ for (const agent of Object.keys(configuredAccounts)) for (const account of configuredAccounts[agent]) {
304
+ const u = readUsage(agent, account)
305
+ accounts.push({ agent, account, five_hour: u.five_hour, seven_day: u.seven_day, limited_until: u.limited_until, limited_reason: u.limited_reason, source: u.source, observed_at: u.observed_at, updated_at: u.updated_at, stale: usageIsStale(u), live: sessions.filter((s) => s.active && s.agent === agent && s.account === account).length })
306
+ }
307
+ const repos = new Map()
308
+ for (const s of sessions) if (s.repo && (s.active || s.worktree) && !repos.has(canonPath(s.repo))) repos.set(canonPath(s.repo), s.repo)
309
+ const landings = readLandings()
310
+ const canon = new Map()
311
+ const landingsFor = (key) => landings.filter((l) => { if (!canon.has(l.repo)) canon.set(l.repo, canonPath(l.repo)); return canon.get(l.repo) === key })
312
+ const trunk = [...repos].map(([key, r]) => { try { return withLandings(trunkFor(r), landingsFor(key)) } catch { return { repo: r, commits: [] } } })
313
+ const guest = shared && viewer && viewer.role !== 'owner'
314
+ const mine = (s) => !shared || !viewer || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
315
+ const shown = sessions.map((s) => (mine(s) ? { ...s, requests: readRequests(s.session_id).filter((r) => r.state === 'pending') } : redactSession(s)))
316
+ return {
317
+ sessions: shown,
318
+ // a guest sees which accounts exist and which are busy, never how much of
319
+ // them is used: the head prints "not shared" in these slots, and the
320
+ // percentages, the reset times and the reading source stay on this machine
321
+ // (.design/BOARD-DESIGN.md 6.13)
322
+ accounts: guest ? accounts.map((a) => ({ agent: a.agent, account: a.account, live: a.live, shared: false })) : accounts,
323
+ // a guest sees what landed, not where the repo lives on this machine
324
+ trunk: guest ? trunk.map((t) => ({ repo_name: t.repo_name, branch: t.branch, commits: t.commits ?? [] })) : trunk,
325
+ you: viewer,
326
+ share: { on: shared, bind: shared ? share.bind : null, people: shared ? share.people.length : 0 },
327
+ preferences: guest ? null : readPreferences(),
328
+ ts: new Date().toISOString(),
329
+ }
330
+ }
331
+
332
+ // ---- http helpers ----
333
+ function send(res, status, body, headers = {}) {
334
+ const data = typeof body === 'string' ? body : JSON.stringify(body)
335
+ res.writeHead(status, { 'Content-Type': typeof body === 'string' ? 'text/plain; charset=utf-8' : 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers })
336
+ res.end(data)
337
+ }
338
+
339
+ function readBody(req) {
340
+ return new Promise((resolvePromise, reject) => {
341
+ let data = ''
342
+ req.on('data', (c) => { data += c; if (data.length > 1e6) reject(new Error('body too large')) })
343
+ req.on('end', () => {
344
+ if (!data) return resolvePromise({})
345
+ try { resolvePromise(JSON.parse(data)) } catch { reject(new Error('invalid JSON body')) }
346
+ })
347
+ req.on('error', reject)
348
+ })
349
+ }
350
+
351
+ function serveStatic(res, urlPath) {
352
+ const map = { '/': 'index.html', '/floor': 'floor.html' }
353
+ const rel = map[urlPath] ?? urlPath.replace(/^\/+/, '')
354
+ const file = resolve(BOARD_DIR, rel)
355
+ if (!file.startsWith(BOARD_DIR + sep) || !existsSync(file) || !statSync(file).isFile()) return send(res, 404, 'not found')
356
+ res.writeHead(200, { 'Content-Type': TYPES[extname(file)] ?? 'application/octet-stream', 'Cache-Control': 'no-store' })
357
+ res.end(readFileSync(file))
358
+ }
359
+
360
+ // ---- SSE: watch $BATON_HOME/cards for fs events and push only what changed ----
361
+ function createSse({ healthIntervalMs = 10000, debounceMs = 30, viewFor = () => sessionsView(), reauth = (c) => c.viewer } = {}) {
362
+ const clients = new Set() // { res, viewer, token, loopback, sig }
363
+ let watcher = null
364
+ let healthTimer = null
365
+ const pending = new Set()
366
+ let flushTimer = null
367
+ // data may be a function of the client's viewer: each human gets their own
368
+ // payload, so a guest's stream never carries someone else's terminal. The
369
+ // viewer is re-resolved from the live roster on every push, so a link that
370
+ // `share rm`/`rotate` invalidated loses its stream at once.
371
+ const broadcast = (event, data) => {
372
+ for (const c of [...clients]) {
373
+ const viewer = reauth(c)
374
+ if (viewer === null) { try { c.res.end() } catch {} clients.delete(c); if (!clients.size) stopWatch(); continue }
375
+ c.viewer = viewer
376
+ const payload = typeof data === 'function' ? data(viewer) : data
377
+ if (payload === null || payload === undefined) continue
378
+ try { c.res.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`) } catch {}
379
+ }
380
+ }
381
+ // Re-read and re-emit exactly one card's files — never the whole ledger. Each
382
+ // client carries its own high-water mark, so a second client connecting never
383
+ // resets the count the first is reading from.
384
+ const refreshCard = (id) => {
385
+ const card = readCard(id)
386
+ // pipeline cards and their events are the owner's: a guest never gets them
387
+ const forOwner = (payload) => (viewer) => (viewer && viewer.role !== 'owner' ? null : payload)
388
+ if (!card) { for (const c of clients) c.sig.delete(id); broadcast('removed', forOwner({ card_id: id })); return }
389
+ const events = readEvents(id)
390
+ // broadcast() refreshes each client's viewer (and drops revoked ones) first
391
+ broadcast('card', forOwner(summarize(card)))
392
+ for (const c of [...clients]) {
393
+ if (!c.viewer || c.viewer.role !== 'owner') { c.sig.set(id, events.length); continue }
394
+ const from = c.sig.get(id) ?? 0
395
+ c.sig.set(id, events.length)
396
+ for (const e of events.slice(from)) { try { c.res.write(`event: event\ndata: ${JSON.stringify(e)}\n\n`) } catch {} }
397
+ }
398
+ }
399
+ const flushPending = () => {
400
+ flushTimer = null
401
+ const ids = [...pending]
402
+ pending.clear()
403
+ for (const id of ids) refreshCard(id)
404
+ }
405
+ const scheduleRefresh = (id) => {
406
+ pending.add(id)
407
+ if (!flushTimer) flushTimer = setTimeout(flushPending, debounceMs)
408
+ }
409
+ let sessionsWatcher = null
410
+ let sessionsTimer = null
411
+ const pushSessions = () => { sessionsTimer = null; try { broadcast('sessions', (viewer) => viewFor(viewer)) } catch (err) { log(`sessions view: ${err.message}`) } }
412
+ const startWatch = () => {
413
+ if (watcher) return
414
+ try {
415
+ const sdir = sessionsRoot()
416
+ mkdirSync(sdir, { recursive: true })
417
+ sessionsWatcher = fsWatch(realPath(sdir), { recursive: true }, () => { if (!sessionsTimer) sessionsTimer = setTimeout(pushSessions, 300) })
418
+ } catch (err) { log(`sessions watch: ${err.message}`); sessionsWatcher = null }
419
+ // watch the real long path: libuv's recursive watcher asserts when the
420
+ // watched dir is an 8.3 short path (fs-event.c, seen on a GitHub runner)
421
+ const dir = join(home(), 'cards')
422
+ try {
423
+ mkdirSync(dir, { recursive: true })
424
+ watcher = fsWatch(realPath(dir), { recursive: true }, (_event, filename) => {
425
+ if (!filename) return
426
+ const id = String(filename).split(/[\\/]/)[0]
427
+ if (id.startsWith('card-')) scheduleRefresh(id)
428
+ })
429
+ } catch (err) { log(`sse watch: ${err.message}`); watcher = null }
430
+ // the scheduler is the pipeline's, like /api/health: a guest's topbar
431
+ // gets the tick without it
432
+ healthTimer = setInterval(() => {
433
+ const ts = new Date().toISOString()
434
+ broadcast('health', (viewer) => (viewer && viewer.role !== 'owner' ? { ok: true, ts } : { ok: true, scheduler: { ...schedulerStatus(), max_concurrent: MAX_CONCURRENT }, ts }))
435
+ pushSessions()
436
+ }, healthIntervalMs)
437
+ }
438
+ const stopWatch = () => {
439
+ if (watcher) { watcher.close(); watcher = null }
440
+ if (sessionsWatcher) { sessionsWatcher.close(); sessionsWatcher = null }
441
+ if (sessionsTimer) { clearTimeout(sessionsTimer); sessionsTimer = null }
442
+ if (healthTimer) { clearInterval(healthTimer); healthTimer = null }
443
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null }
444
+ pending.clear()
445
+ }
446
+ const add = (res, cards, viewer = null, meta = {}) => {
447
+ const client = { res, viewer, token: meta.token ?? null, loopback: Boolean(meta.loopback), sig: new Map() }
448
+ clients.add(client)
449
+ for (const c of cards) client.sig.set(c.card_id, readEvents(c.card_id).length)
450
+ startWatch()
451
+ res.on('close', () => { clients.delete(client); if (!clients.size) stopWatch() })
452
+ }
453
+ const stop = () => { stopWatch(); for (const c of clients) { try { c.res.end() } catch {} } clients.clear() }
454
+ return { add, stop, broadcast, clients }
455
+ }
456
+
457
+ // ---- the server ----
458
+ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN || process.env.BATON_TOKEN || '', scheduler = (process.env.LEG_NO_SCHEDULER || process.env.BATON_NO_SCHEDULER) !== '1', share, usagePolling = false, usageReader = readCodexUsage } = {}) {
459
+ // An explicit `share` (tests) is fixed; the real server passes none and reads
460
+ // share.json from disk, re-reading it per request (mtime-cached) so `baton
461
+ // share add|rotate|rm` takes effect on a live board — a new link works at
462
+ // once and a removed or rotated one stops at once — without a restart.
463
+ const explicitShare = share !== undefined
464
+ const initialShare = explicitShare ? share : readShare()
465
+ const shared0 = shareIsOn(initialShare)
466
+ // with share on, the board's address and port come from share.json
467
+ bind = bind ?? (shared0 ? initialShare.bind : (process.env.LEG_BIND || process.env.BATON_BIND || '127.0.0.1'))
468
+ port = port ?? (shared0 ? initialShare.port : Number(process.env.LEG_PORT || process.env.BATON_PORT || 4747))
469
+ checkBind({ bind, token, share: initialShare })
470
+ let shareSnapshot = initialShare
471
+ let shareMtime = -1
472
+ const currentShare = explicitShare ? () => initialShare : () => {
473
+ try {
474
+ const st = statSync(sharePath())
475
+ if (st.mtimeMs !== shareMtime) { shareMtime = st.mtimeMs; shareSnapshot = readShare() }
476
+ } catch { if (shareMtime !== -1) { shareMtime = -1; shareSnapshot = readShare() } }
477
+ return shareSnapshot
478
+ }
479
+ const limiter = createLimiter()
480
+ const viewFor = (viewer, sh) => sessionsView({ viewer, share: sh ?? currentShare() })
481
+ const forOwner = (payload) => (viewer) => (viewer && viewer.role !== 'owner' ? null : payload)
482
+ // SSE re-identifies each client from the live roster on every push
483
+ const reauthClient = (c) => {
484
+ const sh = currentShare()
485
+ // share stopping is a revocation, never a promotion: a guest holding this
486
+ // stream through their own token loses it, and only the machine's own
487
+ // tokenless page (or this board's BATON_TOKEN) is the single-player owner
488
+ if (!shareIsOn(sh)) {
489
+ if (c.token) return tokenMatches(token, c.token) ? { name: 'token', role: 'owner' } : null
490
+ return c.loopback ? { name: 'local', role: 'owner' } : null
491
+ }
492
+ const person = identify(sh, c.token)
493
+ if (person) return { name: person.name, role: person.role }
494
+ if (!c.token && sh.loopback_owner !== false && c.loopback) {
495
+ const owner = personNamed(sh, sh.owner) ?? sh.people.find((p) => p.role === 'owner') ?? null
496
+ if (owner) return { name: owner.name, role: owner.role }
497
+ }
498
+ return null
499
+ }
500
+ const sse = createSse({ viewFor, reauth: reauthClient })
501
+ let sched = null
502
+ let usageTimer = null
503
+ let usageController = null
504
+ let usageInFlight = null
505
+ const refreshCodexAccounts = () => {
506
+ if (!usagePolling || ((process.env.LEG_CODEX_BIN || process.env.BATON_CODEX_BIN) && usageReader === readCodexUsage) || usageInFlight) return usageInFlight
507
+ usageController = new AbortController()
508
+ const signal = usageController.signal
509
+ usageInFlight = Promise.all((readAccounts().codex ?? ['default']).map(async (account) => {
510
+ const codexHome = envFor('codex', account).CODEX_HOME || LAYOUT.codex.home()
511
+ const r = await usageReader({ codexHome, timeoutMs: 8000, signal })
512
+ if (!r.ok) return false
513
+ recordUsage('codex', account, r.limits, 'codex app-server account/rateLimits/read', { observed_at: r.observed_at, available: r.available })
514
+ return true
515
+ })).then((changed) => {
516
+ if (changed.some(Boolean)) sse.broadcast('sessions', (viewer) => viewFor(viewer))
517
+ }).catch((err) => log(`codex usage refresh: ${err.message}`)).finally(() => {
518
+ usageInFlight = null
519
+ usageController = null
520
+ })
521
+ return usageInFlight
522
+ }
523
+
524
+ async function handle(req, res) {
525
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
526
+ const path = url.pathname
527
+ if (!path.startsWith('/api/')) return serveStatic(res, path)
528
+ // reject a state-changing request whose Origin is a different site: a page
529
+ // the operator has open in the same browser cannot drive the loopback board
530
+ if (req.method !== 'GET' && req.headers.origin) {
531
+ let ok = false
532
+ try { ok = new URL(req.headers.origin).host === req.headers.host } catch {}
533
+ if (!ok) return send(res, 403, { error: 'cross-origin request refused' })
534
+ }
535
+ // the roster, resolved fresh per request from share.json (mtime-cached)
536
+ const share = currentShare()
537
+ const shared = shareIsOn(share)
538
+ const ip = remoteAddress(req) || 'unknown'
539
+ // a token cannot be guessed at speed, and no one client can flood the board
540
+ if (limiter.lockedOut(ip)) return send(res, 429, { error: 'too many bad tokens from here; wait a minute' }, { 'Retry-After': String(limiter.retryAfter(ip)) })
541
+ const auth = authorize({ token, req, url, share, bind, loopbackOwner: isLoopbackRequest(req) })
542
+ if (!auth.ok) {
543
+ // only a token that was presented and did not match counts as a guess;
544
+ // a board page that has not been given a token yet is not an attacker,
545
+ // and neither is a stale tab polling the one token it was given
546
+ const presented = presentedToken(req, url)
547
+ if (presented) limiter.failure(ip, presented)
548
+ return send(res, 401, { error: shared ? 'unauthorized: open the board with your own link (leg share)' : 'unauthorized: set Authorization: Bearer <LEG_TOKEN>' })
549
+ }
550
+ const viewer = auth.person ? { name: auth.person.name, role: auth.person.role } : { name: auth.subject ?? 'local', role: 'owner' }
551
+ // a name is a bucket only when it names a human: 'local' and 'token' are
552
+ // every client at once, so those are counted per address
553
+ const rl = limiter.request(auth.person ? viewer.name : ip)
554
+ if (!rl.ok) return send(res, 429, { error: `rate limit: more than ${limiter.max} requests a minute` }, { 'Retry-After': String(rl.retry_after) })
555
+ const actor = { type: 'human', id: viewer.name }
556
+ // a guest sees the terminals lane, read-only; the pipeline side is the owner's
557
+ const guest = shared && viewer.role !== 'owner'
558
+ const ownsSession = (s) => !shared || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
559
+ const parts = path.split('/').filter(Boolean) // ['api', ...]
560
+ if (guest && ['cards', 'floor', 'presets', 'adapters', 'leases', 'trunk'].includes(parts[1])) return send(res, 403, { error: 'the pipeline board belongs to the owner of this machine' })
561
+ try {
562
+ if (req.method === 'GET' && path === '/api/health') {
563
+ const you = { ...viewer, share: { on: shared, people: shared ? share.people.length : 0 } }
564
+ if (guest) return send(res, 200, { ok: true, version: VERSION, you })
565
+ const cards = listCards()
566
+ return send(res, 200, { ok: true, version: VERSION, bind, port, home: home(), you, scheduler: { ...schedulerStatus(), in_process: Boolean(sched), max_concurrent: MAX_CONCURRENT }, tools: await detectTools(), columns: columnsFor(cards), cards: cards.length })
567
+ }
568
+ if (req.method === 'GET' && path === '/api/adapters') return send(res, 200, { adapters: await adaptersInfo() })
569
+ if (req.method === 'GET' && path === '/api/presets') return send(res, 200, { presets: PRESETS })
570
+ if (req.method === 'GET' && path === '/api/cards') {
571
+ const cards = listCards()
572
+ return send(res, 200, { columns: columnsFor(cards), cards: cards.map(summarize) })
573
+ }
574
+ if (req.method === 'POST' && path === '/api/cards') {
575
+ const body = await readBody(req)
576
+ try {
577
+ const card = await createCard(body, actor)
578
+ sse.broadcast('card', forOwner(summarize(card)))
579
+ return send(res, 201, { card: summarize(card) })
580
+ } catch (err) {
581
+ if (err instanceof CardInputError) return send(res, 400, { error: err.message })
582
+ throw err
583
+ }
584
+ }
585
+ if (req.method === 'GET' && path === '/api/events') {
586
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' })
587
+ const cards = guest ? [] : listCards()
588
+ res.write(`event: hello\ndata: ${JSON.stringify({ columns: columnsFor(cards), cards: cards.map(summarize), sessions: viewFor(viewer), ts: new Date().toISOString() })}\n\n`)
589
+ sse.add(res, cards, viewer, { token: presentedToken(req, url), loopback: isLoopbackRequest(req) })
590
+ return
591
+ }
592
+ if (req.method === 'GET' && path === '/api/sessions') return send(res, 200, viewFor(viewer))
593
+ if (path === '/api/settings') {
594
+ if (guest) return send(res, 403, { error: 'the machine settings belong to the owner of this board' })
595
+ if (req.method === 'GET') return send(res, 200, { preferences: readPreferences() })
596
+ if (req.method === 'POST' || req.method === 'PATCH') {
597
+ const body = await readBody(req)
598
+ try {
599
+ const preferences = writePreferences({ handoff_order: requireHandoffOrder(body.handoff_order) })
600
+ sse.broadcast('sessions', (v) => viewFor(v))
601
+ return send(res, 200, { preferences })
602
+ } catch (err) {
603
+ if (err instanceof TypeError) return send(res, 400, { error: err.message })
604
+ throw err
605
+ }
606
+ }
607
+ }
608
+ if (parts[1] === 'sessions' && parts[2]) {
609
+ const id = parts[2]
610
+ const sess = readSession(id)
611
+ if (!sess) return send(res, 404, { error: `session not found: ${id}` })
612
+ const mine = ownsSession(sess)
613
+ // another human's terminal: its prompts, files and events are not theirs to read
614
+ if (!mine && !(req.method === 'POST' && parts[3] === 'request-handoff')) {
615
+ return send(res, 403, { error: `read-only: this terminal belongs to ${sess.owner ?? 'someone else'}; ask for a hand-off instead` })
616
+ }
617
+ if (req.method === 'POST' && parts[3] === 'request-handoff') {
618
+ if (!shared) return send(res, 409, { error: 'share is off: use Hand off now' })
619
+ if (mine) return send(res, 409, { error: 'this terminal is yours: use Hand off now' })
620
+ if (!isActive(sess)) return send(res, 409, { error: `session ${id} is not active` })
621
+ const list = readRequests(id).filter((r) => !(r.by === viewer.name && r.state === 'pending'))
622
+ list.push({ by: viewer.name, at: new Date().toISOString(), state: 'pending' })
623
+ writeRequests(id, list)
624
+ appendSessionEvent(id, { type: 'handoff_requested', by: viewer.name, summary: `${viewer.name} asked ${sess.owner ?? 'the owner'} to hand this terminal off` })
625
+ log(`hand-off requested for ${id} by ${viewer.name}`)
626
+ sse.broadcast('sessions', (v) => viewFor(v))
627
+ return send(res, 202, { ok: true, requested: 'handoff', by: viewer.name })
628
+ }
629
+ if (req.method === 'POST' && parts[3] === 'requests' && parts[4] && ['approve', 'dismiss'].includes(parts[5])) {
630
+ const who = decodeURIComponent(parts[4])
631
+ const list = readRequests(id)
632
+ const hit = list.find((r) => r.by === who && r.state === 'pending')
633
+ if (!hit) return send(res, 404, { error: `no pending hand-off request from ${who}` })
634
+ const approve = parts[5] === 'approve'
635
+ // check liveness before mutating the request, so a session that ended
636
+ // just before Approve does not leave the request stuck in 'approved'
637
+ if (approve && !isActive(sess)) return send(res, 409, { error: `session ${id} is not active` })
638
+ hit.state = approve ? 'approved' : 'dismissed'
639
+ hit.answered_at = new Date().toISOString()
640
+ hit.answered_by = viewer.name
641
+ writeRequests(id, list)
642
+ if (approve) requestControl(id, { handoff: true, by: `${viewer.name} for ${who}` })
643
+ appendSessionEvent(id, { type: approve ? 'handoff_requested' : 'status', by: viewer.name, summary: `${viewer.name} ${approve ? 'approved' : 'dismissed'} ${who}'s hand-off request` })
644
+ sse.broadcast('sessions', (v) => viewFor(v))
645
+ return send(res, 200, { ok: true, request: hit })
646
+ }
647
+ if (req.method === 'GET' && parts.length === 3) return send(res, 200, { session: sess, events: readSessionEvents(id), requests: readRequests(id) })
648
+ // the card's drawer: what the agent last said, what it changed, what it
649
+ // has done. Only ever this viewer's own terminal; the guard above sent
650
+ // anyone else away before we read a transcript.
651
+ if (req.method === 'GET' && parts[3] === 'detail') return send(res, 200, sessionDetail(sess))
652
+ if (req.method === 'GET' && parts[3] === 'diff') {
653
+ try {
654
+ return send(res, 200, sessionDiff(sess, url.searchParams.get('file') ?? ''))
655
+ } catch (err) {
656
+ if (err instanceof DiffInputError) return send(res, 400, { error: err.message })
657
+ throw err
658
+ }
659
+ }
660
+ if (req.method === 'POST' && parts[3] === 'handoff-order') {
661
+ if (!sess.runtime_capabilities?.includes(HANDOFF_ORDER_CAPABILITY)) {
662
+ return send(res, 409, { error: 'this terminal started before order changes were available; save the order as the default, then restart the terminal when ready' })
663
+ }
664
+ if (!['starting', 'running', 'warning', 'limit', 'waiting'].includes(sess.status)) {
665
+ return send(res, 409, { error: `handoff order cannot change while this terminal is ${sess.status}` })
666
+ }
667
+ const body = await readBody(req)
668
+ try {
669
+ const order = requireHandoffOrder(body.handoff_order)
670
+ const next = updateSession(id, (current) => {
671
+ if (!['starting', 'running', 'warning', 'limit', 'waiting'].includes(current.status)) {
672
+ const conflict = new Error(`handoff order cannot change while this terminal is ${current.status}`)
673
+ conflict.statusCode = 409
674
+ throw conflict
675
+ }
676
+ return {
677
+ handoff_order: order,
678
+ chain: candidates({ agent: current.agent, account: current.account, accounts: readAccounts(), order }),
679
+ }
680
+ }, { event: { type: 'status', by: actor.id, summary: `handoff order changed to ${order.join(' → ')}` } })
681
+ sse.broadcast('sessions', (v) => viewFor(v))
682
+ return send(res, 200, { session: next })
683
+ } catch (err) {
684
+ if (err instanceof TypeError) return send(res, 400, { error: err.message })
685
+ if (err.statusCode === 409) return send(res, 409, { error: err.message })
686
+ throw err
687
+ }
688
+ }
689
+ if (req.method === 'POST' && parts[3] === 'land') {
690
+ const why = landBlocker(sess)
691
+ if (why) return send(res, 409, { error: why })
692
+ landSession(sess, { by: actor.id })
693
+ .catch((err) => log(`land ${id}: ${err.message}`))
694
+ .finally(() => { trunkCache.clear(); try { sse.broadcast('sessions', (v) => viewFor(v)) } catch {} })
695
+ log(`land requested for ${id} by ${actor.id}`)
696
+ return send(res, 202, { ok: true, requested: 'land' })
697
+ }
698
+ if (req.method === 'POST' && (parts[3] === 'handoff' || parts[3] === 'end')) {
699
+ if (!isActive(sess)) return send(res, 409, { error: `session ${id} is not active` })
700
+ requestControl(id, parts[3] === 'handoff' ? { handoff: true, by: actor.id } : { end: true, by: actor.id })
701
+ log(`${parts[3]} requested for ${id} by ${actor.id}`)
702
+ return send(res, 200, { ok: true, requested: parts[3] })
703
+ }
704
+ if (req.method === 'DELETE' && parts.length === 3) {
705
+ if (isActive(sess)) return send(res, 409, { error: 'end the session before removing it' })
706
+ if (landingNow(id)) return send(res, 409, { error: 'wait for the landing to finish before removing it' })
707
+ const force = url.searchParams.get('force') === '1'
708
+ const keepWorktree = url.searchParams.get('keep_worktree') === '1'
709
+ if (keepWorktree && !force) return send(res, 400, { error: 'keep_worktree requires force=1' })
710
+ let worktree = null
711
+ if (keepWorktree && sess.worktree) {
712
+ worktree = { removed: false, preserved: true, path: sess.worktree.path, branch: sess.worktree.branch, reason: 'preserved by request' }
713
+ } else if (sess.worktree) { try { worktree = pruneSessionWorktree(sess) } catch (err) { worktree = { removed: false, reason: scrub(err.message).slice(0, 200) } } }
714
+ // Remove must not orphan unlanded work: if the worktree could not be
715
+ // pruned (uncommitted or unmerged), keep the record unless forced
716
+ if (worktree && !worktree.removed && !force) return send(res, 409, { error: `not removing ${id}: ${worktree.reason}. Land it first, or retry with ?force=1 to drop the record and leave the worktree in place.`, worktree })
717
+ removeSession(id)
718
+ sse.broadcast('sessions', (v) => viewFor(v))
719
+ return send(res, 200, { removed: id, worktree })
720
+ }
721
+ }
722
+ if (req.method === 'GET' && path === '/api/floor') return send(res, 200, floor(listCards()))
723
+ if (req.method === 'GET' && path === '/api/trunk') return send(res, 200, trunk(listCards(), parseSince(url.searchParams.get('since'))))
724
+ if (req.method === 'GET' && path === '/api/leases') return send(res, 200, { leases: held(listCards()) })
725
+ if (parts[1] === 'cards' && parts[2]) {
726
+ const id = parts[2]
727
+ const card = readCard(id)
728
+ if (!card) return send(res, 404, { error: `card not found: ${id}` })
729
+ if (req.method === 'GET' && parts.length === 3) {
730
+ return send(res, 200, { card: summarize(card), runs: readRuns(id), events: readEvents(id), bundle: card.last_bundle ? { id: card.last_bundle, path: card.worktree ? join(card.worktree, '.context-handoffs', card.last_bundle) : null } : null })
731
+ }
732
+ if (req.method === 'GET' && parts[3] === 'events') return send(res, 200, { events: readEvents(id) })
733
+ if (req.method === 'GET' && parts[3] === 'log') {
734
+ const run = url.searchParams.get('run') ? parseInt(url.searchParams.get('run'), 10) : null
735
+ const tail = Math.min(2000, parseInt(url.searchParams.get('tail') ?? '200', 10) || 200)
736
+ return send(res, 200, logTail(id, run, tail))
737
+ }
738
+ if (req.method === 'DELETE' && parts.length === 3) {
739
+ if (['running', 'handing_off'].includes(card.status)) return send(res, 409, { error: 'kill the card before removing it' })
740
+ // don't discard uncommitted agent work without an explicit force
741
+ if (url.searchParams.get('force') !== '1') {
742
+ let dirty
743
+ try { dirty = worktreeDirty(card.repo, id) } catch (err) {
744
+ // a status check that fails must not read as clean
745
+ return send(res, 409, { error: `cannot verify the card's worktree (git status failed: ${scrub(err.message).slice(0, 200)}). Retry with ?force=1 to discard it.` })
746
+ }
747
+ if (dirty.length) return send(res, 409, { error: `the card's worktree has ${dirty.length} uncommitted file(s): ${dirty.slice(0, 10).join(', ')}. Retry with ?force=1 to discard them.`, dirty })
748
+ }
749
+ // `branch=delete` on its own is `git branch -d`: commits the branch is
750
+ // the only copy of are lost only when the caller says force
751
+ let wt = null
752
+ try { wt = removeWorktree(card.repo, id, { deleteBranch: url.searchParams.get('branch') === 'delete', force: url.searchParams.get('force') === '1' }) } catch (err) { log(`worktree remove: ${err.message}`) }
753
+ if (wt && wt.branchUnmerged && !wt.branchDeleted) return send(res, 409, { error: `not removing ${id}: its branch has commits that are not on its base. Land it first, or retry with ?force=1 to delete the branch and discard them.`, worktree: wt })
754
+ rmSync(cardDir(id), { recursive: true, force: true })
755
+ sse.broadcast('removed', forOwner({ card_id: id }))
756
+ return send(res, 200, { removed: id })
757
+ }
758
+ if (req.method === 'POST' && parts[3]) {
759
+ const map = { run: 'enqueue', queue: 'enqueue', approve: 'approve', reassign: 'reassign', pause: 'pause', resume: 'resume', kill: 'kill', handoff: 'handoff_now', 'handoff-now': 'handoff_now', rerun: 'rerun' }
760
+ const action = map[parts[3]]
761
+ if (!action) return send(res, 404, { error: `unknown action ${parts[3]}` })
762
+ const body = await readBody(req)
763
+ try {
764
+ const next = humanAction(id, action, body, actor)
765
+ sse.broadcast('card', forOwner(summarize(next)))
766
+ return send(res, 200, { card: summarize(next) })
767
+ } catch (err) {
768
+ if (err instanceof IllegalTransition) return send(res, 409, { error: err.message })
769
+ if (/needs an adapter/.test(err.message)) return send(res, 400, { error: err.message })
770
+ throw err
771
+ }
772
+ }
773
+ }
774
+ return send(res, 404, { error: 'not found' })
775
+ } catch (err) {
776
+ log(`error ${req.method} ${path}: ${err.message}`)
777
+ return send(res, 500, { error: scrub(err.message) })
778
+ }
779
+ }
780
+
781
+ const onReq = (req, res) => { handle(req, res).catch((err) => { try { send(res, 500, { error: scrub(err.message) }) } catch {} }) }
782
+ const server = http.createServer(onReq)
783
+ // When the board is bound to a non-loopback address (share on), also listen on
784
+ // 127.0.0.1 so the machine's own browser has a tokenless owner URL — a real
785
+ // remote peer's address is never loopback, so it still needs a token.
786
+ const loopbackCompanion = !isLoopback(bind) ? http.createServer(onReq) : null
787
+
788
+ return {
789
+ server,
790
+ bind, port,
791
+ start() {
792
+ return new Promise((resolvePromise, reject) => {
793
+ server.once('error', reject)
794
+ server.listen(port, bind, () => {
795
+ const addr = server.address()
796
+ log(`listening on http://${bind}:${addr.port} (home ${home()}${token ? ', token required' : ', loopback open'})`)
797
+ // A terminal that crashed instead of exiting left its hand-off in
798
+ // .baton/RESUME.md looking live. The board is the thing that starts
799
+ // after a crash, so it is where that gets corrected.
800
+ try {
801
+ const touched = refreshPointers()
802
+ if (touched.length) log(`rewrote ${touched.length} stale resume pointer${touched.length === 1 ? '' : 's'}: the terminal each described is gone, or no Baton stamped it`)
803
+ } catch (err) { log(`resume pointers not refreshed: ${err.message}`) }
804
+ if (scheduler) {
805
+ sched = createScheduler()
806
+ sched.run().catch((err) => log(`scheduler crashed: ${err.message}`))
807
+ }
808
+ if (usagePolling) {
809
+ refreshCodexAccounts()
810
+ usageTimer = setInterval(refreshCodexAccounts, 60000)
811
+ usageTimer.unref?.()
812
+ }
813
+ if (loopbackCompanion) {
814
+ loopbackCompanion.on('error', (err) => log(`loopback companion: ${err.message}`))
815
+ loopbackCompanion.listen(addr.port, '127.0.0.1', () => log(`also on http://127.0.0.1:${addr.port} (this machine, tokenless owner)`))
816
+ }
817
+ resolvePromise({ port: addr.port, bind })
818
+ })
819
+ })
820
+ },
821
+ async stop() {
822
+ if (usageTimer) { clearInterval(usageTimer); usageTimer = null }
823
+ usageController?.abort()
824
+ sse.stop()
825
+ if (sched) sched.stop()
826
+ if (loopbackCompanion) await new Promise((r) => { loopbackCompanion.closeAllConnections?.(); loopbackCompanion.close(() => r()) })
827
+ await new Promise((r) => { server.closeAllConnections?.(); server.close(r) })
828
+ },
829
+ }
830
+ }
831
+
832
+ const isMain = process.argv[1] && resolve(process.argv[1]).toLowerCase() === SELF.toLowerCase()
833
+ if (isMain) {
834
+ let srv
835
+ try {
836
+ srv = createBoardServer({ usagePolling: true })
837
+ } catch (err) {
838
+ process.stderr.write(err.message + '\n')
839
+ process.exit(err.exitCode ?? 1)
840
+ }
841
+ await srv.start()
842
+ const bye = () => { srv.stop().then(() => process.exit(0)) }
843
+ process.on('SIGINT', bye)
844
+ process.on('SIGTERM', bye)
845
+ }