@ucsandman/legcli 0.8.0 → 0.9.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 (110) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/NOTICE +8 -0
  3. package/README.md +601 -560
  4. package/bin/fake-agent.mjs +4 -4
  5. package/bin/leg.mjs +21 -12
  6. package/docs/DECISIONS.md +20 -2
  7. package/docs/ERRORS.md +71 -0
  8. package/docs/README.md +2 -0
  9. package/docs/REUSE.md +1 -1
  10. package/docs/VOCABULARY.md +21 -0
  11. package/docs/board-guide.md +13 -0
  12. package/docs/cli-contracts.md +22 -1
  13. package/docs/concepts.md +42 -3
  14. package/docs/configuration.md +22 -1
  15. package/docs/faq.md +19 -0
  16. package/docs/getting-started.md +272 -251
  17. package/docs/harness.md +319 -0
  18. package/fixtures/verified.json +1 -1
  19. package/package.json +7 -3
  20. package/scripts/build-docs-site.mjs +11 -4
  21. package/scripts/check-branding.mjs +118 -0
  22. package/scripts/check-claims.mjs +1 -1
  23. package/scripts/license-sign.mjs +1 -1
  24. package/scripts/limits-table.mjs +1 -1
  25. package/scripts/live-limits.mjs +1 -1
  26. package/scripts/npm-publish-gate.mjs +114 -0
  27. package/scripts/probe.mjs +4 -3
  28. package/scripts/seed-fake-cards.mjs +4 -3
  29. package/scripts/seed-floor-board.mjs +5 -4
  30. package/scripts/seed-wes-board.mjs +5 -4
  31. package/scripts/stripe-setup.mjs +1 -1
  32. package/scripts/sync-harness-engine.mjs +159 -0
  33. package/scripts/sync-leg-agents.mjs +127 -0
  34. package/src/accounts.mjs +1 -2
  35. package/src/adapters/codex.mjs +1 -1
  36. package/src/attach.mjs +75 -19
  37. package/src/auth.mjs +2 -2
  38. package/src/board/board.js +3 -3
  39. package/src/board/sessions.js +77 -3
  40. package/src/bundle.mjs +54 -8
  41. package/src/chain.mjs +1 -1
  42. package/src/contract.mjs +4 -3
  43. package/src/fsx.mjs +5 -2
  44. package/src/handoff.mjs +6 -6
  45. package/src/harness/cli.mjs +281 -0
  46. package/src/harness/fingerprint.mjs +68 -0
  47. package/src/harness/index.mjs +407 -0
  48. package/src/harness/registry.mjs +124 -0
  49. package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
  50. package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
  51. package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
  52. package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
  53. package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
  54. package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
  55. package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
  56. package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
  57. package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
  58. package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
  59. package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
  60. package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
  61. package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
  62. package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
  63. package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
  64. package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
  65. package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
  66. package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
  67. package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
  68. package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
  69. package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
  70. package/src/hook.mjs +49 -49
  71. package/src/land.mjs +7 -35
  72. package/src/launcher.mjs +38 -26
  73. package/src/ledger.mjs +6 -6
  74. package/src/license.mjs +10 -9
  75. package/src/live-capture.mjs +1 -1
  76. package/src/mergequeue.mjs +5 -5
  77. package/src/orchestrator.mjs +28 -4
  78. package/src/preferences.mjs +37 -3
  79. package/src/redact.mjs +1 -1
  80. package/src/resume.mjs +17 -15
  81. package/src/runner.mjs +2 -2
  82. package/src/scheduler.mjs +1 -1
  83. package/src/server.mjs +38 -10
  84. package/src/session-detail.mjs +15 -1
  85. package/src/sessions.mjs +6 -3
  86. package/src/share.mjs +2 -2
  87. package/src/stations/agent.mjs +1 -1
  88. package/src/sync/dashclaw.mjs +4 -4
  89. package/src/synthesis.mjs +165 -0
  90. package/src/taps/agy.mjs +2 -2
  91. package/src/taps/claude-usage.mjs +1 -1
  92. package/src/taps/claude.mjs +170 -170
  93. package/src/taps/codex.mjs +286 -286
  94. package/src/taps/grok.mjs +2 -2
  95. package/src/trust.mjs +205 -36
  96. package/src/usage.mjs +5 -1
  97. package/src/worktree.mjs +5 -4
  98. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
  99. package/fixtures/live/agy/err.log +0 -0
  100. package/fixtures/live/agy/out.log +0 -1
  101. package/fixtures/live/agy/supervisor.log +0 -2
  102. package/fixtures/live/claude/err.log +0 -0
  103. package/fixtures/live/claude/out.log +0 -1
  104. package/fixtures/live/claude/supervisor.log +0 -2
  105. package/fixtures/live/codex/err.log +0 -1
  106. package/fixtures/live/codex/out.log +0 -8
  107. package/fixtures/live/codex/supervisor.log +0 -2
  108. package/fixtures/live/grok/err.log +0 -32
  109. package/fixtures/live/grok/out.log +0 -7
  110. package/fixtures/live/grok/supervisor.log +0 -2
@@ -1,286 +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
- }
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). Leg tails that file; no hook is injected, so codex never shows
4
+ // its "review new hooks" prompt for a Leg 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/taps/grok.mjs CHANGED
@@ -1,5 +1,5 @@
1
- // grok tap - how Baton supervises the xAI grok CLI (xai-org/grok-build).
2
- // Usage percentages tap: Grok CLI exposes no usage command. Baton reads the
1
+ // grok tap - how Leg supervises the xAI grok CLI (xai-org/grok-build).
2
+ // Usage percentages tap: Grok CLI exposes no usage command. Leg reads the
3
3
  // login token from ~/.grok/auth.json and polls:
4
4
  // GET https://cli-chat-proxy.grok.com/v1/billing?format=credits
5
5
  // GET https://cli-chat-proxy.grok.com/v1/user?include=subscription