@ucsandman/legcli 0.8.0 → 0.10.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.
- package/CHANGELOG.md +121 -0
- package/NOTICE +8 -0
- package/README.md +639 -560
- package/bin/fake-agent.mjs +4 -4
- package/bin/leg.mjs +43 -12
- package/docs/DECISIONS.md +20 -2
- package/docs/ERRORS.md +205 -0
- package/docs/README.md +5 -1
- package/docs/REUSE.md +1 -1
- package/docs/VOCABULARY.md +22 -0
- package/docs/board-guide.md +33 -1
- package/docs/cli-contracts.md +36 -1
- package/docs/concepts.md +42 -3
- package/docs/configuration.md +23 -1
- package/docs/faq.md +19 -0
- package/docs/getting-started.md +272 -251
- package/docs/harness.md +319 -0
- package/docs/history.md +172 -0
- package/docs/runtime-tap.md +156 -0
- package/fixtures/verified.json +1 -1
- package/package.json +7 -3
- package/scripts/build-docs-site.mjs +18 -4
- package/scripts/check-branding.mjs +118 -0
- package/scripts/check-claims.mjs +1 -1
- package/scripts/license-sign.mjs +1 -1
- package/scripts/limits-table.mjs +1 -1
- package/scripts/live-limits.mjs +1 -1
- package/scripts/npm-publish-gate.mjs +114 -0
- package/scripts/probe.mjs +4 -3
- package/scripts/seed-fake-cards.mjs +4 -3
- package/scripts/seed-floor-board.mjs +5 -4
- package/scripts/seed-wes-board.mjs +5 -4
- package/scripts/stripe-setup.mjs +1 -1
- package/scripts/sync-harness-engine.mjs +159 -0
- package/scripts/sync-leg-agents.mjs +127 -0
- package/src/accounts.mjs +6 -4
- package/src/adapters/codex.mjs +1 -1
- package/src/attach.mjs +125 -23
- package/src/auth.mjs +2 -2
- package/src/board/board.css +23 -1
- package/src/board/board.js +17 -5
- package/src/board/history.js +377 -0
- package/src/board/index.html +33 -0
- package/src/board/sessions.js +95 -7
- package/src/bundle.mjs +54 -8
- package/src/chain.mjs +1 -1
- package/src/contract.mjs +4 -3
- package/src/fsx.mjs +5 -2
- package/src/handoff.mjs +6 -6
- package/src/harness/cli.mjs +281 -0
- package/src/harness/fingerprint.mjs +68 -0
- package/src/harness/index.mjs +407 -0
- package/src/harness/registry.mjs +124 -0
- package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
- package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
- package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
- package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
- package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
- package/src/history/cli.mjs +159 -0
- package/src/history/common.mjs +119 -0
- package/src/history/index.mjs +429 -0
- package/src/history/providers/agy.mjs +91 -0
- package/src/history/providers/claude.mjs +161 -0
- package/src/history/providers/codex.mjs +133 -0
- package/src/history/providers/copilot.mjs +94 -0
- package/src/history/providers/grok.mjs +138 -0
- package/src/history/worktrees.mjs +116 -0
- package/src/hook.mjs +49 -49
- package/src/land.mjs +7 -35
- package/src/launcher.mjs +38 -26
- package/src/ledger.mjs +6 -6
- package/src/license.mjs +10 -9
- package/src/live-capture.mjs +1 -1
- package/src/mergequeue.mjs +5 -5
- package/src/orchestrator.mjs +28 -4
- package/src/preferences.mjs +37 -3
- package/src/redact.mjs +24 -6
- package/src/resume.mjs +17 -15
- package/src/runner.mjs +2 -2
- package/src/scheduler.mjs +1 -1
- package/src/server.mjs +224 -18
- package/src/session-detail.mjs +15 -1
- package/src/sessions.mjs +15 -3
- package/src/share.mjs +2 -2
- package/src/stations/agent.mjs +1 -1
- package/src/sync/dashclaw.mjs +4 -4
- package/src/synthesis.mjs +165 -0
- package/src/taps/agy.mjs +2 -2
- package/src/taps/claude-usage.mjs +1 -1
- package/src/taps/claude.mjs +177 -170
- package/src/taps/codex.mjs +286 -286
- package/src/taps/grok.mjs +2 -2
- package/src/taps/mod.mjs +340 -0
- package/src/trust.mjs +205 -36
- package/src/usage.mjs +5 -1
- package/src/worktree.mjs +6 -5
- package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
- package/fixtures/live/agy/err.log +0 -0
- package/fixtures/live/agy/out.log +0 -1
- package/fixtures/live/agy/supervisor.log +0 -2
- package/fixtures/live/claude/err.log +0 -0
- package/fixtures/live/claude/out.log +0 -1
- package/fixtures/live/claude/supervisor.log +0 -2
- package/fixtures/live/codex/err.log +0 -1
- package/fixtures/live/codex/out.log +0 -8
- package/fixtures/live/codex/supervisor.log +0 -2
- package/fixtures/live/grok/err.log +0 -32
- package/fixtures/live/grok/out.log +0 -7
- package/fixtures/live/grok/supervisor.log +0 -2
package/src/taps/codex.mjs
CHANGED
|
@@ -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).
|
|
4
|
-
// its "review new hooks" prompt for a
|
|
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
|
|
2
|
-
// Usage percentages tap: Grok CLI exposes no usage command.
|
|
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
|