@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/runner.mjs ADDED
@@ -0,0 +1,438 @@
1
+ #!/usr/bin/env node
2
+ // Ported 2026-09-10 from private ucsandman team tooling; see NOTICE and docs/REUSE.md.
3
+ // runner — detached, supervised execution of one agent leg on a card.
4
+ // Subcommands: launch | supervise | sweep.
5
+ // State: $BATON_HOME/cards/<id>/runs/<n>/ (run.json, prompt.txt, out.log, err.log,
6
+ // supervisor.log). Ledger writes go through src/ledger.mjs. Exports sanitizeEnv.
7
+ import {
8
+ mkdirSync, readFileSync, existsSync, openSync, copyFileSync, readdirSync, rmSync, statSync,
9
+ } from 'node:fs'
10
+ import { writeJsonAtomic, withFileLock } from './fsx.mjs'
11
+ import { spawn, spawnSync, execFileSync } from 'node:child_process'
12
+ import { join, dirname, resolve } from 'node:path'
13
+ import { homedir } from 'node:os'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { get as getAdapter } from './adapters/index.mjs'
16
+ import { classify } from './limits.mjs'
17
+
18
+ const SELF = fileURLToPath(import.meta.url)
19
+ const LEDGER = join(dirname(SELF), 'ledger.mjs')
20
+ const ROOT = process.env.LEG_HOME || process.env.BATON_HOME || (existsSync(join(homedir(), '.leg')) ? join(homedir(), '.leg') : existsSync(join(homedir(), '.baton')) ? join(homedir(), '.baton') : join(homedir(), '.leg'))
21
+ const [NOTIFY_MS, KILL_MS, KILL_VERIFY_RAW] = ((process.env.LEG_TIMERS_MS || process.env.BATON_TIMERS_MS) || '1800000,5400000')
22
+ .split(',').map(Number)
23
+ const KILL_VERIFY_MS = Number.isFinite(KILL_VERIFY_RAW) ? KILL_VERIFY_RAW : 30000
24
+ const BATON_ACTOR = JSON.stringify({ type: 'baton' })
25
+ const ACTIVE = ['launching', 'running']
26
+
27
+ // Every adapter's env() calls sanitizeEnv; it lives in src/env.mjs (see there)
28
+ // and is re-exported here so callers have one import.
29
+ export { sanitizeEnv } from './env.mjs'
30
+
31
+ function die(code, msg) {
32
+ process.stderr.write(msg + '\n')
33
+ process.exit(code)
34
+ }
35
+
36
+ function parseArgs(argv) {
37
+ const args = {}
38
+ for (let i = 0; i < argv.length; i += 2) {
39
+ if (!argv[i].startsWith('--') || argv[i + 1] === undefined) {
40
+ die(2, `bad argument pair near "${argv[i]}"`)
41
+ }
42
+ args[argv[i].slice(2)] = argv[i + 1]
43
+ }
44
+ return args
45
+ }
46
+
47
+ function need(args, key) {
48
+ const v = args[key]
49
+ if (!v) die(2, `missing --${key}`)
50
+ return v
51
+ }
52
+
53
+ const now = () => new Date().toISOString()
54
+
55
+ function pidAlive(pid) {
56
+ if (!pid) return false
57
+ try { process.kill(pid, 0); return true } catch { return false }
58
+ }
59
+
60
+ function cardDir(id) { return join(ROOT, 'cards', id) }
61
+ function runsDir(id) { return join(cardDir(id), 'runs') }
62
+ function runDir(id, n) { return join(runsDir(id), String(n)) }
63
+
64
+ function listRuns(id) {
65
+ const dir = runsDir(id)
66
+ if (!existsSync(dir)) return []
67
+ return readdirSync(dir).map(Number).filter((n) => Number.isInteger(n) && n > 0).sort((a, b) => a - b)
68
+ }
69
+
70
+ function readRun(id, n) {
71
+ const f = join(runDir(id, n), 'run.json')
72
+ if (!existsSync(f)) return null
73
+ try { return JSON.parse(readFileSync(f, 'utf8')) } catch { return null }
74
+ }
75
+
76
+ function writeRun(id, n, r) {
77
+ // atomic: the orchestrator and the board poll this file (src/fsx.mjs)
78
+ writeJsonAtomic(join(runDir(id, n), 'run.json'), { ...r, updated_at: now() })
79
+ }
80
+
81
+ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
82
+
83
+ // Read-modify-write of run.json under its lock. The supervisor (status,
84
+ // agent_pid, the verdict), the driving orchestrator (driver_pid, settled_at)
85
+ // and a human Kill (kill_requested) all patch this file from different
86
+ // processes; without the lock the last writer silently dropped the others'
87
+ // fields. A read that finds the file torn (the atomic write's direct-write
88
+ // fallback on Windows) is retried before fn sees a null.
89
+ export function updateRun(id, n, fn) {
90
+ // a longer budget than the hook default: nobody's turn is waiting on this,
91
+ // and a patch that runs unlocked is a patch that can be lost
92
+ return withFileLock(join(runDir(id, n), 'run.json.lock'), () => {
93
+ let cur = readRun(id, n)
94
+ for (let i = 0; cur === null && i < 5 && existsSync(join(runDir(id, n), 'run.json')); i++) { pause(20); cur = readRun(id, n) }
95
+ const next = fn(cur)
96
+ if (next) writeRun(id, n, next)
97
+ return next
98
+ }, { retries: 250, waitMs: 20, staleMs: 10000 })
99
+ }
100
+
101
+ // Ledger writes must never crash the supervisor; failures go to its log.
102
+ function ledgerSafe(argsArr, log) {
103
+ try {
104
+ execFileSync(process.execPath, [LEDGER, ...argsArr],
105
+ { windowsHide: true, encoding: 'utf8', env: process.env })
106
+ return true
107
+ } catch (err) {
108
+ log(`ledger write failed: ${String(err.message).slice(0, 300)}`)
109
+ return false
110
+ }
111
+ }
112
+
113
+ function ledgerAppend(id, type, summary, body, log) {
114
+ const argsArr = ['append', '--card', id, '--actor', BATON_ACTOR, '--type', type, '--summary', summary]
115
+ if (body) argsArr.push('--body', body)
116
+ return ledgerSafe(argsArr, log)
117
+ }
118
+
119
+ // The ledger's assertNoSecrets dies on matches; scrub BEFORE logging.
120
+ // One pattern list for the whole project lives in src/redact.mjs.
121
+ import { scrub } from './redact.mjs'
122
+ export { scrub }
123
+
124
+ function errTail(path, lines = 10) {
125
+ if (!existsSync(path)) return '(no stderr)'
126
+ const all = readFileSync(path, 'utf8').trim().split('\n')
127
+ return scrub(all.slice(-lines).join(' | ')).slice(0, 1500)
128
+ }
129
+
130
+ function killTree(pid, log) {
131
+ if ((process.env.LEG_SKIP_KILL || process.env.BATON_SKIP_KILL) === '1') { // test seam: unkillable agent
132
+ log('BATON_SKIP_KILL=1: killTree skipped')
133
+ return
134
+ }
135
+ if (process.platform === 'win32') {
136
+ const r = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, encoding: 'utf8' })
137
+ if (r.status !== 0) log(`taskkill failed: ${String(r.stderr ?? '').slice(0, 300)}`)
138
+ } else {
139
+ try { process.kill(-pid, 'SIGKILL') } catch { try { process.kill(pid, 'SIGKILL') } catch {} }
140
+ }
141
+ }
142
+
143
+ // Work evidence for the classifier: did the leg change anything in its cwd?
144
+ // Cheap and worktree-agnostic: porcelain status plus HEAD movement. Not a git
145
+ // repo → null (the classifier then trusts only the DONE marker).
146
+ function gitHead(cwd) {
147
+ const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
148
+ return r.status === 0 ? r.stdout.trim() : null
149
+ }
150
+
151
+ function gitDiff(cwd, headAtStart) {
152
+ const r = spawnSync('git', ['status', '--porcelain'], { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
153
+ if (r.status !== 0) return null
154
+ const files = r.stdout.split(/\r?\n/).filter(Boolean).filter((l) => !/\.baton[\\/]/.test(l)).length
155
+ const head = gitHead(cwd)
156
+ return { changed: files > 0 || (headAtStart !== null && head !== headAtStart), files, head_at_start: headAtStart, head }
157
+ }
158
+
159
+ // Fallback when the cwd is not a git repo (tests, ad-hoc dirs): a shallow
160
+ // mtime snapshot, so "wrote a file but no DONE" still reads as incomplete.
161
+ const SNAP_SKIP = new Set(['.git', 'node_modules', '.baton', '.baton-worktrees'])
162
+ function fsSnapshot(cwd, depth = 3) {
163
+ const out = new Map()
164
+ const walk = (dir, rel, d) => {
165
+ let names
166
+ try { names = readdirSync(dir) } catch { return }
167
+ for (const name of names) {
168
+ if (SNAP_SKIP.has(name) || out.size > 5000) continue
169
+ const full = join(dir, name)
170
+ let st
171
+ try { st = statSync(full) } catch { continue }
172
+ if (st.isDirectory()) { if (d > 0) walk(full, `${rel}${name}/`, d - 1) } else out.set(`${rel}${name}`, st.mtimeMs)
173
+ }
174
+ }
175
+ walk(cwd, '', depth)
176
+ return out
177
+ }
178
+
179
+ function fsDiff(cwd, before) {
180
+ const after = fsSnapshot(cwd)
181
+ let files = 0
182
+ for (const [k, v] of after) if (!before.has(k) || before.get(k) !== v) files += 1
183
+ for (const k of before.keys()) if (!after.has(k)) files += 1
184
+ return { changed: files > 0, files, head_at_start: null, head: null, source: 'fs' }
185
+ }
186
+
187
+ function legOpts(args) {
188
+ const opts = {}
189
+ if (args.mode) opts.mode = args.mode
190
+ if (args['env-json']) {
191
+ // Extra child variables (e.g. FAKE_MODE for the fake adapter); the adapter's
192
+ // env() still sanitizes the result, so auth keys cannot be smuggled in.
193
+ try { opts.extraEnv = JSON.parse(args['env-json']) } catch { die(2, 'invalid --env-json') }
194
+ if (!opts.extraEnv || typeof opts.extraEnv !== 'object') die(2, 'invalid --env-json')
195
+ }
196
+ if (args['max-turns']) {
197
+ opts.maxTurns = parseInt(args['max-turns'], 10)
198
+ if (!Number.isInteger(opts.maxTurns) || opts.maxTurns < 1) die(2, `invalid --max-turns "${args['max-turns']}"`)
199
+ }
200
+ if (args.resume) opts.resume = args.resume
201
+ if (args.model) opts.model = args.model
202
+ if (args.network === '1' || args.network === 'true') opts.network = true
203
+ return opts
204
+ }
205
+
206
+ function legArgv(opts) {
207
+ const out = []
208
+ if (opts.mode) out.push('--mode', opts.mode)
209
+ if (opts.maxTurns) out.push('--max-turns', String(opts.maxTurns))
210
+ if (opts.resume) out.push('--resume', opts.resume)
211
+ if (opts.model) out.push('--model', opts.model)
212
+ if (opts.network) out.push('--network', '1')
213
+ if (opts.extraEnv) out.push('--env-json', JSON.stringify(opts.extraEnv))
214
+ return out
215
+ }
216
+
217
+ async function main() {
218
+ const [cmd, ...rest] = process.argv.slice(2)
219
+ const args = parseArgs(rest)
220
+
221
+ if (cmd === 'launch') {
222
+ const id = need(args, 'card')
223
+ if (!existsSync(join(cardDir(id), 'card.json'))) die(3, `card not found: ${id}`)
224
+ const adapterName = need(args, 'adapter')
225
+ try { await getAdapter(adapterName) } catch (err) { die(2, err.message) }
226
+ const promptFile = need(args, 'prompt-file')
227
+ if (!existsSync(promptFile)) die(2, `prompt file not found: ${promptFile}`)
228
+ const cwd = resolve(args.cwd || process.cwd())
229
+ const opts = legOpts(args)
230
+ mkdirSync(runsDir(id), { recursive: true })
231
+ let last = 0
232
+ for (const n of listRuns(id)) {
233
+ last = n
234
+ const r = readRun(id, n)
235
+ if (!r || !ACTIVE.includes(r.status)) continue
236
+ if (pidAlive(r.supervisor_pid)) {
237
+ process.stdout.write(JSON.stringify({
238
+ ok: false, error: 'run_already_active', run: n, supervisor_pid: r.supervisor_pid,
239
+ }) + '\n')
240
+ process.exit(11)
241
+ }
242
+ writeRun(id, n, { ...r, status: 'orphaned' })
243
+ }
244
+ const n = last + 1
245
+ const dir = runDir(id, n)
246
+ mkdirSync(dir, { recursive: true })
247
+ copyFileSync(promptFile, join(dir, 'prompt.txt'))
248
+ const logFd = openSync(join(dir, 'supervisor.log'), 'a')
249
+ // run.json exists, naming its driver, before the supervisor is spawned: a
250
+ // second driver or a scheduler tick landing here sees a claimed run, never
251
+ // an empty directory it could launch into.
252
+ const driverPid = args['driver-pid'] ? parseInt(args['driver-pid'], 10) : null
253
+ writeRun(id, n, {
254
+ card_id: id, run: n, adapter: adapterName, mode: opts.mode ?? null,
255
+ max_turns: opts.maxTurns ?? null, model: opts.model ?? null, resume: opts.resume ?? null, cwd,
256
+ status: 'launching', supervisor_pid: null, agent_pid: null,
257
+ driver_pid: Number.isInteger(driverPid) ? driverPid : null,
258
+ started_at: now(), outcome: null,
259
+ })
260
+ const child = spawn(process.execPath,
261
+ [SELF, 'supervise', '--card', id, '--adapter', adapterName, '--run', String(n),
262
+ '--cwd', cwd, ...legArgv(opts)],
263
+ { detached: true, windowsHide: true, stdio: ['ignore', logFd, logFd], env: process.env })
264
+ child.unref()
265
+ updateRun(id, n, (cur) => ({ ...cur, supervisor_pid: child.pid }))
266
+ process.stdout.write(JSON.stringify({
267
+ ok: true, run: n, supervisor_pid: child.pid, run_dir: dir,
268
+ }) + '\n')
269
+ process.exit(0)
270
+ } else if (cmd === 'supervise') {
271
+ const id = need(args, 'card')
272
+ const adapterName = need(args, 'adapter')
273
+ const n = parseInt(need(args, 'run'), 10)
274
+ if (!Number.isInteger(n) || n < 1) die(2, `invalid --run "${args.run}"`)
275
+ const cwd = resolve(args.cwd || process.cwd())
276
+ const opts = legOpts(args)
277
+ if (!Number.isFinite(NOTIFY_MS) || !Number.isFinite(KILL_MS)) {
278
+ die(2, `bad BATON_TIMERS_MS "${process.env.BATON_TIMERS_MS}"`)
279
+ }
280
+ const dir = runDir(id, n)
281
+ // what a run.json patch falls back to if the file cannot be read (torn)
282
+ const base = { card_id: id, run: n, adapter: adapterName, cwd, supervisor_pid: process.pid, started_at: now() }
283
+ // stdout IS supervisor.log when launched detached; timestamps make it a log.
284
+ const log = (msg) => process.stdout.write(`${now()} ${msg}\n`)
285
+ const prompt = readFileSync(join(dir, 'prompt.txt'), 'utf8')
286
+ const outPath = join(dir, 'out.log')
287
+ const errPath = join(dir, 'err.log')
288
+ const outFd = openSync(outPath, 'w')
289
+ const errFd = openSync(errPath, 'w')
290
+ let adapter
291
+ try { adapter = await getAdapter(adapterName) } catch (err) { die(2, err.message) }
292
+ let spec
293
+ try {
294
+ spec = adapter.argv({ ...opts, cwd, prompt, promptFile: join(dir, 'prompt.txt'), runDir: dir, killMs: KILL_MS })
295
+ } catch (err) {
296
+ // A forbidden mode/flag never spawns: record it and fail the leg.
297
+ log(`refusing to launch: ${err.message}`)
298
+ ledgerAppend(id, 'error', `[supervisor] refused to launch: ${scrub(err.message).slice(0, 200)}`, null, log)
299
+ updateRun(id, n, (cur) => ({ ...(cur ?? base), status: 'failed', exit_code: null, ended_at: now(), refusal: err.message }))
300
+ process.exit(13)
301
+ }
302
+ const childEnv = adapter.env({ ...process.env, ...(opts.extraEnv ?? {}) })
303
+ // A stale DONE marker from an earlier leg must not count for this one.
304
+ rmSync(join(cwd, '.leg', 'DONE'), { force: true })
305
+ rmSync(join(cwd, '.baton', 'DONE'), { force: true })
306
+ const headAtStart = gitHead(cwd)
307
+ const fsAtStart = headAtStart === null ? fsSnapshot(cwd) : null
308
+ log(`spawning leg: adapter=${adapterName} run=${n} mode=${opts.mode ?? 'default'} cwd=${cwd}`)
309
+ ledgerAppend(id, 'leg_started', `leg started: adapter=${adapterName} run=${n} mode=${opts.mode ?? 'default'}`, null, log)
310
+ const child = spawn(spec.bin, spec.args, {
311
+ cwd, windowsHide: true, stdio: [adapter.stdin === 'pipe' ? 'pipe' : 'ignore', outFd, errFd], env: childEnv,
312
+ })
313
+ if (adapter.stdin === 'pipe') {
314
+ child.stdin.on('error', () => {})
315
+ child.stdin.write(prompt)
316
+ child.stdin.end()
317
+ }
318
+
319
+ child.on('error', (err) => {
320
+ log(`agent spawn error: ${err.message}`)
321
+ ledgerAppend(id, 'error', `[supervisor] agent spawn failed: ${scrub(err.message).slice(0, 200)}`, null, log)
322
+ // A run that never started still needs a verdict, or the orchestrator
323
+ // has nothing to transition on and the card sits in `running` forever.
324
+ const verdict = classify({
325
+ adapter: adapter.emulates ?? adapterName, exitCode: null, stdout: '', stderr: '', result: null,
326
+ doneMarker: false, diff: null, killedByTimer: false, killedByHuman: false, spawnError: err.message,
327
+ })
328
+ updateRun(id, n, (cur) => ({
329
+ ...(cur ?? base), status: 'failed', exit_code: null, ended_at: now(),
330
+ outcome: verdict.outcome, signal: verdict.signal, handoff: verdict.handoff, reason: verdict.reason,
331
+ }))
332
+ process.exit(13)
333
+ })
334
+
335
+ updateRun(id, n, (cur) => ({ ...(cur ?? base), status: 'running', agent_pid: child.pid }))
336
+ let killedByTimer = false
337
+ const notifyTimer = setTimeout(() => {
338
+ ledgerAppend(id, 'status',
339
+ `[supervisor] leg still running after ${Math.round(NOTIFY_MS / 60000)}m`,
340
+ `log: ${errPath}`, log)
341
+ }, NOTIFY_MS)
342
+ const killTimer = setTimeout(() => {
343
+ killedByTimer = true
344
+ log(`kill timer fired after ${KILL_MS}ms; killing pid ${child.pid}`)
345
+ killTree(child.pid, log)
346
+ // Kill-verify fallback: taskkill can fail and leave the agent burning
347
+ // tokens with the supervisor blocked on the child handle forever.
348
+ // Retry once, then surface the unkillable pid and exit anyway.
349
+ setTimeout(() => {
350
+ if (child.exitCode !== null || !pidAlive(child.pid)) return
351
+ log(`agent pid ${child.pid} survived kill; retrying once`)
352
+ killTree(child.pid, log)
353
+ setTimeout(() => {
354
+ if (child.exitCode !== null || !pidAlive(child.pid)) return
355
+ ledgerAppend(id, 'error',
356
+ `[supervisor] agent UNKILLABLE (pid ${child.pid}); manual kill required`, null, log)
357
+ updateRun(id, n, (cur) => ({ ...(cur ?? base), status: 'killed', exit_code: null, ended_at: now() }))
358
+ process.exit(12)
359
+ }, KILL_VERIFY_MS)
360
+ }, KILL_VERIFY_MS)
361
+ }, KILL_MS)
362
+
363
+ child.on('exit', (code) => {
364
+ clearTimeout(notifyTimer)
365
+ clearTimeout(killTimer)
366
+ // JSON-only-at-end: the result file is read here, after the exit event,
367
+ // and nowhere else (LESSONS 07-10).
368
+ let stdout = ''
369
+ let stderr = ''
370
+ try { stdout = readFileSync(outPath, 'utf8') } catch {}
371
+ try { stderr = readFileSync(errPath, 'utf8') } catch {}
372
+ let parsed = null
373
+ try { parsed = adapter.parseResult(stdout) } catch {}
374
+ const sessionId = parsed?.session_id ?? null
375
+ if (sessionId) {
376
+ ledgerSafe(['update', '--card', id, '--session-id', sessionId], log)
377
+ }
378
+ const current = readRun(id, n)
379
+ const doneMarker = existsSync(join(cwd, '.leg', 'DONE')) || existsSync(join(cwd, '.baton', 'DONE'))
380
+ const diff = fsAtStart ? fsDiff(cwd, fsAtStart) : gitDiff(cwd, headAtStart)
381
+ const verdict = classify({
382
+ adapter: adapter.emulates ?? adapterName, exitCode: code, stdout, stderr, result: parsed?.raw ?? null,
383
+ doneMarker, diff, killedByTimer, killedByHuman: current?.kill_requested === true, spawnError: null,
384
+ })
385
+ const final = {
386
+ exit_code: code, session_id: sessionId, ended_at: now(),
387
+ outcome: verdict.outcome, signal: verdict.signal, handoff: verdict.handoff, reason: verdict.reason,
388
+ done_marker: doneMarker, diff,
389
+ }
390
+ const summary = `[supervisor] leg ${verdict.outcome} (exit ${code}${sessionId ? `, session ${sessionId}` : ''}${verdict.signal !== 'none' ? `, signal ${verdict.signal}` : ''})`
391
+ if (killedByTimer || verdict.outcome === 'killed') {
392
+ ledgerAppend(id, 'killed', summary, verdict.reason, log)
393
+ updateRun(id, n, (cur) => ({ ...(cur ?? current ?? base), ...final, status: 'killed' }))
394
+ process.exit(12)
395
+ }
396
+ const eventType = verdict.outcome === 'limit' ? 'limit_detected'
397
+ : (verdict.outcome === 'auth_failed' || verdict.outcome === 'launch_failed') ? 'error'
398
+ : 'leg_exited'
399
+ ledgerAppend(id, eventType, summary,
400
+ verdict.outcome === 'completed' ? null : `${verdict.reason}${code === 0 ? '' : ` | stderr: ${errTail(errPath)}`}`, log)
401
+ updateRun(id, n, (cur) => ({ ...(cur ?? current ?? base), ...final, status: 'exited' }))
402
+ log(`leg exited code ${code}: ${verdict.outcome} (${verdict.reason})`)
403
+ process.exit(verdict.outcome === 'completed' ? 0 : 13)
404
+ })
405
+ } else if (cmd === 'sweep') {
406
+ const log = (msg) => process.stdout.write(`${msg}\n`)
407
+ const cardsRoot = join(ROOT, 'cards')
408
+ const ids = existsSync(cardsRoot) ? readdirSync(cardsRoot).sort() : []
409
+ let active = 0
410
+ const orphanLines = []
411
+ for (const id of ids) {
412
+ for (const n of listRuns(id)) {
413
+ const r = readRun(id, n)
414
+ if (!r || !ACTIVE.includes(r.status)) continue
415
+ if (pidAlive(r.supervisor_pid)) { active += 1; continue }
416
+ writeRun(id, n, { ...r, status: 'orphaned' })
417
+ ledgerAppend(id, 'error',
418
+ `[sweep] supervisor pid ${r.supervisor_pid} dead; run ${n} orphaned`, null, log)
419
+ const agentAlive = pidAlive(r.agent_pid)
420
+ orphanLines.push(`ORPHANED ${id} run ${n}: supervisor ${r.supervisor_pid} dead; ` +
421
+ (agentAlive
422
+ ? `agent pid ${r.agent_pid} STILL ALIVE — consider: taskkill /PID ${r.agent_pid} /T /F`
423
+ : `agent pid ${r.agent_pid ?? 'unknown'} dead`))
424
+ }
425
+ }
426
+ for (const line of orphanLines) log(line)
427
+ if (!orphanLines.length) {
428
+ log(active ? `OK: ${active} active run(s), no orphans` : 'OK: no active runs')
429
+ }
430
+ process.exit(0)
431
+ } else {
432
+ die(2, `unknown command "${cmd ?? ''}" (expected launch|supervise|sweep)`)
433
+ }
434
+ }
435
+
436
+ const isMain = process.argv[1]
437
+ && resolve(process.argv[1]).toLowerCase() === SELF.toLowerCase()
438
+ if (isMain) await main()
@@ -0,0 +1,118 @@
1
+ // scheduler — every tick reads the ledger (card.json files are truth, never
2
+ // in-memory state), starts queued cards whose path leases do not overlap any
3
+ // running card's leases, up to BATON_MAX_CONCURRENT, and records one
4
+ // blocked_by event per blocker change. Cards run inside this process via
5
+ // orchestrator.runCard (async, non-blocking).
6
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+ import { conflicts } from './leases.mjs'
9
+ import { canonPath } from './fsx.mjs'
10
+ import { runCard, orphanedRun, unsettledRun, driverAlive } from './orchestrator.mjs'
11
+ import { listCards, ledgerAppend, ledgerLog, home, sleep } from './store.mjs'
12
+
13
+ export const MAX_CONCURRENT = Math.max(1, parseInt((process.env.LEG_MAX_CONCURRENT || process.env.BATON_MAX_CONCURRENT) || '2', 10) || 2)
14
+ const ACTIVE = ['running', 'handing_off']
15
+
16
+ // Two cards on one repo compare by the canonical path, so two spellings of a
17
+ // checkout (short name, symlink, case) never look like two repos.
18
+ const repoKey = (c) => { try { return canonPath(c.repo) } catch { return String(c.repo) } }
19
+ const stationKind = (c) => (c.pipeline ?? []).find((s) => s.name === c.station)?.kind
20
+
21
+ // Repos with a land station running right now (by station KIND: a land
22
+ // station can be named anything).
23
+ export function landingRepos(cards) {
24
+ return new Set(cards.filter((c) => c.status === 'running' && stationKind(c) === 'land').map(repoKey))
25
+ }
26
+
27
+ // Pure: which queued cards may start now, and why the others cannot.
28
+ export function pickRunnable(cards, { max = MAX_CONCURRENT, landing = new Set() } = {}) {
29
+ const running = cards.filter((c) => ACTIVE.includes(c.status))
30
+ const queued = cards.filter((c) => c.status === 'queued').sort((a, b) => (a.created_at < b.created_at ? -1 : 1))
31
+ const keys = new Map(cards.map((c) => [c.card_id, repoKey(c)]))
32
+ const landingKeys = new Set([...landing].map((repo) => repoKey({ repo })))
33
+ const start = []
34
+ const blocked = []
35
+ for (const c of queued) {
36
+ if (running.length + start.length >= max) { blocked.push({ card: c, conflicts: [], reason: `concurrency cap ${max}` }); continue }
37
+ // a landing repo only holds back other cards that want to land; build stations keep running
38
+ const wantsLand = stationKind(c) === 'land'
39
+ if (wantsLand && landingKeys.has(keys.get(c.card_id))) { blocked.push({ card: c, conflicts: [], reason: 'repo is landing' }); continue }
40
+ const cf = conflicts({ card_id: c.card_id, leases: c.leases }, [...running, ...start].filter((r) => keys.get(r.card_id) === keys.get(c.card_id)))
41
+ if (cf.length) { blocked.push({ card: c, conflicts: cf, reason: 'lease overlap' }); continue }
42
+ start.push(c)
43
+ }
44
+ return { start, blocked, running }
45
+ }
46
+
47
+ export function pidfile() { return join(home(), 'scheduler.pid') }
48
+
49
+ export function createScheduler({ max = MAX_CONCURRENT, intervalMs = 1000, actor = { type: 'baton' } } = {}) {
50
+ const state = { blockedKeys: new Map(), inflight: new Map(), stopped: false, ticks: 0 }
51
+
52
+ async function tick() {
53
+ state.ticks += 1
54
+ const cards = listCards()
55
+ const landing = landingRepos(cards)
56
+ const { start, blocked } = pickRunnable(cards, { max, landing })
57
+ for (const b of blocked) {
58
+ const key = b.conflicts.length ? b.conflicts.map((x) => `${x.holder}:${x.lease}`).join(',') : b.reason
59
+ if (state.blockedKeys.get(b.card.card_id) === key) continue
60
+ state.blockedKeys.set(b.card.card_id, key)
61
+ const summary = b.conflicts.length
62
+ ? `blocked by ${b.conflicts[0].holder} on ${b.conflicts[0].lease} (against ${b.conflicts[0].against})`
63
+ : `blocked: ${b.reason}`
64
+ ledgerAppend(b.card.card_id, { actor, type: 'blocked_by', summary, station: b.card.station, leg: b.card.leg })
65
+ }
66
+ // Running cards nobody alive is driving (their orchestrator died with the
67
+ // last server) get re-attached so a finished run's verdict is applied, a
68
+ // test or land station left mid-way runs again, and a card that crashed
69
+ // between `start` and its launch gets its leg. A card a live `card run`
70
+ // is driving (its driver.lock names a live pid) is left to that process.
71
+ const reattach = cards.filter((c) => ACTIVE.includes(c.status) && !state.inflight.has(c.card_id) && !driverAlive(c.card_id)
72
+ && (c.status === 'handing_off' || stationKind(c) !== 'agent' || !unsettledRun(c.card_id) || orphanedRun(c.card_id)))
73
+ for (const c of [...start, ...reattach]) {
74
+ if (state.inflight.has(c.card_id)) continue
75
+ state.blockedKeys.delete(c.card_id)
76
+ const p = runCard(c.card_id, { actor }).catch((err) => {
77
+ try { ledgerAppend(c.card_id, { actor, type: 'error', summary: `orchestrator crashed: ${String(err.message).slice(0, 300)}`, station: c.station, leg: c.leg }) } catch {}
78
+ }).finally(() => state.inflight.delete(c.card_id))
79
+ state.inflight.set(c.card_id, p)
80
+ }
81
+ return { started: start.map((c) => c.card_id), reattached: reattach.map((c) => c.card_id), blocked: blocked.map((b) => b.card.card_id), inflight: [...state.inflight.keys()] }
82
+ }
83
+
84
+ async function run({ ticks = Infinity } = {}) {
85
+ // one scheduler per home: the pidfile is created atomically, and a live
86
+ // holder (the board's in-process scheduler, or a CLI one) wins
87
+ try { writeFileSync(pidfile(), String(process.pid), { flag: 'wx' }) } catch (err) {
88
+ if (err.code !== 'EEXIST') throw err
89
+ const st = schedulerStatus()
90
+ if (st.running && st.pid !== process.pid) throw new Error(`scheduler already running (pid ${st.pid})`)
91
+ writeFileSync(pidfile(), String(process.pid))
92
+ }
93
+ ledgerLog({ actor, type: 'scheduler_started', summary: `scheduler started (max ${max}, pid ${process.pid})` })
94
+ try {
95
+ for (let i = 0; i < ticks && !state.stopped; i++) {
96
+ await tick()
97
+ if (i + 1 < ticks && !state.stopped) await sleep(intervalMs)
98
+ }
99
+ await Promise.allSettled([...state.inflight.values()])
100
+ } finally {
101
+ ledgerLog({ actor, type: 'scheduler_stopped', summary: `scheduler stopped after ${state.ticks} tick(s)` })
102
+ try { if (existsSync(pidfile()) && readFileSync(pidfile(), 'utf8').trim() === String(process.pid)) unlinkSync(pidfile()) } catch {}
103
+ }
104
+ }
105
+
106
+ function stop() { state.stopped = true }
107
+
108
+ return { tick, run, stop, state }
109
+ }
110
+
111
+ export function schedulerStatus() {
112
+ const f = pidfile()
113
+ if (!existsSync(f)) return { running: false, pid: null }
114
+ const pid = parseInt(readFileSync(f, 'utf8').trim(), 10)
115
+ let alive = false
116
+ try { process.kill(pid, 0); alive = true } catch {}
117
+ return { running: alive, pid, stale: !alive }
118
+ }