@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,286 @@
1
+ // codex tap — an interactive `codex` writes its whole thread to a rollout
2
+ // file, flushed per event (observed live 2026-09-11: file mtime == last line
3
+ // timestamp). Baton tails that file; no hook is injected, so codex never shows
4
+ // its "review new hooks" prompt for a Baton session.
5
+ // Shapes (observed live, codex-cli 0.153.4, originator codex-tui):
6
+ // session_meta.payload {id, cwd, originator, cli_version, source}
7
+ // event_msg.token_count.rate_limits {primary:{used_percent,window_minutes,resets_at}, secondary:{…}}
8
+ // primary = 300 min (5h), secondary = 10080 min (7d)
9
+ // event_msg.task_complete.error {message:"You've hit your usage limit…", codex_error_info:"usage_limit_exceeded"}
10
+ // response_item.message {role:'user'|'assistant', content:[{type:'input_text'|'output_text', text}]}
11
+ // Source of the error text: codex-rs/protocol/src/error.rs (UsageLimitReachedError).
12
+ import { spawn, spawnSync } from 'node:child_process'
13
+ import { existsSync, readdirSync, readFileSync, statSync, openSync, readSync, closeSync, fstatSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import { LAYOUT } from '../accounts.mjs'
16
+ import { sanitizeEnv } from '../env.mjs'
17
+ import codexAdapter from '../adapters/codex.mjs'
18
+
19
+ const LEG_VERSION = (() => { try { return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version } catch { return 'unknown' } })()
20
+
21
+ export function sessionsRootFor(codexHome = LAYOUT.codex.home()) { return join(codexHome, 'sessions') }
22
+
23
+ function sameDir(a, b) {
24
+ const n = (x) => String(x ?? '').replace(/\\/g, '/').replace(/\/$/, '').toLowerCase()
25
+ return n(a) === n(b)
26
+ }
27
+
28
+ const localDay = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
29
+
30
+ // Newest rollout created at or after `sinceMs` whose session_meta cwd is `cwd`.
31
+ export function findRollout({ codexHome, cwd, sinceMs, allowOlderMs = 5000 }) {
32
+ const root = sessionsRootFor(codexHome)
33
+ if (!existsSync(root)) return null
34
+ // codex names the day directory from LOCAL time while the rollout's own
35
+ // session_meta timestamp is UTC (observed live: a 20:33 EDT rollout under
36
+ // sessions/2026/09/10 stamped 2026-09-11T00:33:44Z). Compare local days, and
37
+ // keep the one before as slack around midnight.
38
+ const from = localDay(new Date(sinceMs - allowOlderMs - 86400000))
39
+ const days = []
40
+ for (const y of readdirSync(root)) for (const m of safeList(join(root, y))) for (const day of safeList(join(root, y, m))) {
41
+ if (`${y}-${m}-${day}` >= from) days.push(join(root, y, m, day))
42
+ }
43
+ const cands = []
44
+ for (const dir of days) for (const f of safeList(dir)) {
45
+ if (!f.startsWith('rollout-') || !f.endsWith('.jsonl')) continue
46
+ const p = join(dir, f)
47
+ let st
48
+ try { st = statSync(p) } catch { continue }
49
+ if (st.birthtimeMs && st.birthtimeMs < sinceMs - allowOlderMs && st.mtimeMs < sinceMs - allowOlderMs) continue
50
+ cands.push({ p, mtime: st.mtimeMs })
51
+ }
52
+ cands.sort((a, b) => b.mtime - a.mtime)
53
+ for (const c of cands) {
54
+ const meta = readMeta(c.p)
55
+ if (meta && sameDir(meta.cwd, cwd) && Date.parse(meta.timestamp ?? 0) >= sinceMs - allowOlderMs) return { path: c.p, meta }
56
+ }
57
+ return null
58
+ }
59
+
60
+ function safeList(dir) { try { return readdirSync(dir) } catch { return [] } }
61
+
62
+ export function readMeta(path) {
63
+ try {
64
+ const fd = openSync(path, 'r')
65
+ // session_meta carries the git snapshot and can run past 15 KB (seen live)
66
+ const buf = Buffer.alloc(Math.min(1048576, fstatSync(fd).size))
67
+ readSync(fd, buf, 0, buf.length, 0)
68
+ closeSync(fd)
69
+ const first = buf.toString('utf8').split('\n')[0]
70
+ const j = JSON.parse(first)
71
+ return j.type === 'session_meta' ? j.payload : null
72
+ } catch { return null }
73
+ }
74
+
75
+ // Incremental reader: keeps a byte offset and a partial line. `from` skips
76
+ // what is already in the file (agy's log is per session, not per leg).
77
+ export function createTail(path, { from = 0 } = {}) {
78
+ let offset = from
79
+ let rest = ''
80
+ return {
81
+ path,
82
+ read() {
83
+ let st
84
+ try { st = statSync(path) } catch { return [] }
85
+ // truncated or replaced under us: read it again rather than go blind
86
+ if (st.size < offset) { offset = 0; rest = '' }
87
+ if (st.size <= offset) return []
88
+ const fd = openSync(path, 'r')
89
+ const buf = Buffer.alloc(st.size - offset)
90
+ readSync(fd, buf, 0, buf.length, offset)
91
+ closeSync(fd)
92
+ offset = st.size
93
+ const text = rest + buf.toString('utf8')
94
+ const lines = text.split('\n')
95
+ rest = lines.pop() ?? ''
96
+ return lines.filter(Boolean)
97
+ },
98
+ }
99
+ }
100
+
101
+ const USAGE_LIMIT_RE = /hit your usage limit/i
102
+ const RETRY_AT_RE = /try again at ([^.]+?)(?:\.|$)/i
103
+ const PATCH_BLOCK_RE = /\*\*\* Begin Patch\n([\s\S]*?)\n\*\*\* End Patch/g
104
+ const PATCH_FILE_RE = /^\*\*\* (?:Add|Update|Delete) File: ([^\r\n]+)$/gm
105
+
106
+ function applyPatchArgument(args) {
107
+ const text = String(args ?? '')
108
+ if (text.startsWith('*** Begin Patch')) return text
109
+ const start = /tools\.apply_patch\(\s*(['"])/.exec(text)
110
+ if (!start) return text
111
+ const quote = start[1]
112
+ let value = ''
113
+ for (let i = start.index + start[0].length; i < text.length; i += 1) {
114
+ const char = text[i]
115
+ if (char === quote) return value
116
+ if (char !== '\\' || i + 1 === text.length) { value += char; continue }
117
+ const escaped = text[++i]
118
+ if (escaped === 'n') value += '\n'
119
+ else if (escaped === 'r') value += '\r'
120
+ else if (escaped === '\\' || escaped === quote) value += escaped
121
+ else value += `\\${escaped}`
122
+ }
123
+ return text
124
+ }
125
+
126
+ function patchFiles(args) {
127
+ // A functions.exec payload can contain an apply_patch JavaScript string,
128
+ // where patch line breaks are serialized as literal "\\n". Decode only
129
+ // that string argument so direct patches retain literal Windows backslashes.
130
+ const patch = applyPatchArgument(args).replace(/\r\n/g, '\n')
131
+ const files = []
132
+ for (const block of patch.matchAll(PATCH_BLOCK_RE)) {
133
+ let match
134
+ while ((match = PATCH_FILE_RE.exec(block[1]))) files.push(match[1].trim())
135
+ }
136
+ return files
137
+ }
138
+
139
+ // Codex can expose either window as primary. The duration is the identity:
140
+ // current Pro Lite rollouts expose only primary=10080 (the weekly window).
141
+ export function normalizeRateLimits(rateLimits) {
142
+ const out = { five_hour: null, seven_day: null }
143
+ const entries = [rateLimits?.primary, rateLimits?.secondary]
144
+ for (const raw of entries) {
145
+ if (!raw) continue
146
+ const pct = Number(raw.used_percent ?? raw.usedPercent)
147
+ if (!Number.isFinite(pct)) continue
148
+ const minutes = Number(raw.window_minutes ?? raw.windowDurationMins)
149
+ const window = { pct, resets_at: raw.resets_at ?? raw.resetsAt ?? null, window_minutes: Number.isFinite(minutes) ? minutes : null }
150
+ if (minutes === 300) out.five_hour = window
151
+ else if (minutes === 10080) out.seven_day = window
152
+ // An absent or unfamiliar duration stays unknown. Primary is a transport
153
+ // position, not a promise that this is the 5-hour bucket.
154
+ }
155
+ return out
156
+ }
157
+
158
+ function parseRetryAt(msg) {
159
+ const m = RETRY_AT_RE.exec(msg ?? '')
160
+ if (!m) return null
161
+ // "Sep 14th, 2026 9:35 PM" → strip the ordinal suffix
162
+ const t = Date.parse(m[1].replace(/(\d+)(st|nd|rd|th)/, '$1'))
163
+ return Number.isFinite(t) ? Math.floor(t / 1000) : null
164
+ }
165
+
166
+ // lines → { limits, limit, messages, turnsDone, taskStarted, threadId }
167
+ export function parseLines(lines) {
168
+ const out = { limits: null, limits_at: null, limit: null, messages: [], turnsDone: 0, taskStarted: 0, threadId: null, files: [] }
169
+ for (const line of lines) {
170
+ let j
171
+ try { j = JSON.parse(line) } catch { continue }
172
+ const p = j.payload ?? {}
173
+ if (j.type === 'session_meta') { out.threadId = p.id ?? null; continue }
174
+ if (j.type === 'event_msg') {
175
+ if (p.type === 'token_count' && p.rate_limits) {
176
+ out.limits = normalizeRateLimits(p.rate_limits)
177
+ out.limits_at = j.timestamp ?? null
178
+ } else if (p.type === 'task_started') out.taskStarted += 1
179
+ else if (p.type === 'task_complete') {
180
+ out.turnsDone += 1
181
+ if (p.last_agent_message) out.messages.push({ role: 'assistant', text: String(p.last_agent_message).slice(0, 1500), ts: j.timestamp ?? null })
182
+ const err = p.error
183
+ if (err && (err.codex_error_info === 'usage_limit_exceeded' || USAGE_LIMIT_RE.test(err.message ?? ''))) {
184
+ out.limit = { reason: 'usage_limit_exceeded', detail: String(err.message ?? '').slice(0, 300), resets_at: parseRetryAt(err.message), observed_at: j.timestamp ?? null, raw: j }
185
+ }
186
+ } else if (p.type === 'error' && USAGE_LIMIT_RE.test(p.message ?? '')) {
187
+ out.limit = { reason: 'usage_limit_exceeded', detail: String(p.message).slice(0, 300), resets_at: parseRetryAt(p.message), observed_at: j.timestamp ?? null, raw: j }
188
+ }
189
+ continue
190
+ }
191
+ if (j.type === 'response_item') {
192
+ if (p.type === 'message' && Array.isArray(p.content)) {
193
+ const text = p.content.filter((c) => (c.type === 'input_text' || c.type === 'output_text') && c.text).map((c) => c.text).join('\n').trim()
194
+ // skip the injected context (<environment_context>, and the "# AGENTS.md instructions" block codex
195
+ // prepends to a thread: observed live 2026-09-11 as the first user message of every rollout)
196
+ if (text && !/^<[a-z_-]+>/i.test(text) && !/^# AGENTS\.md instructions/i.test(text)) out.messages.push({ role: p.role === 'user' ? 'user' : 'assistant', text: text.slice(0, 1500), ts: j.timestamp ?? null })
197
+ } else if (p.type === 'function_call' || p.type === 'custom_tool_call') {
198
+ const args = String(p.arguments ?? p.input ?? '')
199
+ out.files.push(...patchFiles(args))
200
+ }
201
+ }
202
+ }
203
+ return out
204
+ }
205
+
206
+ // Read the account quota without starting a model turn. This protocol and the
207
+ // `ordinaryUsageAllowed` tri-state come from `codex app-server
208
+ // generate-json-schema --experimental`; null means unavailable and must not be
209
+ // inferred from percentages.
210
+ export function readCodexUsage({ codexHome = LAYOUT.codex.home(), timeoutMs = 8000, signal = null } = {}) {
211
+ return new Promise((resolvePromise) => {
212
+ let child
213
+ let settled = false
214
+ let stdout = ''
215
+ const killProbe = () => {
216
+ if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return
217
+ if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
218
+ else { try { process.kill(-child.pid, 'SIGTERM') } catch { try { child.kill() } catch {} } }
219
+ }
220
+ const finish = (result, { kill = false } = {}) => {
221
+ if (settled) return
222
+ settled = true
223
+ clearTimeout(timer)
224
+ signal?.removeEventListener?.('abort', onAbort)
225
+ if (kill) killProbe()
226
+ else {
227
+ try { child?.stdin.end() } catch {}
228
+ const cleanup = setTimeout(killProbe, 1000)
229
+ cleanup.unref?.()
230
+ child?.once('exit', () => clearTimeout(cleanup))
231
+ }
232
+ resolvePromise(result)
233
+ }
234
+ const stop = (error) => {
235
+ finish({ ok: false, limits: null, available: null, observed_at: null, error }, { kill: true })
236
+ }
237
+ const onAbort = () => stop('codex usage read cancelled')
238
+ const timer = setTimeout(() => stop('codex usage read timed out'), timeoutMs)
239
+ timer.unref?.()
240
+ if (signal?.aborted) return onAbort()
241
+ signal?.addEventListener?.('abort', onAbort, { once: true })
242
+ try {
243
+ const { bin, viaNode, entry } = codexAdapter.resolve()
244
+ const args = [...(viaNode ? [entry ?? bin] : []), 'app-server', '--listen', 'stdio://']
245
+ const env = { ...sanitizeEnv(process.env, { interactive: true }), CODEX_HOME: codexHome }
246
+ child = spawn(viaNode ? process.execPath : bin, args, { env, windowsHide: true, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] })
247
+ child.stderr.resume()
248
+ } catch { return stop('codex app-server failed to start') }
249
+ child.on('error', () => stop('codex app-server failed to start'))
250
+ child.stdin.on('error', () => stop('codex app-server input closed'))
251
+ child.on('exit', (code) => { if (!settled) finish({ ok: false, limits: null, available: null, observed_at: null, error: `codex app-server exited (${code ?? 'unknown'})` }) })
252
+ child.stdout.on('data', (chunk) => {
253
+ stdout += chunk.toString('utf8')
254
+ const lines = stdout.split('\n')
255
+ stdout = lines.pop() ?? ''
256
+ for (const line of lines) {
257
+ let message
258
+ try { message = JSON.parse(line) } catch { continue }
259
+ if (message.id === 1 && message.result) {
260
+ try {
261
+ child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`)
262
+ child.stdin.write(`${JSON.stringify({ id: 2, method: 'account/rateLimits/read', params: { excludeResetCreditDetails: true } })}\n`)
263
+ } catch { return stop('codex app-server input closed') }
264
+ } else if (message.id === 2) {
265
+ if (message.error || !message.result) return stop('codex rate-limit read failed')
266
+ const snapshot = message.result.rateLimitsByLimitId?.codex ?? message.result.rateLimits
267
+ const limits = normalizeRateLimits(snapshot)
268
+ const available = typeof message.result.ordinaryUsageAllowed === 'boolean' ? message.result.ordinaryUsageAllowed : null
269
+ return finish({ ok: available !== null || Boolean(limits.five_hour || limits.seven_day), limits, available, observed_at: new Date().toISOString(), error: null })
270
+ }
271
+ }
272
+ })
273
+ try { child.stdin.write(`${JSON.stringify({ id: 1, method: 'initialize', params: { clientInfo: { name: 'leg', version: LEG_VERSION }, capabilities: { experimentalApi: true } } })}\n`) } catch { stop('codex app-server input closed') }
274
+ })
275
+ }
276
+
277
+ export function firstPrompt(path) {
278
+ if (!path || !existsSync(path)) return null
279
+ const r = parseLines(readFileSync(path, 'utf8').split('\n').filter(Boolean))
280
+ return r.messages.find((m) => m.role === 'user')?.text.slice(0, 500) ?? null
281
+ }
282
+
283
+ export function transcriptTail(path, limit = 8) {
284
+ if (!path || !existsSync(path)) return []
285
+ return parseLines(readFileSync(path, 'utf8').split('\n').filter(Boolean)).messages.slice(-limit)
286
+ }
package/src/trust.mjs ADDED
@@ -0,0 +1,323 @@
1
+ // trust — records the folder-trust answer each agent CLI asks for on its first
2
+ // run in a directory, before Baton spawns that agent.
3
+ //
4
+ // Why this exists: the handoff is the product. When claude hits its 5-hour
5
+ // limit at 3am, Baton writes the bundle and starts codex in the same terminal
6
+ // with nobody there. If the incoming agent has never run in that folder it
7
+ // stops on a full-screen "Is this a project you trust?" prompt and waits for a
8
+ // keypress that is not coming, and the handoff the user paid for silently
9
+ // becomes a stalled terminal they find in the morning.
10
+ //
11
+ // Each CLI already stores that answer in a file, so the fix is to write the
12
+ // same answer the user would have clicked, for the repository they already
13
+ // chose by typing `baton claude` in it:
14
+ //
15
+ // claude ~/.claude.json projects["<repo>"].hasTrustDialogAccepted
16
+ // (or $CLAUDE_CONFIG_DIR/.claude.json)
17
+ // codex ~/.codex/config.toml [projects."<repo>"] trust_level = "trusted"
18
+ // agy ~/.gemini/trustedFolders.json "<repo>": "TRUST_FOLDER"
19
+ //
20
+ // For claude this is the documented remedy: its own permissions docs say to
21
+ // "set projects[<path>].hasTrustDialogAccepted to true in ~/.claude.json, where
22
+ // <path> is the repository root", and its error text prints the same sentence.
23
+ //
24
+ // Three rules this module holds to, because it writes files Baton does not own:
25
+ // 1. Never create a config file that is not already there. A missing file
26
+ // means that CLI has never run here, so its own first-run flow (login,
27
+ // onboarding) is about to happen with the user present anyway. Skip.
28
+ // 2. Never rewrite a file to say what it already says. No write, no risk.
29
+ // 3. Never lose what is already in the file. Read, add, write atomically,
30
+ // under the same cross-process lock the rest of Baton uses.
31
+ //
32
+ // BATON_TRUST=never turns all of it off; the prompts come back.
33
+ import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
34
+ import { homedir } from 'node:os'
35
+ import { join, resolve, dirname, parse, isAbsolute } from 'node:path'
36
+ import { realPath, withFileLock, writeJsonAtomic } from './fsx.mjs'
37
+
38
+ // The repository root is the unit of trust: claude walks up from the working
39
+ // directory to the repo root looking for the flag, so one record covers every
40
+ // worktree Baton creates under <repo>/.baton-worktrees/ as well.
41
+ export function repoRootOf(dir) {
42
+ let at = realPath(dir)
43
+ const stop = parse(at).root
44
+ while (true) {
45
+ if (existsSync(join(at, '.git'))) return at
46
+ const up = dirname(at)
47
+ if (up === at || at === stop) return realPath(dir)
48
+ at = up
49
+ }
50
+ }
51
+
52
+ export function trustPolicy(env = process.env) {
53
+ const raw = String(env.LEG_TRUST ?? env.BATON_TRUST ?? 'auto').trim().toLowerCase()
54
+ return raw === 'never' || raw === 'off' || raw === '0' ? 'never' : 'auto'
55
+ }
56
+
57
+ // ---- claude ----
58
+
59
+ // A path like `C:\cfg` is absolute on Windows and a plain relative filename
60
+ // everywhere else, so `resolve()` on a POSIX host prepends the process's own
61
+ // cwd and invents a directory that was never named. These functions describe a
62
+ // Windows agent's config, and they have to say the same thing whatever host
63
+ // they are asked on, so a drive-letter path is taken as already absolute.
64
+ const DRIVE_ABS = /^[A-Za-z]:[\\/]/
65
+
66
+ function absolutely(p) { return DRIVE_ABS.test(p) ? p : resolve(p) }
67
+
68
+ export function claudeConfigFile(env = process.env) {
69
+ const dir = env.CLAUDE_CONFIG_DIR ? absolutely(env.CLAUDE_CONFIG_DIR) : homedir()
70
+ return join(dir, '.claude.json')
71
+ }
72
+
73
+ // Claude Code stores a project under a normalized spelling of its path, and on
74
+ // Windows that spelling is forward slashes with an upper-case drive letter and
75
+ // no trailing separator: 100 of the 104 entries in a real ~/.claude.json are in
76
+ // that form, and the four that are not are older duplicates of projects that
77
+ // also appear in the new form. Write the wrong spelling and the entry is simply
78
+ // ignored: the flag is on file, the prompt still appears, and nothing says why.
79
+ export function claudeProjectKey(repo) {
80
+ // realPath() resolves symlinks and 8.3 names against the host filesystem,
81
+ // which is the right thing for a path that lives on it and meaningless for a
82
+ // drive-letter path on a POSIX host: there it silently becomes cwd + the
83
+ // literal. A drive-letter path is already the canonical spelling, so it is
84
+ // normalized directly.
85
+ const raw = String(repo)
86
+ let p = (DRIVE_ABS.test(raw) ? raw : realPath(raw)).replace(/\\/g, '/')
87
+ if (/^[a-z]:/.test(p)) p = p[0].toUpperCase() + p.slice(1)
88
+ return p.length > 3 ? p.replace(/\/+$/, '') : p
89
+ }
90
+
91
+ // The canonical key, plus the spellings Claude Code used in older versions.
92
+ // Older spellings are updated when they are already in the file and never
93
+ // created, so Baton corrects a stale entry without littering the config.
94
+ export function projectKeys(repo, existing = {}) {
95
+ const key = claudeProjectKey(repo)
96
+ const out = [key]
97
+ for (const v of [key.normalize('NFC'), realPath(repo), resolve(repo)]) {
98
+ if (!out.includes(v) && Object.prototype.hasOwnProperty.call(existing, v)) out.push(v)
99
+ }
100
+ // The older spelling is the same path written with Windows separators, so it
101
+ // is recognised by its spelling rather than by asking the host to resolve it:
102
+ // matching on the exact string only worked on a host where a backslash is a
103
+ // separator, and the entry Baton was meant to correct was left stale
104
+ // anywhere else. Nothing is created here — an entry is only ever brought up
105
+ // to date when it is already in the file.
106
+ for (const k of Object.keys(existing)) {
107
+ if (!out.includes(k) && k.includes('\\') && claudeProjectKey(k.replace(/\\/g, '/')) === key) out.push(k)
108
+ }
109
+ return out
110
+ }
111
+
112
+ // A project-level CLAUDE.md that imports a file outside the working directory
113
+ // is what triggers the second prompt ("Allow external CLAUDE.md file imports?").
114
+ // Claude Code reads a CLAUDE.md from every ancestor directory of the working
115
+ // directory, so an import in C:\Projects\CLAUDE.md is external to a session in
116
+ // C:\Projects\some-repo. Returns the resolved paths, so Baton can print exactly
117
+ // what it approved instead of approving something invisible.
118
+ export function externalImports(cwd) {
119
+ const start = realPath(cwd)
120
+ const stop = parse(start).root
121
+ const dirs = []
122
+ for (let at = start; ; at = dirname(at)) {
123
+ dirs.push(at)
124
+ if (at === stop || dirname(at) === at) break
125
+ }
126
+ const home = realPath(homedir())
127
+ const out = []
128
+ for (const dir of dirs) {
129
+ // ~/.claude/CLAUDE.md is user memory, not project memory: its imports never
130
+ // raise the dialog, so they are not ours to approve either.
131
+ if (dir === home) continue
132
+ const file = join(dir, 'CLAUDE.md')
133
+ if (!existsSync(file)) continue
134
+ let text
135
+ try { text = readFileSync(file, 'utf8') } catch { continue }
136
+ for (const line of text.split('\n')) {
137
+ const m = line.match(/^\s*@(\S+)\s*$/)
138
+ if (!m) continue
139
+ const target = isAbsolute(m[1]) ? resolve(m[1]) : resolve(dir, m[1])
140
+ if (inside(target, start) || out.includes(target)) continue
141
+ out.push(target)
142
+ }
143
+ }
144
+ return out
145
+ }
146
+
147
+ function inside(target, root) {
148
+ const a = process.platform === 'win32' ? target.toLowerCase() : target
149
+ const b = process.platform === 'win32' ? root.toLowerCase() : root
150
+ return a === b || a.startsWith(b.endsWith('\\') || b.endsWith('/') ? b : b + (b.includes('\\') ? '\\' : '/'))
151
+ }
152
+
153
+ function readJson(file) {
154
+ try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null }
155
+ }
156
+
157
+ export function ensureClaudeTrust(repo, { env = process.env, cwd = repo } = {}) {
158
+ const file = claudeConfigFile(env)
159
+ const root = repoRootOf(repo)
160
+ // Rule 1: a config that is not there means claude has never run as this
161
+ // user. Its own onboarding is next, with a person at the keyboard.
162
+ if (!existsSync(file)) return { agent: 'claude', file, wrote: [], imports: [], skipped: 'no claude config yet' }
163
+ const imports = externalImports(cwd)
164
+ let wrote = []
165
+ withFileLock(`${file}.baton-lock`, () => {
166
+ const cfg = readJson(file)
167
+ // Rule 3: an unreadable or non-object config is not ours to repair.
168
+ if (!cfg || typeof cfg !== 'object') { wrote = null; return }
169
+ const projects = cfg.projects && typeof cfg.projects === 'object' ? cfg.projects : {}
170
+ const keys = projectKeys(root, projects)
171
+ // Rule 4, and the one that matters most: an answer already on file is the
172
+ // user's, including "no". Only an ABSENT key is an unanswered question.
173
+ // Treating "not true" as "not asked yet" would flip a deliberate refusal
174
+ // to trust a folder into trust, silently, on every run in that repo.
175
+ const said = (o, k) => Object.prototype.hasOwnProperty.call(o, k)
176
+ if (keys.some((k) => projects[k] && said(projects[k], 'hasTrustDialogAccepted') && projects[k].hasTrustDialogAccepted !== true)) {
177
+ wrote = 'declined'
178
+ return
179
+ }
180
+ const changed = []
181
+ for (const key of keys) {
182
+ const was = projects[key] && typeof projects[key] === 'object' ? projects[key] : {}
183
+ const next = { ...was }
184
+ const here = []
185
+ if (!said(was, 'hasTrustDialogAccepted')) { next.hasTrustDialogAccepted = true; here.push('hasTrustDialogAccepted') }
186
+ // The imports question is answered separately, and declining it is also
187
+ // an answer: WarningShown true with Approved false is a recorded "no".
188
+ if (imports.length && !said(was, 'hasClaudeMdExternalIncludesWarningShown') && !said(was, 'hasClaudeMdExternalIncludesApproved')) {
189
+ next.hasClaudeMdExternalIncludesApproved = true
190
+ next.hasClaudeMdExternalIncludesWarningShown = true
191
+ here.push('hasClaudeMdExternalIncludesApproved', 'hasClaudeMdExternalIncludesWarningShown')
192
+ }
193
+ if (!here.length) continue
194
+ projects[key] = next
195
+ changed.push(...here)
196
+ }
197
+ // Rule 2: nothing to say that the file does not already say.
198
+ if (!changed.length) return
199
+ writeJsonAtomic(file, { ...cfg, projects })
200
+ wrote = [...new Set(changed)]
201
+ })
202
+ if (wrote === null) return { agent: 'claude', file, wrote: [], imports, skipped: 'claude config is not readable json' }
203
+ if (wrote === 'declined') return { agent: 'claude', file, root, wrote: [], imports, skipped: 'you answered no for this folder; Baton leaves that answer alone' }
204
+ return { agent: 'claude', file, root, wrote, imports, skipped: null }
205
+ }
206
+
207
+ // ---- codex ----
208
+
209
+ export function codexConfigFile(env = process.env) {
210
+ const dir = env.CODEX_HOME ? resolve(env.CODEX_HOME) : join(homedir(), '.codex')
211
+ return join(dir, 'config.toml')
212
+ }
213
+
214
+ // TOML basic strings escape a backslash, which is every separator on Windows.
215
+ const tomlPath = (p) => p.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
216
+
217
+ export function ensureCodexTrust(repo, { env = process.env } = {}) {
218
+ const file = codexConfigFile(env)
219
+ const root = repoRootOf(repo)
220
+ if (!existsSync(file)) return { agent: 'codex', file, wrote: [], imports: [], skipped: 'no codex config yet' }
221
+ let wrote = []
222
+ withFileLock(`${file}.baton-lock`, () => {
223
+ let text
224
+ try { text = readFileSync(file, 'utf8') } catch { wrote = null; return }
225
+ if (codexTrustedPaths(text).some((p) => samePath(p, root))) return
226
+ const block = `\n[projects."${tomlPath(root)}"]\ntrust_level = "trusted"\n`
227
+ // Append only: a TOML table added at the end cannot change the meaning of
228
+ // a table above it, and Baton never reformats a file it did not write.
229
+ writeTextAtomic(file, text.endsWith('\n') ? text + block : text + '\n' + block)
230
+ wrote = ['trust_level']
231
+ })
232
+ if (wrote === null) return { agent: 'codex', file, wrote: [], imports: [], skipped: 'codex config is not readable' }
233
+ return { agent: 'codex', file, root, wrote, imports: [], skipped: null }
234
+ }
235
+
236
+ // Every folder config.toml already names. Codex writes the same folder two
237
+ // ways, plain and in the \\?\ extended-length form, so both unescape to the
238
+ // path we are about to compare against.
239
+ export function codexTrustedPaths(text) {
240
+ const out = []
241
+ for (const m of text.matchAll(/^\s*\[projects\."((?:[^"\\]|\\.)*)"\]/gm)) {
242
+ const raw = m[1].replace(/\\(.)/g, '$1')
243
+ out.push(raw.replace(/^\\\\\?\\/, ''))
244
+ }
245
+ return out
246
+ }
247
+
248
+ function samePath(a, b) {
249
+ return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b
250
+ }
251
+
252
+ function writeTextAtomic(file, text) {
253
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
254
+ writeFileSync(tmp, text)
255
+ for (let i = 0; i < 20; i++) {
256
+ try { renameSync(tmp, file); return } catch (err) {
257
+ // Windows holds a brief lock on a file another process has open; the
258
+ // same retry the rest of Baton uses rather than lose the config.
259
+ if (!['EPERM', 'EBUSY', 'EACCES', 'EEXIST'].includes(err.code)) { try { unlinkSync(tmp) } catch {} throw err }
260
+ const until = Date.now() + 25
261
+ while (Date.now() < until) { /* spin */ }
262
+ }
263
+ }
264
+ try { unlinkSync(tmp) } catch {}
265
+ writeFileSync(file, text)
266
+ }
267
+
268
+ // ---- agy ----
269
+
270
+ export function agyTrustFile(env = process.env) {
271
+ const dir = env.GEMINI_CONFIG_DIR ? resolve(env.GEMINI_CONFIG_DIR) : join(homedir(), '.gemini')
272
+ return join(dir, 'trustedFolders.json')
273
+ }
274
+
275
+ export function ensureAgyTrust(repo, { env = process.env } = {}) {
276
+ const file = agyTrustFile(env)
277
+ const root = repoRootOf(repo)
278
+ // agy keeps trust in its own small file rather than a big config, so an
279
+ // absent file next to an existing ~/.gemini is just an empty answer sheet.
280
+ if (!existsSync(dirname(file))) return { agent: 'agy', file, wrote: [], imports: [], skipped: 'no agy config yet' }
281
+ let wrote = []
282
+ withFileLock(`${file}.baton-lock`, () => {
283
+ const map = existsSync(file) ? readJson(file) : {}
284
+ if (map === null || typeof map !== 'object') { wrote = null; return }
285
+ // Rule 4: a folder already in this file has been answered, whatever the
286
+ // answer was. Only an absent key is an unanswered question; anything else
287
+ // (DO_NOT_TRUST included) is the user's decision and stays.
288
+ if (Object.prototype.hasOwnProperty.call(map, root)) { if (map[root] !== 'TRUST_FOLDER') wrote = 'declined'; return }
289
+ writeJsonAtomic(file, { ...map, [root]: 'TRUST_FOLDER' })
290
+ wrote = ['TRUST_FOLDER']
291
+ })
292
+ if (wrote === null) return { agent: 'agy', file, wrote: [], imports: [], skipped: 'agy trust file is not readable json' }
293
+ if (wrote === 'declined') return { agent: 'agy', file, root, wrote: [], imports: [], skipped: 'you answered no for this folder; Baton leaves that answer alone' }
294
+ return { agent: 'agy', file, root, wrote, imports: [], skipped: null }
295
+ }
296
+
297
+ // ---- the one entry point ----
298
+
299
+ const ENSURE = { claude: ensureClaudeTrust, codex: ensureCodexTrust, agy: ensureAgyTrust }
300
+
301
+ // Returns a result even when nothing was written, so the caller can say why.
302
+ export function ensureTrust(agent, repo, { env = process.env, cwd = repo } = {}) {
303
+ if (trustPolicy(env) === 'never') return { agent, wrote: [], imports: [], skipped: 'BATON_TRUST=never' }
304
+ const fn = ENSURE[agent]
305
+ if (!fn) return { agent, wrote: [], imports: [], skipped: `no trust record for ${agent}` }
306
+ try {
307
+ return fn(repo, { env, cwd })
308
+ } catch (err) {
309
+ // A trust record Baton could not write is a prompt the user will answer
310
+ // themselves. It is never a reason to fail the session.
311
+ return { agent, wrote: [], imports: [], skipped: `could not write the trust record: ${err.message}` }
312
+ }
313
+ }
314
+
315
+ // The line Baton prints. Null when there is nothing worth saying.
316
+ export function trustLine(result) {
317
+ if (!result || !result.wrote?.length) return null
318
+ const what = result.agent === 'claude' && result.imports?.length
319
+ ? `trusted ${result.root} for claude, and allowed ${result.imports.length} external CLAUDE.md import${result.imports.length === 1 ? '' : 's'}: ${result.imports.join(', ')}`
320
+ : `trusted ${result.root} for ${result.agent}`
321
+ return `${what} (recorded in ${result.file}; BATON_TRUST=never turns this off)`
322
+ }
323
+