@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,129 @@
1
+ // What one terminal card's drawer shows: the agent's last messages, the files
2
+ // this session changed, and the diff for one of them. Read-only, and every
3
+ // string is scrubbed on the way out: a transcript and a diff can both carry a
4
+ // key the agent printed, and neither has ever left this machine before.
5
+ import { existsSync, readFileSync, statSync } from 'node:fs'
6
+ import { execFileSync } from 'node:child_process'
7
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
8
+ import { readEvents, workRoot } from './sessions.mjs'
9
+ import { realPath } from './fsx.mjs'
10
+ import { scrub } from './redact.mjs'
11
+ import { transcriptTail as claudeTail } from './taps/claude.mjs'
12
+ import { transcriptTail as codexTail } from './taps/codex.mjs'
13
+ import { resumeVerdict, verdictForBoard } from './resume.mjs'
14
+
15
+ export const MESSAGE_LIMIT = 8
16
+ export const DIFF_MAX_LINES = 400
17
+ export const EVENT_LIMIT = 200
18
+
19
+ export class DiffInputError extends Error {}
20
+
21
+ function git(root, args) {
22
+ try {
23
+ return execFileSync('git', ['-C', root, ...args], { windowsHide: true, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: 5000, env: { ...process.env, MSYS_NO_PATHCONV: '1' }, stdio: ['ignore', 'pipe', 'ignore'] })
24
+ } catch { return null }
25
+ }
26
+
27
+ // A path the session recorded (relative, or absolute in another form: a short
28
+ // 8.3 prefix, a symlinked temp dir) → its path inside this session's own tree,
29
+ // or null when it is not in that tree at all.
30
+ function insideRoot(root, file) {
31
+ if (!root || !file) return null
32
+ const rootR = realPath(root)
33
+ const absR = realPath(isAbsolute(file) ? file : resolve(rootR, file))
34
+ const rel = relative(rootR, absR)
35
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) return null
36
+ return { rel: rel.split(sep).join('/'), abs: absR }
37
+ }
38
+
39
+ export function sessionMessages(session, limit = MESSAGE_LIMIT) {
40
+ const path = session?.transcript_path
41
+ if (!path) return []
42
+ const tail = session.agent === 'claude' ? claudeTail(path, limit)
43
+ : session.agent === 'codex' ? codexTail(path, limit)
44
+ : [] // agy keeps no transcript Baton can read
45
+ return tail.map((m) => ({ role: m.role === 'user' ? 'user' : 'assistant', text: scrub(m.text), ts: m.ts ?? null }))
46
+ }
47
+
48
+ // path → {adds, dels} for everything git can compare against HEAD. A file the
49
+ // agent created is not in here; it is still listed, with no counts.
50
+ function numstat(root) {
51
+ const out = git(root, ['diff', '--numstat', 'HEAD'])
52
+ const counts = new Map()
53
+ for (const line of (out ?? '').split('\n')) {
54
+ const [adds, dels, path] = line.split('\t')
55
+ if (!path) continue
56
+ counts.set(path.trim(), { adds: Number(adds) || 0, dels: Number(dels) || 0 })
57
+ }
58
+ return counts
59
+ }
60
+
61
+ // Is this a path git already has? A file with no counts against HEAD is either
62
+ // one the agent created (nobody tracks it, ignored files included) or one that
63
+ // already landed in a commit, and the drawer must not call those the same.
64
+ function isTracked(root, rel) {
65
+ const out = git(root, ['ls-files', '--error-unmatch', '--', rel])
66
+ return typeof out === 'string' && out.trim().length > 0
67
+ }
68
+
69
+ export function sessionFiles(session) {
70
+ const root = workRoot(session)
71
+ if (!root) return []
72
+ const counts = numstat(root)
73
+ const dirty = new Set()
74
+ for (const f of session.files_dirty ?? []) { const i = insideRoot(root, f); if (i) dirty.add(i.rel) }
75
+ const files = new Map()
76
+ for (const f of [...(session.files_touched ?? []), ...(session.files_dirty ?? [])]) {
77
+ const i = insideRoot(root, f)
78
+ if (!i || files.has(i.rel)) continue
79
+ const c = counts.get(i.rel) ?? null
80
+ const state = c ? 'modified' : (isTracked(root, i.rel) ? 'committed' : 'new')
81
+ files.set(i.rel, { path: i.rel, adds: c?.adds ?? null, dels: c?.dels ?? null, dirty: dirty.has(i.rel), state })
82
+ }
83
+ return [...files.values()].sort((a, b) => a.path.localeCompare(b.path))
84
+ }
85
+
86
+ export function sessionDiff(session, file) {
87
+ const root = workRoot(session)
88
+ if (!root) throw new DiffInputError('this terminal has no repository to diff')
89
+ const inside = insideRoot(root, String(file ?? ''))
90
+ if (!inside) throw new DiffInputError('that file is not in this terminal\'s tree')
91
+ let out = git(root, ['diff', 'HEAD', '--', inside.rel]) ?? ''
92
+ let state = 'modified'
93
+ if (!out.trim()) {
94
+ // nothing to compare against is not one situation: the agent created this
95
+ // file, or it already landed in a commit, or it is gone. Say which.
96
+ const isFile = existsSync(inside.abs) && statSync(inside.abs).isFile()
97
+ if (isFile && !isTracked(root, inside.rel)) {
98
+ let body = ''
99
+ try { body = readFileSync(inside.abs, 'utf8') } catch { body = '' }
100
+ if (body) out = `--- /dev/null\n+++ b/${inside.rel}\n` + body.split('\n').map((l) => '+' + l).join('\n')
101
+ state = 'new'
102
+ } else state = existsSync(inside.abs) ? 'committed' : 'gone'
103
+ }
104
+ const lines = scrub(out).split('\n')
105
+ const truncated = lines.length > DIFF_MAX_LINES
106
+ return { file: inside.rel, diff: lines.slice(0, DIFF_MAX_LINES).join('\n'), truncated, state }
107
+ }
108
+
109
+ // Is the resume pointer in this terminal's checkout still describing the repo
110
+ // the reader will find? Recomputed from git on every drawer poll, and handed
111
+ // over without its local paths: a screenshot of the drawer travels further
112
+ // than this machine.
113
+ function resumeFor(session) {
114
+ const root = workRoot(session)
115
+ if (!root) return null
116
+ try { return verdictForBoard(resumeVerdict(root)) } catch { return null }
117
+ }
118
+
119
+ export function sessionDetail(session) {
120
+ return {
121
+ session_id: session.session_id,
122
+ messages: sessionMessages(session),
123
+ files: sessionFiles(session),
124
+ events: readEvents(session.session_id).slice(-EVENT_LIMIT),
125
+ bundle: session.bundle ?? null,
126
+ resume: resumeFor(session),
127
+ ts: new Date().toISOString(),
128
+ }
129
+ }
@@ -0,0 +1,202 @@
1
+ // sessions — the store behind `baton claude|codex|agy`. One directory per
2
+ // interactive terminal session under $BATON_HOME/sessions/<id>/:
3
+ // session.json the live record the board renders (atomic writes)
4
+ // events.jsonl timeline (started, turn, warning, limit, handoff, ended)
5
+ // control.json board → runner requests ({ handoff: true })
6
+ // The runner (src/attach.mjs) is the only writer of session.json; hooks and
7
+ // taps go through recordFromTap() so every write is one atomic replace.
8
+ import { existsSync, mkdirSync, readdirSync, readFileSync, appendFileSync, rmSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { randomBytes } from 'node:crypto'
11
+ import { home } from './store.mjs'
12
+ import { writeJsonAtomic, withFileLock } from './fsx.mjs'
13
+ import { scrub } from './redact.mjs'
14
+ import { HANDOFF_AGENTS, normalizeHandoffOrder } from './preferences.mjs'
15
+
16
+ export const AGENTS = HANDOFF_AGENTS
17
+ export const HANDOFF_ORDER_CAPABILITY = 'handoff_order_v1'
18
+ export const SESSION_STATUSES = ['starting', 'running', 'warning', 'limit', 'handing_off', 'waiting', 'handed_off', 'ended', 'lost']
19
+ const ACTIVE = ['starting', 'running', 'warning', 'limit', 'handing_off', 'waiting']
20
+
21
+ const now = () => new Date().toISOString()
22
+
23
+ export function sessionsRoot() { return join(home(), 'sessions') }
24
+ export function sessionDir(id) { return join(sessionsRoot(), id) }
25
+
26
+ export function newSessionId(agent) {
27
+ const d = new Date()
28
+ const pad = (n) => String(n).padStart(2, '0')
29
+ return `s-${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}-${agent}-${randomBytes(2).toString('hex')}`
30
+ }
31
+
32
+ export function readSession(id) {
33
+ const f = join(sessionDir(id), 'session.json')
34
+ if (!existsSync(f)) return null
35
+ try { return JSON.parse(readFileSync(f, 'utf8')) } catch { return null }
36
+ }
37
+
38
+ export function listSessions() {
39
+ const root = sessionsRoot()
40
+ if (!existsSync(root)) return []
41
+ return readdirSync(root).filter((n) => n.startsWith('s-')).sort().map(readSession).filter(Boolean)
42
+ }
43
+
44
+ export function isActive(s) { return ACTIVE.includes(s?.status) }
45
+
46
+ // The checkout this session's files live in: its own worktree when Baton gave
47
+ // it one (repo stays the main checkout, for grouping and landing), else the repo.
48
+ export function workRoot(s) { return s?.worktree?.path ?? s?.repo ?? s?.cwd ?? null }
49
+
50
+ export function createSession({ id, agent, account = 'default', cwd, repo = null, branch = null, argv = [], runner_pid = process.pid, chain = [], worktree = null, owner = null, handoffOrder = AGENTS, installed = null, runtimeCapabilities = [] }) {
51
+ const session = {
52
+ session_id: id, agent, account, cwd, repo, branch, argv, worktree, owner,
53
+ repo_name: repo ? repo.split(/[\\/]/).filter(Boolean).pop() : null,
54
+ status: 'starting', runner_pid, pid: null,
55
+ started_at: now(), updated_at: now(), ended_at: null, last_activity: now(),
56
+ agent_session_id: null, transcript_path: null,
57
+ task: null, turns: 0,
58
+ files_touched: [], files_dirty: [], head: null, head_at_start: null,
59
+ limits: null, limit: null, warning: null,
60
+ bundle: null, handoff: null, chain,
61
+ handoff_order: normalizeHandoffOrder(handoffOrder), installed,
62
+ runtime_capabilities: [...new Set(runtimeCapabilities)],
63
+ lineage: { from: null, to: null },
64
+ exit_code: null,
65
+ }
66
+ mkdirSync(sessionDir(id), { recursive: true })
67
+ writeJsonAtomic(join(sessionDir(id), 'session.json'), session)
68
+ appendEvent(id, { type: 'started', summary: `${agent} (${account}) started in ${cwd}` })
69
+ return session
70
+ }
71
+
72
+ // Patch the record; arrays replace, `merge` deep-merges one level (limits).
73
+ // `patch` may be a function (cur) => delta: the read, the compute and the write
74
+ // then happen inside one cross-process lock, so concurrent hook/tap/poller
75
+ // processes cannot lose an accumulated field (files_touched, turns) to a
76
+ // last-writer-wins race. Callers that only set fixed values pass a plain object.
77
+ export function updateSession(id, patch, { event } = {}) {
78
+ return withFileLock(join(sessionDir(id), '.session.lock'), () => {
79
+ const cur = readSession(id)
80
+ if (!cur) return null
81
+ const delta = typeof patch === 'function' ? patch(cur) : patch
82
+ const next = { ...cur, ...delta, updated_at: now() }
83
+ if (delta.limits && cur.limits) next.limits = { ...cur.limits, ...delta.limits }
84
+ writeJsonAtomic(join(sessionDir(id), 'session.json'), next)
85
+ if (event) appendEvent(id, event)
86
+ return next
87
+ })
88
+ }
89
+
90
+ export function appendEvent(id, ev) {
91
+ const dir = sessionDir(id)
92
+ if (!existsSync(dir)) return
93
+ const line = { ts: now(), session_id: id, ...ev, summary: scrub(String(ev.summary ?? '')) }
94
+ if (line.body) line.body = scrub(String(line.body)).slice(0, 4000)
95
+ appendFileSync(join(dir, 'events.jsonl'), JSON.stringify(line) + '\n')
96
+ }
97
+
98
+ export function readEvents(id) {
99
+ const f = join(sessionDir(id), 'events.jsonl')
100
+ if (!existsSync(f)) return []
101
+ return readFileSync(f, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
102
+ }
103
+
104
+ // Board → runner. The runner polls this file; a consumed request is deleted.
105
+ // Merge, never replace: an `end` and a later `handoff` (or a second board
106
+ // command in the same poll window) both survive to be seen by takeControl,
107
+ // instead of the second write silently dropping the first.
108
+ export function requestControl(id, req) {
109
+ const f = join(sessionDir(id), 'control.json')
110
+ return withFileLock(join(sessionDir(id), '.control.lock'), () => {
111
+ let existing = {}
112
+ if (existsSync(f)) { try { existing = JSON.parse(readFileSync(f, 'utf8')) } catch {} }
113
+ writeJsonAtomic(f, { ...existing, ...req, requested_at: now() })
114
+ })
115
+ }
116
+ export function takeControl(id) {
117
+ const f = join(sessionDir(id), 'control.json')
118
+ return withFileLock(join(sessionDir(id), '.control.lock'), () => {
119
+ if (!existsSync(f)) return null
120
+ let req = null
121
+ try { req = JSON.parse(readFileSync(f, 'utf8')) } catch {}
122
+ rmSync(f, { force: true })
123
+ return req
124
+ })
125
+ }
126
+
127
+ export function removeSession(id) { rmSync(sessionDir(id), { recursive: true, force: true }) }
128
+
129
+ // Land state for one session: land.json is written by the board server only
130
+ // (the runner owns session.json), so a land result never races a tap write.
131
+ export function readLand(id) {
132
+ const f = join(sessionDir(id), 'land.json')
133
+ if (!existsSync(f)) return null
134
+ try { return JSON.parse(readFileSync(f, 'utf8')) } catch { return null }
135
+ }
136
+ export function writeLand(id, land) {
137
+ if (!existsSync(sessionDir(id))) return
138
+ writeJsonAtomic(join(sessionDir(id), 'land.json'), land)
139
+ }
140
+
141
+ // Hand-off requests from another human (share mode). Server-owned, like land.json.
142
+ export function readRequests(id) {
143
+ const f = join(sessionDir(id), 'requests.json')
144
+ if (!existsSync(f)) return []
145
+ try { const j = JSON.parse(readFileSync(f, 'utf8')); return Array.isArray(j) ? j : [] } catch { return [] }
146
+ }
147
+ export function writeRequests(id, list) {
148
+ if (!existsSync(sessionDir(id))) return
149
+ writeJsonAtomic(join(sessionDir(id), 'requests.json'), list.slice(-20))
150
+ }
151
+
152
+ // Who landed what: one line per landing, kept after the session is removed.
153
+ const landingsFile = () => join(home(), 'landings.jsonl')
154
+ export function appendLanding(entry) {
155
+ mkdirSync(home(), { recursive: true })
156
+ appendFileSync(landingsFile(), JSON.stringify({ ts: now(), ...entry }) + '\n')
157
+ }
158
+ export function readLandings() {
159
+ if (!existsSync(landingsFile())) return []
160
+ return readFileSync(landingsFile(), 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
161
+ }
162
+
163
+ function pidAlive(pid) {
164
+ if (!pid) return false
165
+ // EPERM means the process exists but is not ours to signal (e.g. an elevated
166
+ // terminal): it is alive. Only ESRCH ("no such process") means gone.
167
+ try { process.kill(pid, 0); return true } catch (e) { return e.code === 'EPERM' }
168
+ }
169
+
170
+ // A session whose runner process is gone (terminal closed, crash) is marked
171
+ // lost so the board never shows a dead terminal as live.
172
+ export function reapLost(sessions = listSessions()) {
173
+ const out = []
174
+ for (const s of sessions) {
175
+ if (isActive(s) && !pidAlive(s.runner_pid)) {
176
+ out.push(updateSession(s.session_id, { status: 'lost', ended_at: now() }, { event: { type: 'lost', summary: `runner pid ${s.runner_pid} is gone; session marked lost` } }) ?? s)
177
+ } else out.push(s)
178
+ }
179
+ return out
180
+ }
181
+
182
+ // Two live sessions on one repo touching the same file: both get flagged.
183
+ export function overlaps(sessions) {
184
+ const live = sessions.filter(isActive).filter((s) => s.repo)
185
+ const out = new Map()
186
+ for (let i = 0; i < live.length; i++) {
187
+ for (let j = i + 1; j < live.length; j++) {
188
+ const a = live[i]; const b = live[j]
189
+ if (a.repo.toLowerCase() !== b.repo.toLowerCase()) continue
190
+ const setB = new Set([...(b.files_touched ?? []), ...(b.files_dirty ?? [])])
191
+ const shared = [...new Set([...(a.files_touched ?? []), ...(a.files_dirty ?? [])])].filter((f) => setB.has(f))
192
+ if (!shared.length) continue
193
+ // separate: each has its own checkout, so the clash waits for the second landing
194
+ const separate = String(workRoot(a)).toLowerCase() !== String(workRoot(b)).toLowerCase()
195
+ for (const [x, y] of [[a, b], [b, a]]) {
196
+ if (!out.has(x.session_id)) out.set(x.session_id, [])
197
+ out.get(x.session_id).push({ session_id: y.session_id, agent: y.agent, files: shared, separate })
198
+ }
199
+ }
200
+ }
201
+ return out
202
+ }
package/src/share.mjs ADDED
@@ -0,0 +1,163 @@
1
+ // share — optional multiplayer for the board, off until `baton share on`
2
+ // writes $BATON_HOME/share.json. With it on, the board binds the Tailscale or
3
+ // LAN address, every human has a name and their own token (kept as a sha256
4
+ // hash: a token is printed once and `baton share rotate` issues a new one),
5
+ // and every session belongs to the human whose terminal started it
6
+ // (`BATON_PERSON`, else the owner). With it off nothing changes: loopback is
7
+ // open and `BATON_TOKEN` is the only token.
8
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
9
+ import { existsSync, readFileSync, mkdirSync } from 'node:fs'
10
+ import { networkInterfaces, userInfo } from 'node:os'
11
+ import { createSocket } from 'node:dgram'
12
+ import { join } from 'node:path'
13
+ import { home } from './store.mjs'
14
+ import { writeJsonAtomic } from './fsx.mjs'
15
+
16
+ export const OFF = { version: 1, on: false, bind: null, bind_kind: null, port: null, owner: null, people: [] }
17
+ export const ROLES = ['owner', 'guest']
18
+
19
+ export function sharePath() { return join(home(), 'share.json') }
20
+
21
+ export function readShare() {
22
+ const f = sharePath()
23
+ if (!existsSync(f)) return { ...OFF }
24
+ try {
25
+ const s = JSON.parse(readFileSync(f, 'utf8'))
26
+ return { ...OFF, ...s, people: Array.isArray(s.people) ? s.people : [] }
27
+ } catch { return { ...OFF } }
28
+ }
29
+
30
+ export function writeShare(share) {
31
+ mkdirSync(home(), { recursive: true })
32
+ writeJsonAtomic(sharePath(), share)
33
+ return share
34
+ }
35
+
36
+ // On means: turned on, bound somewhere, and at least one person to let in.
37
+ export function isOn(share = readShare()) { return Boolean(share.on && share.people.length && share.bind) }
38
+
39
+ export function hashToken(token) { return createHash('sha256').update(String(token)).digest('hex') }
40
+ export function newToken() { return randomBytes(24).toString('base64url') }
41
+ export function validName(name) { return /^[a-z0-9][a-z0-9_-]{0,31}$/i.test(String(name ?? '')) }
42
+
43
+ // Which person presented this token, in constant time. null for no match.
44
+ export function identify(share, presented) {
45
+ if (!presented || !share?.people?.length) return null
46
+ const h = Buffer.from(hashToken(presented))
47
+ let found = null
48
+ for (const p of share.people) {
49
+ const q = Buffer.from(String(p.token_sha256 ?? '').padEnd(h.length, '0').slice(0, h.length))
50
+ if (timingSafeEqual(h, q) && p.token_sha256) found = found ?? p
51
+ }
52
+ return found
53
+ }
54
+
55
+ export function personNamed(share, name) { return share.people.find((p) => p.name.toLowerCase() === String(name ?? '').toLowerCase()) ?? null }
56
+ export function isOwner(person) { return person?.role === 'owner' }
57
+
58
+ export function addPerson(name, { role = 'guest', share = readShare() } = {}) {
59
+ if (!validName(name)) throw new Error(`bad name "${name}": letters, digits, dash and underscore, up to 32 characters`)
60
+ if (!ROLES.includes(role)) throw new Error(`bad role "${role}" (owner|guest)`)
61
+ if (personNamed(share, name)) throw new Error(`"${name}" is already on the board; baton share rotate ${name} issues a new link`)
62
+ const token = newToken()
63
+ const person = { name, role, token_sha256: hashToken(token), created_at: new Date().toISOString(), last_seen: null }
64
+ share.people.push(person)
65
+ writeShare(share)
66
+ return { person, token, share }
67
+ }
68
+
69
+ export function removePerson(name, share = readShare()) {
70
+ const person = personNamed(share, name)
71
+ if (!person) throw new Error(`no one called "${name}" on this board`)
72
+ // a shared board with no owner locks this machine's own browser out of it
73
+ if (isOwner(person) && isOn(share) && !share.people.some((p) => isOwner(p) && p !== person)) {
74
+ throw new Error(`"${name}" is the only owner of this board: leg share add <someone> --role owner first, or leg share off`)
75
+ }
76
+ share.people = share.people.filter((p) => p.name.toLowerCase() !== String(name).toLowerCase())
77
+ if (share.owner && share.owner.toLowerCase() === String(name).toLowerCase()) share.owner = share.people.find((p) => p.role === 'owner')?.name ?? null
78
+ writeShare(share)
79
+ return share
80
+ }
81
+
82
+ export function rotate(name, share = readShare()) {
83
+ const person = personNamed(share, name)
84
+ if (!person) throw new Error(`no one called "${name}" on this board`)
85
+ const token = newToken()
86
+ person.token_sha256 = hashToken(token)
87
+ person.rotated_at = new Date().toISOString()
88
+ writeShare(share)
89
+ return { person, token, share }
90
+ }
91
+
92
+ function ipv4s() {
93
+ const out = []
94
+ for (const [name, list] of Object.entries(networkInterfaces())) for (const a of list ?? []) if (a.family === 'IPv4' && !a.internal) out.push({ name, address: a.address })
95
+ return out
96
+ }
97
+
98
+ // The address of the interface the default route uses, without sending a byte.
99
+ function defaultRouteAddress() {
100
+ return new Promise((resolvePromise) => {
101
+ const s = createSocket('udp4')
102
+ const done = (v) => { try { s.close() } catch {} resolvePromise(v) }
103
+ s.on('error', () => done(null))
104
+ try { s.connect(53, '8.8.8.8', () => { try { done(s.address().address) } catch { done(null) } }) } catch { done(null) }
105
+ })
106
+ }
107
+
108
+ // 'tailscale' (default), 'lan', or an address to use as given.
109
+ export async function resolveBind(kind = 'tailscale') {
110
+ const want = String(kind).toLowerCase()
111
+ if (!['tailscale', 'lan'].includes(want)) return want
112
+ const addrs = ipv4s()
113
+ if (want === 'tailscale') {
114
+ const ts = addrs.find((a) => /tailscale/i.test(a.name) || /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(a.address))
115
+ if (!ts) throw new Error('no Tailscale address on this machine: use --bind lan, or --bind <address>')
116
+ return ts.address
117
+ }
118
+ const viaRoute = await defaultRouteAddress()
119
+ if (viaRoute && addrs.some((a) => a.address === viaRoute)) return viaRoute
120
+ const lan = addrs.find((a) => /^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(a.address) && !/tailscale|virtual|default switch|wsl/i.test(a.name))
121
+ if (!lan) throw new Error('no LAN address found: pass --bind <address>')
122
+ return lan.address
123
+ }
124
+
125
+ export function linkFor(share, token) { return `http://${share.bind}:${share.port}/?token=${token}` }
126
+
127
+ // Whose terminal this is: BATON_PERSON, else the board's owner, else 'local'.
128
+ export function whoami(share = readShare()) {
129
+ const named = String(process.env.LEG_PERSON ?? process.env.BATON_PERSON ?? '').trim()
130
+ if (named && validName(named)) return named
131
+ return share.owner || 'local'
132
+ }
133
+
134
+ export async function turnOn({ bind = 'tailscale', port = Number(process.env.LEG_PORT || process.env.BATON_PORT || 4747), owner } = {}) {
135
+ const share = readShare()
136
+ const address = await resolveBind(bind)
137
+ share.on = true
138
+ share.bind = address
139
+ share.bind_kind = ['tailscale', 'lan'].includes(String(bind).toLowerCase()) ? String(bind).toLowerCase() : 'address'
140
+ share.port = port
141
+ // being first in the roster is not being the owner: a board with nobody in
142
+ // the owner role gets one of its own, so the machine that shares it keeps it
143
+ const named = personNamed(share, share.owner)
144
+ const existing = isOwner(named) ? named : share.people.find((p) => isOwner(p)) ?? null
145
+ if (!existing) {
146
+ const name = validName(owner) ? owner : (validName(userInfo().username) ? userInfo().username.toLowerCase() : 'owner')
147
+ if (personNamed(share, name)) throw new Error(`no one on this board is an owner: leg share add <you> --role owner`)
148
+ share.owner = name
149
+ writeShare(share)
150
+ const added = addPerson(name, { role: 'owner', share })
151
+ return { share: added.share, owner: added.person, token: added.token }
152
+ }
153
+ share.owner = existing.name
154
+ writeShare(share)
155
+ return { share, owner: existing, token: null }
156
+ }
157
+
158
+ export function turnOff() {
159
+ const share = readShare()
160
+ share.on = false
161
+ writeShare(share)
162
+ return share
163
+ }
@@ -0,0 +1,42 @@
1
+ // Station kind: agent. One chain leg runs here (or, after `baton down` or a
2
+ // crashed server, the orchestrator re-attaches to the run it left behind),
3
+ // the run is settled, and its verdict is applied to the card. `ops` are the
4
+ // orchestrator's helpers; this module never imports the orchestrator.
5
+ export async function run({ id, card, station, worktree, actor, ops }) {
6
+ const { unsettledRun, pidAlive, ledgerAppend, patchRun, waitForRun, runLeg, settleRun, readCard, handoffOn, transition, apply, step, log } = ops
7
+ const pending = unsettledRun(id)
8
+ if (pending?.driver_pid && pending.driver_pid !== process.pid && pidAlive(pending.driver_pid)) {
9
+ log(`card ${id}: run ${pending.run} is driven by pid ${pending.driver_pid}; not attaching`)
10
+ return { card, done: true }
11
+ }
12
+ if (pending) {
13
+ ledgerAppend(id, { type: 'status', station: station.name, leg: card.leg, summary: `re-attached to run ${pending.run} (${pending.status}); no new leg launched` })
14
+ patchRun(id, pending.run, { driver_pid: process.pid })
15
+ }
16
+ const runRecord = pending ? await waitForRun(id, pending.run) : await runLeg(card, station, worktree)
17
+ settleRun(id, runRecord)
18
+ const fresh = readCard(id)
19
+ if (fresh.status !== 'running') {
20
+ // A human acted while the leg ran; the machine already moved the card.
21
+ // paused: keep the context for Resume. handing_off: the human asked for
22
+ // the bundle, so write it and queue the next leg. queued: reassigned, and
23
+ // the loop starts the new adapter. killed: nothing more to do.
24
+ if (fresh.status === 'paused') handoffOn(fresh, station, runRecord, worktree, ['paused by a human; resume continues from this bundle'])
25
+ if (fresh.status === 'handing_off') {
26
+ handoffOn(fresh, station, runRecord, worktree, ['hand-off requested by a human; the running leg was stopped'])
27
+ return { card: step(id, 'bundle_written', {}, actor) }
28
+ }
29
+ return { card: fresh, done: fresh.status !== 'queued' }
30
+ }
31
+ const before = fresh
32
+ const result = transition(before, 'leg_result', {
33
+ outcome: runRecord.outcome, handoff: runRecord.handoff, signal: runRecord.signal,
34
+ adapter: station.chain[before.leg]?.adapter, run: runRecord.run,
35
+ })
36
+ let next = apply(id, before, result, actor)
37
+ if (next.status === 'handing_off') {
38
+ handoffOn(next, station, runRecord, worktree)
39
+ next = step(id, 'bundle_written', {}, actor)
40
+ }
41
+ return { card: next }
42
+ }
@@ -0,0 +1,9 @@
1
+ // Station prompt template: build. The agent implements the task.
2
+ export default {
3
+ goal: 'Implement the task in this working directory.',
4
+ deliverables: [
5
+ 'Make the code change the task describes, with tests where the repository has a test suite.',
6
+ 'Run the repository\'s own checks (tests, lint) when they exist and fix what you broke.',
7
+ 'If PLAN.md exists, follow it; note any deviation in .leg/PROGRESS.md.',
8
+ ],
9
+ }
@@ -0,0 +1,5 @@
1
+ // Station kind: human. Nothing runs; the card parks in waiting_human until a
2
+ // person presses Approve (or Kill / Reassign) on the board.
3
+ export async function run({ id, actor, ops }) {
4
+ return { card: ops.step(id, 'start', {}, actor), done: true }
5
+ }
@@ -0,0 +1,6 @@
1
+ // Station template: land. No agent runs here; the merge queue does the work
2
+ // (src/mergequeue.mjs). Kept so every station kind has a template entry.
3
+ export default {
4
+ goal: 'Rebase onto trunk, run the repository tests, fast-forward trunk.',
5
+ deliverables: [],
6
+ }
@@ -0,0 +1,8 @@
1
+ // Station prompt template: plan. The agent turns the task into a plan file.
2
+ export default {
3
+ goal: 'Turn the task into a concrete plan before any code changes.',
4
+ deliverables: [
5
+ 'Write PLAN.md at the repository root: goal, the files you expect to touch, the steps in order, how each step is verified, and open questions.',
6
+ 'Do not implement anything in this station; planning only.',
7
+ ],
8
+ }
@@ -0,0 +1,31 @@
1
+ // pr — the opt-in land mode. Instead of fast-forwarding trunk, build the
2
+ // `gh pr create` argv and run it through BATON_GH_BIN. This run never
3
+ // executes a real gh (hard stop: no remotes); tests point BATON_GH_BIN at a
4
+ // stub that logs its argv and prints a URL.
5
+ import { spawnSync } from 'node:child_process'
6
+ import { mkdirSync, writeFileSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+ import { branchName } from '../worktree.mjs'
9
+ import { scrub } from '../runner.mjs'
10
+
11
+ export function prArgv({ card, bodyFile }) {
12
+ return ['pr', 'create', '--base', card.trunk || 'main', '--head', branchName(card.card_id), '--title', card.title || card.card_id, '--body-file', bodyFile]
13
+ }
14
+
15
+ export function prBody(card, bundleSummary = '') {
16
+ return `## Task\n\n${card.task}\n\n## Leg\n\ncard ${card.card_id}, pipeline ${card.pipeline.map((s) => s.name).join(' → ')}\n\n${bundleSummary ? `## Handoff summary\n\n${bundleSummary}\n` : ''}`
17
+ }
18
+
19
+ export function openPr({ card, runDir, bundleSummary = '' }) {
20
+ const bin = (process.env.LEG_GH_BIN || process.env.BATON_GH_BIN)
21
+ if (!bin) return { ok: false, error: 'LEG_GH_BIN or BATON_GH_BIN is not set; pr land mode is stub-only in this build (no live gh)' }
22
+ mkdirSync(runDir, { recursive: true })
23
+ const bodyFile = join(runDir, 'pr-body.md')
24
+ writeFileSync(bodyFile, prBody(card, bundleSummary))
25
+ const argv = prArgv({ card, bodyFile })
26
+ const viaNode = /\.(mjs|cjs|js)$/.test(bin)
27
+ const r = spawnSync(viaNode ? process.execPath : bin, viaNode ? [bin, ...argv] : argv, { cwd: card.worktree ?? card.repo, windowsHide: true, encoding: 'utf8', timeout: 60000, env: process.env })
28
+ if (r.error || r.status !== 0) return { ok: false, error: scrub(r.error?.message || r.stderr || r.stdout).slice(0, 300), argv }
29
+ const url = (r.stdout.match(/https?:\/\/\S+/) || [null])[0]
30
+ return { ok: true, url, argv, stdout: r.stdout.trim() }
31
+ }
@@ -0,0 +1,8 @@
1
+ // Station prompt template: review. The agent reviews the branch and fixes blockers.
2
+ export default {
3
+ goal: 'Review the changes on this branch against the trunk and fix anything blocking.',
4
+ deliverables: [
5
+ 'Write REVIEW.md at the repository root: findings ordered by severity, each with file:line and a one-line fix.',
6
+ 'Fix every blocking finding (bugs, broken tests, security) in place; leave non-blocking ones listed.',
7
+ ],
8
+ }
@@ -0,0 +1,27 @@
1
+ // Station kind: test. Runs the repo's test command in the worktree; green
2
+ // advances the card, red bounces it to the build station with the tail in
3
+ // the handoff bundle (the attempt cap is shared with the land station).
4
+ export async function run({ id, card, station, worktree, actor, ops }) {
5
+ const { resolveTestCommand, runTestCommand, ledgerAppend, step, transition, handoffOn, apply, readCard } = ops
6
+ const cmd = station.command ?? resolveTestCommand(card, worktree).command
7
+ if (!cmd) {
8
+ ledgerAppend(id, { type: 'status', station: station.name, leg: 0, summary: 'test station: no test command configured; treating as green' })
9
+ return { card: step(id, 'test_result', { green: true }, actor) }
10
+ }
11
+ const t = await runTestCommand(cmd, worktree)
12
+ ledgerAppend(id, { type: 'status', station: station.name, leg: 0, summary: `test ${t.green ? 'green' : 'red'}: ${t.command}${t.timedOut ? ' (timed out)' : ''}`, body: t.tail })
13
+ // the suite ran for a while: a human may have killed or paused the card
14
+ // meanwhile, and a result applied to that stale status would undo them
15
+ const fresh = readCard(id)
16
+ if (fresh.status !== 'running') {
17
+ ledgerAppend(id, { type: 'status', station: station.name, leg: 0, summary: `test result not applied: the card was ${fresh.status} by then` })
18
+ return { card: fresh, done: fresh.status !== 'queued' }
19
+ }
20
+ if (!t.green) {
21
+ const bounced = transition(fresh, 'test_result', { green: false, reason: `test red (${t.command}, exit ${t.status}): ${t.tail.split('\n').slice(-5).join(' | ')}` })
22
+ // Attach the failure to a bundle so the next build leg starts from it.
23
+ handoffOn(fresh, { name: station.name, chain: [] }, { outcome: 'test_red', reason: t.tail, exit_code: t.status, adapter: 'test' }, worktree, [`test red: ${t.command}`, t.tail.slice(0, 1500)])
24
+ return { card: apply(id, fresh, bounced, actor) }
25
+ }
26
+ return { card: step(id, 'test_result', { green: true }, actor) }
27
+ }