@ucsandman/legcli 0.7.0 → 0.8.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 +12 -0
- package/README.md +16 -14
- package/bin/leg.mjs +47 -26
- package/docs/adapters.md +17 -3
- package/docs/cli-contracts.md +36 -3
- package/docs/configuration.md +20 -1
- package/docs/getting-started.md +3 -3
- package/fixtures/limits/grok/grok-rate-limit.json +11 -0
- package/fixtures/live/agy/limit-agy-resource-exhausted.json +11 -0
- package/package.json +3 -3
- package/scripts/build-docs-site.mjs +4 -3
- package/src/accounts.mjs +10 -1
- package/src/adapters/grok.mjs +4 -7
- package/src/attach.mjs +87 -18
- package/src/board/board.css +45 -17
- package/src/board/board.js +1 -1
- package/src/board/floor.js +2 -2
- package/src/board/sessions.js +104 -35
- package/src/land.mjs +688 -47
- package/src/launcher.mjs +2 -1
- package/src/mergequeue.mjs +1 -1
- package/src/preferences.mjs +28 -8
- package/src/runner.mjs +1 -1
- package/src/server.mjs +32 -11
- package/src/sessions.mjs +3 -2
- package/src/taps/grok.mjs +251 -0
package/src/launcher.mjs
CHANGED
|
@@ -46,6 +46,7 @@ const INSTALL_HINT = {
|
|
|
46
46
|
claude: 'https://claude.com/claude-code (then `claude` to log in)',
|
|
47
47
|
codex: 'npm i -g @openai/codex (then `codex login`)',
|
|
48
48
|
agy: 'Antigravity CLI (`agy`), log in once interactively',
|
|
49
|
+
grok: 'xAI Grok CLI (`grok`), log in once via `grok login`',
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
// Rows: [name, status, detail]. Nothing here is fatal except "no adapter at all".
|
|
@@ -62,7 +63,7 @@ export async function preflight() {
|
|
|
62
63
|
const v = viaNode ? version(process.execPath, [entry, '--version']) : version(bin)
|
|
63
64
|
if (v) { present += 1; rows.push([name, 'ok', `${v} (${viaNode ? entry : bin})`]) } else rows.push([name, 'missing', INSTALL_HINT[name] ?? 'not on PATH'])
|
|
64
65
|
}
|
|
65
|
-
rows.push(['fake adapters', 'ok', 'fake, fake-claude, fake-codex, fake-agy (tests and demo)'])
|
|
66
|
+
rows.push(['fake adapters', 'ok', 'fake, fake-claude, fake-codex, fake-agy, fake-grok (tests and demo)'])
|
|
66
67
|
return { rows, adapters_present: present }
|
|
67
68
|
}
|
|
68
69
|
|
package/src/mergequeue.mjs
CHANGED
|
@@ -82,7 +82,7 @@ export function resolveTestCommand(card, worktree) {
|
|
|
82
82
|
return { command: null, source: 'none' }
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
function runTests(command, worktree) {
|
|
85
|
+
export function runTests(command, worktree) {
|
|
86
86
|
return runCommandAsync(command, worktree, { timeoutMs: TEST_TIMEOUT_MS, tailLines: 40 })
|
|
87
87
|
}
|
|
88
88
|
|
package/src/preferences.mjs
CHANGED
|
@@ -7,11 +7,15 @@ import { home } from './store.mjs'
|
|
|
7
7
|
import { writeJsonAtomic, withFileLock } from './fsx.mjs'
|
|
8
8
|
|
|
9
9
|
export const HANDOFF_AGENTS = ['claude', 'codex', 'agy']
|
|
10
|
+
export const ALL_HANDOFF_AGENTS = ['claude', 'codex', 'agy', 'grok']
|
|
10
11
|
|
|
11
12
|
export function validHandoffOrder(value) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
if (!Array.isArray(value)) return false
|
|
14
|
+
const set = new Set(value)
|
|
15
|
+
if (set.size !== value.length) return false
|
|
16
|
+
if (value.length === 3 && ['claude', 'codex', 'agy'].every((a) => set.has(a))) return true
|
|
17
|
+
if (value.length === 4 && ['claude', 'codex', 'agy', 'grok'].every((a) => set.has(a))) return true
|
|
18
|
+
return false
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
export function normalizeHandoffOrder(value) {
|
|
@@ -25,22 +29,38 @@ export function requireHandoffOrder(value) {
|
|
|
25
29
|
|
|
26
30
|
export function preferencesFile() { return join(home(), 'preferences.json') }
|
|
27
31
|
|
|
32
|
+
export function resolveAutoApprove({ env = process.env, preferences = null, cliFlag = null } = {}) {
|
|
33
|
+
if (cliFlag !== null && cliFlag !== undefined) return Boolean(cliFlag)
|
|
34
|
+
const envVal = env.LEG_AUTO_APPROVE ?? env.BATON_AUTO_APPROVE
|
|
35
|
+
if (envVal !== undefined) return envVal !== '0' && envVal !== 'false' && envVal !== 'off'
|
|
36
|
+
if ((env.LEG_NO_AUTO_APPROVE ?? env.BATON_NO_AUTO_APPROVE) === '1') return false
|
|
37
|
+
const prefs = preferences ?? readPreferences()
|
|
38
|
+
if (typeof prefs?.auto_approve === 'boolean') return prefs.auto_approve
|
|
39
|
+
return true
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
export function readPreferences() {
|
|
29
43
|
const file = preferencesFile()
|
|
30
|
-
if (!existsSync(file)) return { handoff_order: [...HANDOFF_AGENTS] }
|
|
44
|
+
if (!existsSync(file)) return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true }
|
|
31
45
|
try {
|
|
32
46
|
const value = JSON.parse(readFileSync(file, 'utf8'))
|
|
33
|
-
return {
|
|
47
|
+
return {
|
|
48
|
+
handoff_order: normalizeHandoffOrder(value?.handoff_order),
|
|
49
|
+
auto_approve: value?.auto_approve !== false,
|
|
50
|
+
}
|
|
34
51
|
} catch {
|
|
35
|
-
return { handoff_order: [...HANDOFF_AGENTS] }
|
|
52
|
+
return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true }
|
|
36
53
|
}
|
|
37
54
|
}
|
|
38
55
|
|
|
39
56
|
export function writePreferences(patch) {
|
|
40
|
-
const order = requireHandoffOrder(patch?.handoff_order)
|
|
57
|
+
const order = patch?.handoff_order !== undefined ? requireHandoffOrder(patch?.handoff_order) : undefined
|
|
41
58
|
mkdirSync(home(), { recursive: true })
|
|
42
59
|
return withFileLock(preferencesFile() + '.lock', () => {
|
|
43
|
-
const
|
|
60
|
+
const current = readPreferences()
|
|
61
|
+
const next = { ...current }
|
|
62
|
+
if (order !== undefined) next.handoff_order = order
|
|
63
|
+
if (patch?.auto_approve !== undefined) next.auto_approve = Boolean(patch.auto_approve)
|
|
44
64
|
writeJsonAtomic(preferencesFile(), next)
|
|
45
65
|
return next
|
|
46
66
|
})
|
package/src/runner.mjs
CHANGED
|
@@ -158,7 +158,7 @@ function gitDiff(cwd, headAtStart) {
|
|
|
158
158
|
|
|
159
159
|
// Fallback when the cwd is not a git repo (tests, ad-hoc dirs): a shallow
|
|
160
160
|
// mtime snapshot, so "wrote a file but no DONE" still reads as incomplete.
|
|
161
|
-
const SNAP_SKIP = new Set(['.git', 'node_modules', '.baton', '.baton-worktrees'])
|
|
161
|
+
const SNAP_SKIP = new Set(['.git', 'node_modules', '.baton', '.baton-worktrees', '.leg', '.leg-worktrees'])
|
|
162
162
|
function fsSnapshot(cwd, depth = 3) {
|
|
163
163
|
const out = new Map()
|
|
164
164
|
const walk = (dir, rel, d) => {
|
package/src/server.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import { resolveChb } from './handoff.mjs'
|
|
|
27
27
|
import { listSessions, readSession, readEvents as readSessionEvents, requestControl, removeSession, overlaps, isActive, sessionsRoot, reapLost, readLand, readLandings, readRequests, writeRequests, appendEvent as appendSessionEvent, updateSession, HANDOFF_ORDER_CAPABILITY } from './sessions.mjs'
|
|
28
28
|
import { sessionDetail, sessionDiff, DiffInputError } from './session-detail.mjs'
|
|
29
29
|
import { refreshPointers } from './resume.mjs'
|
|
30
|
-
import { landSession, landBlocker, landingNow, pruneSessionWorktree } from './land.mjs'
|
|
30
|
+
import { landSession, landBlocker, landingNow, pruneSessionWorktree, canLand, prepareLanding, applyLandFix } from './land.mjs'
|
|
31
31
|
import { readUsage, recordUsage, usageIsStale, candidates, isAvailable } from './usage.mjs'
|
|
32
32
|
import { readAccounts, envFor, LAYOUT } from './accounts.mjs'
|
|
33
33
|
import { readCodexUsage } from './taps/codex.mjs'
|
|
@@ -191,16 +191,17 @@ export async function detectTools({ refresh = false } = {}) {
|
|
|
191
191
|
return !r.error && r.status === 0
|
|
192
192
|
}
|
|
193
193
|
const agents = {}
|
|
194
|
-
for (const name of ['claude', 'codex', 'agy']) {
|
|
194
|
+
for (const name of ['claude', 'codex', 'agy', 'grok']) {
|
|
195
195
|
try {
|
|
196
|
-
const
|
|
196
|
+
const a = name === 'grok' ? (await import('./adapters/grok.mjs')).default : await getAdapter(name)
|
|
197
|
+
const { bin, viaNode, entry } = a.resolve()
|
|
197
198
|
const target = viaNode ? (entry ?? bin) : bin
|
|
198
199
|
agents[name] = /[\\/]/.test(target) ? existsSync(target) : probe(target)
|
|
199
200
|
} catch { agents[name] = false }
|
|
200
201
|
}
|
|
201
202
|
let chb = false
|
|
202
203
|
try { resolveChb(); chb = true } catch {}
|
|
203
|
-
toolsCache = { ...agents,
|
|
204
|
+
toolsCache = { ...agents, chb, git: probe('git') }
|
|
204
205
|
return toolsCache
|
|
205
206
|
}
|
|
206
207
|
|
|
@@ -279,6 +280,7 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
279
280
|
const preferredNext = chain[0] ?? null
|
|
280
281
|
const availabilityKnown = Boolean(s.installed)
|
|
281
282
|
const eligibleNext = availabilityKnown ? (chain.find((next) => s.installed[next.agent] !== false && isAvailable(readUsage(next.agent, next.account))) ?? null) : null
|
|
283
|
+
const can = s.worktree ? canLand(s) : { ok: false, blockers: [{ code: 'no_worktree', message: 'this terminal works in the checkout itself: there is no branch of its own to land', fix: null }] }
|
|
282
284
|
return {
|
|
283
285
|
...s,
|
|
284
286
|
handoff_order: handoffOrder,
|
|
@@ -296,7 +298,8 @@ export function sessionsView({ viewer = null, share = null } = {}) {
|
|
|
296
298
|
files: [...new Set([...(s.files_touched ?? []), ...(s.files_dirty ?? [])])].filter(visibleSessionFile),
|
|
297
299
|
// a 'landing' left behind by a board restart is no longer in flight
|
|
298
300
|
land: land?.state === 'landing' && !landingNow(s.session_id) ? { ...land, state: 'interrupted' } : land,
|
|
299
|
-
|
|
301
|
+
can_land: can,
|
|
302
|
+
land_blocker: s.worktree ? (can.ok ? null : can.blockers[0]?.message) : null,
|
|
300
303
|
}
|
|
301
304
|
})
|
|
302
305
|
const accounts = []
|
|
@@ -563,7 +566,7 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
563
566
|
const you = { ...viewer, share: { on: shared, people: shared ? share.people.length : 0 } }
|
|
564
567
|
if (guest) return send(res, 200, { ok: true, version: VERSION, you })
|
|
565
568
|
const cards = listCards()
|
|
566
|
-
return send(res, 200, { ok: true, version: VERSION, bind, port, home: home(), you, scheduler: { ...schedulerStatus(), in_process: Boolean(sched), max_concurrent: MAX_CONCURRENT }, tools: await detectTools(), columns: columnsFor(cards), cards: cards.length })
|
|
569
|
+
return send(res, 200, { ok: true, pid: process.pid, version: VERSION, bind, port, home: home(), you, scheduler: { ...schedulerStatus(), in_process: Boolean(sched), max_concurrent: MAX_CONCURRENT }, tools: await detectTools(), columns: columnsFor(cards), cards: cards.length })
|
|
567
570
|
}
|
|
568
571
|
if (req.method === 'GET' && path === '/api/adapters') return send(res, 200, { adapters: await adaptersInfo() })
|
|
569
572
|
if (req.method === 'GET' && path === '/api/presets') return send(res, 200, { presets: PRESETS })
|
|
@@ -687,9 +690,25 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
687
690
|
}
|
|
688
691
|
}
|
|
689
692
|
if (req.method === 'POST' && parts[3] === 'land') {
|
|
693
|
+
if (parts[4] === 'prepare') {
|
|
694
|
+
const cl = canLand(sess)
|
|
695
|
+
if (!cl.ok) return send(res, 409, { ok: false, error: cl.blockers[0].message, blockers: cl.blockers })
|
|
696
|
+
const prep = await prepareLanding(sess, { by: actor.id })
|
|
697
|
+
return send(res, prep.ok ? 200 : 409, prep)
|
|
698
|
+
}
|
|
699
|
+
if (parts[4] === 'fix') {
|
|
700
|
+
const body = await readBody(req)
|
|
701
|
+
try {
|
|
702
|
+
const r = await applyLandFix(sess.session_id, body?.action, { by: actor.id, message: body?.message })
|
|
703
|
+
try { sse.broadcast('sessions', (v) => viewFor(v)) } catch {}
|
|
704
|
+
return send(res, 200, r)
|
|
705
|
+
} catch (err) {
|
|
706
|
+
return send(res, 400, { ok: false, error: err.message })
|
|
707
|
+
}
|
|
708
|
+
}
|
|
690
709
|
const why = landBlocker(sess)
|
|
691
710
|
if (why) return send(res, 409, { error: why })
|
|
692
|
-
landSession(sess, { by: actor.id })
|
|
711
|
+
landSession(sess, { by: actor.id, autoCommit: true })
|
|
693
712
|
.catch((err) => log(`land ${id}: ${err.message}`))
|
|
694
713
|
.finally(() => { trunkCache.clear(); try { sse.broadcast('sessions', (v) => viewFor(v)) } catch {} })
|
|
695
714
|
log(`land requested for ${id} by ${actor.id}`)
|
|
@@ -797,10 +816,12 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
|
|
|
797
816
|
// A terminal that crashed instead of exiting left its hand-off in
|
|
798
817
|
// .baton/RESUME.md looking live. The board is the thing that starts
|
|
799
818
|
// after a crash, so it is where that gets corrected.
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
819
|
+
setImmediate(() => {
|
|
820
|
+
try {
|
|
821
|
+
const touched = refreshPointers()
|
|
822
|
+
if (touched.length) log(`rewrote ${touched.length} stale resume pointer${touched.length === 1 ? '' : 's'}: the terminal each described is gone, or no Baton stamped it`)
|
|
823
|
+
} catch (err) { log(`resume pointers not refreshed: ${err.message}`) }
|
|
824
|
+
})
|
|
804
825
|
if (scheduler) {
|
|
805
826
|
sched = createScheduler()
|
|
806
827
|
sched.run().catch((err) => log(`scheduler crashed: ${err.message}`))
|
package/src/sessions.mjs
CHANGED
|
@@ -11,9 +11,10 @@ import { randomBytes } from 'node:crypto'
|
|
|
11
11
|
import { home } from './store.mjs'
|
|
12
12
|
import { writeJsonAtomic, withFileLock } from './fsx.mjs'
|
|
13
13
|
import { scrub } from './redact.mjs'
|
|
14
|
-
import { HANDOFF_AGENTS, normalizeHandoffOrder } from './preferences.mjs'
|
|
14
|
+
import { HANDOFF_AGENTS, ALL_HANDOFF_AGENTS, normalizeHandoffOrder } from './preferences.mjs'
|
|
15
15
|
|
|
16
16
|
export const AGENTS = HANDOFF_AGENTS
|
|
17
|
+
export const SUPERVISED_AGENTS = ALL_HANDOFF_AGENTS
|
|
17
18
|
export const HANDOFF_ORDER_CAPABILITY = 'handoff_order_v1'
|
|
18
19
|
export const SESSION_STATUSES = ['starting', 'running', 'warning', 'limit', 'handing_off', 'waiting', 'handed_off', 'ended', 'lost']
|
|
19
20
|
const ACTIVE = ['starting', 'running', 'warning', 'limit', 'handing_off', 'waiting']
|
|
@@ -160,7 +161,7 @@ export function readLandings() {
|
|
|
160
161
|
return readFileSync(landingsFile(), 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
|
|
161
162
|
}
|
|
162
163
|
|
|
163
|
-
function pidAlive(pid) {
|
|
164
|
+
export function pidAlive(pid) {
|
|
164
165
|
if (!pid) return false
|
|
165
166
|
// EPERM means the process exists but is not ours to signal (e.g. an elevated
|
|
166
167
|
// terminal): it is alive. Only ESRCH ("no such process") means gone.
|
|
@@ -0,0 +1,251 @@
|
|
|
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
|
|
3
|
+
// login token from ~/.grok/auth.json and polls:
|
|
4
|
+
// GET https://cli-chat-proxy.grok.com/v1/billing?format=credits
|
|
5
|
+
// GET https://cli-chat-proxy.grok.com/v1/user?include=subscription
|
|
6
|
+
// Every 60s, same cadence as Claude Code tap.
|
|
7
|
+
// Wall tap: exact signals cited from xai-org/grok-build (Apache 2.0, Rust):
|
|
8
|
+
// - crates/codegen/xai-grok-sampling-types/src/error.rs:304:
|
|
9
|
+
// SamplingError::Api { status: StatusCode::TOO_MANY_REQUESTS, .. }
|
|
10
|
+
// - crates/codegen/xai-grok-shell/src/sampling/error.rs:15, 18-33, 132:
|
|
11
|
+
// RATE_LIMITED_ERROR_CODE = -32003
|
|
12
|
+
// RATE_LIMITED_USER_MESSAGE_OAUTH ("You've hit the rate limit for your plan. Try again later.")
|
|
13
|
+
// RATE_LIMITED_USER_MESSAGE_API_KEY ("You've hit the rate limit for your API key. Try again later.")
|
|
14
|
+
// FREE_USAGE_USER_MESSAGE ("You've used all of your free queries. Upgrade to a paid plan for more access.")
|
|
15
|
+
// FREE_USAGE_EXHAUSTED_ERROR_CODE ("subscription:free-usage-exhausted")
|
|
16
|
+
// - crates/codegen/xai-grok-hooks/src/event.rs:306-315:
|
|
17
|
+
// StopFailureKind::RateLimit serializes to "rate_limit"
|
|
18
|
+
// - crates/codegen/xai-grok-shell/src/session/acp_session_impl/turn_end.rs:118, 125:
|
|
19
|
+
// maps -32003 to StopFailureKind::RateLimit ("rate_limit") on StopFailure
|
|
20
|
+
// - crates/codegen/xai-grok-pager/src/app/error_display.rs:263-267:
|
|
21
|
+
// HTTP 429 maps to headline "Rate limited (429)" and "You've hit the rate limit for your plan. Try again later."
|
|
22
|
+
// - crates/codegen/xai-grok-pager/src/headless.rs:84, 1512:
|
|
23
|
+
// headless mode emits {"type":"error","message": ...} on -32003
|
|
24
|
+
// Native https and http, not fetch (test/lessons.test.mjs no-global-fetch).
|
|
25
|
+
import https from 'node:https'
|
|
26
|
+
import http from 'node:http'
|
|
27
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
28
|
+
import { join } from 'node:path'
|
|
29
|
+
import { homedir } from 'node:os'
|
|
30
|
+
import { LAYOUT } from '../accounts.mjs'
|
|
31
|
+
|
|
32
|
+
export const GROK_BILLING_URL = (process.env.LEG_GROK_BILLING_URL || process.env.BATON_GROK_BILLING_URL) || 'https://cli-chat-proxy.grok.com/v1/billing?format=credits'
|
|
33
|
+
export const GROK_USER_URL = (process.env.LEG_GROK_USER_URL || process.env.BATON_GROK_USER_URL) || 'https://cli-chat-proxy.grok.com/v1/user?include=subscription'
|
|
34
|
+
|
|
35
|
+
export function defaultGrokHome() {
|
|
36
|
+
return process.env.GROK_HOME || LAYOUT.grok?.home() || join(homedir(), '.grok')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Read login token from ~/.grok/auth.json. Re-read on each poll so refreshed tokens
|
|
40
|
+
// take effect. Handles token expiry gracefully.
|
|
41
|
+
export function readGrokToken(configDir = defaultGrokHome()) {
|
|
42
|
+
const f = join(configDir, 'auth.json')
|
|
43
|
+
if (!existsSync(f)) return null
|
|
44
|
+
try {
|
|
45
|
+
const raw = readFileSync(f, 'utf8')
|
|
46
|
+
const j = JSON.parse(raw)
|
|
47
|
+
let entry = null
|
|
48
|
+
if (typeof j === 'object' && j !== null) {
|
|
49
|
+
if (typeof j.key === 'string' && j.key) {
|
|
50
|
+
entry = j
|
|
51
|
+
} else if (typeof j.token === 'string' && j.token) {
|
|
52
|
+
entry = { key: j.token, expires_at: j.expires_at }
|
|
53
|
+
} else {
|
|
54
|
+
const scopeKey = Object.keys(j).find((k) => k.startsWith('https://auth.x.ai') && j[k]?.key)
|
|
55
|
+
if (scopeKey) {
|
|
56
|
+
entry = j[scopeKey]
|
|
57
|
+
} else {
|
|
58
|
+
for (const k of Object.keys(j)) {
|
|
59
|
+
if (j[k] && typeof j[k] === 'object' && typeof j[k].key === 'string' && j[k].key) {
|
|
60
|
+
entry = j[k]
|
|
61
|
+
break
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!entry?.key) return null
|
|
68
|
+
let expired = false
|
|
69
|
+
if (entry.expires_at) {
|
|
70
|
+
const expMs = Date.parse(entry.expires_at)
|
|
71
|
+
if (Number.isFinite(expMs) && Date.now() > expMs) expired = true
|
|
72
|
+
}
|
|
73
|
+
return { token: entry.key, expired, expires_at: entry.expires_at ?? null }
|
|
74
|
+
} catch {
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getJson(url, headers, timeoutMs) {
|
|
80
|
+
return new Promise((resolvePromise) => {
|
|
81
|
+
const u = new URL(url)
|
|
82
|
+
const mod = u.protocol === 'http:' ? http : https
|
|
83
|
+
const req = mod.request({
|
|
84
|
+
host: u.hostname,
|
|
85
|
+
port: u.port || (u.protocol === 'http:' ? 80 : 443),
|
|
86
|
+
path: u.pathname + u.search,
|
|
87
|
+
method: 'GET',
|
|
88
|
+
headers,
|
|
89
|
+
timeout: timeoutMs,
|
|
90
|
+
}, (res) => {
|
|
91
|
+
let d = ''
|
|
92
|
+
res.setEncoding('utf8')
|
|
93
|
+
res.on('data', (c) => { d += c })
|
|
94
|
+
res.on('end', () => resolvePromise({ status: res.statusCode, text: d }))
|
|
95
|
+
})
|
|
96
|
+
req.on('timeout', () => { req.destroy(new Error('usage endpoint timed out')) })
|
|
97
|
+
req.on('error', (err) => resolvePromise({ status: 0, text: '', error: err.message }))
|
|
98
|
+
req.end()
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// → { ok, limits: { five_hour, seven_day }|null, status, tier, expired, error }
|
|
103
|
+
export async function fetchGrokUsage({
|
|
104
|
+
configDir = defaultGrokHome(),
|
|
105
|
+
timeoutMs = 8000,
|
|
106
|
+
billingUrl = GROK_BILLING_URL,
|
|
107
|
+
userUrl = GROK_USER_URL,
|
|
108
|
+
} = {}) {
|
|
109
|
+
const t = readGrokToken(configDir)
|
|
110
|
+
if (!t) return { ok: false, limits: null, error: `no grok login found in ${configDir}` }
|
|
111
|
+
if (t.expired) {
|
|
112
|
+
return { ok: false, limits: null, status: 401, expired: true, error: 'grok auth token is expired' }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const headers = {
|
|
116
|
+
Authorization: `Bearer ${t.token}`,
|
|
117
|
+
Accept: 'application/json',
|
|
118
|
+
'User-Agent': 'legcli',
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const billingRes = await getJson(billingUrl, headers, timeoutMs)
|
|
122
|
+
if (billingRes.error) return { ok: false, limits: null, status: 0, error: billingRes.error }
|
|
123
|
+
if (billingRes.status === 401) {
|
|
124
|
+
return { ok: false, limits: null, status: 401, expired: true, error: 'grok authentication failed (401)' }
|
|
125
|
+
}
|
|
126
|
+
if (billingRes.status !== 200) {
|
|
127
|
+
return { ok: false, limits: null, status: billingRes.status, expired: false, error: `billing endpoint ${billingRes.status}: ${billingRes.text.slice(0, 120)}` }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let billingJson
|
|
131
|
+
try {
|
|
132
|
+
billingJson = JSON.parse(billingRes.text)
|
|
133
|
+
} catch {
|
|
134
|
+
return { ok: false, limits: null, status: billingRes.status, error: 'billing endpoint returned no JSON' }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const config = billingJson.config || billingJson
|
|
138
|
+
const pct = Number(config.creditUsagePercent ?? billingJson.creditUsagePercent)
|
|
139
|
+
if (!Number.isFinite(pct)) {
|
|
140
|
+
return { ok: false, limits: null, status: billingRes.status, error: 'no creditUsagePercent found in billing response' }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let resetsAt = null
|
|
144
|
+
const resetStr = config.currentPeriod?.end || config.billingPeriodEnd || billingJson.billingPeriodEnd || null
|
|
145
|
+
if (typeof resetStr === 'string') {
|
|
146
|
+
const tMs = Date.parse(resetStr)
|
|
147
|
+
if (Number.isFinite(tMs)) resetsAt = Math.floor(tMs / 1000)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const periodType = String(config.currentPeriod?.type || '')
|
|
151
|
+
const isShort = periodType.includes('HOUR') || periodType.includes('DAILY')
|
|
152
|
+
const windowObj = { pct, resets_at: resetsAt }
|
|
153
|
+
const limits = {
|
|
154
|
+
five_hour: isShort ? windowObj : null,
|
|
155
|
+
seven_day: !isShort ? windowObj : null,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let tier = null
|
|
159
|
+
try {
|
|
160
|
+
const userRes = await getJson(userUrl, headers, timeoutMs)
|
|
161
|
+
if (userRes.status === 200) {
|
|
162
|
+
const userJson = JSON.parse(userRes.text)
|
|
163
|
+
tier = userJson.subscriptionTier || userJson.subscription?.tier || null
|
|
164
|
+
}
|
|
165
|
+
} catch {}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
ok: true,
|
|
169
|
+
limits,
|
|
170
|
+
status: billingRes.status,
|
|
171
|
+
tier,
|
|
172
|
+
expired: false,
|
|
173
|
+
raw_keys: Object.keys(billingJson),
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Exact signals cited from xai-org/grok-build source code:
|
|
178
|
+
const LIMIT_RES = [
|
|
179
|
+
['grok-rate-limit-oauth', /You've hit the rate limit for your plan/i],
|
|
180
|
+
['grok-rate-limit-api-key', /You've hit the rate limit for your API key/i],
|
|
181
|
+
['grok-rate-limit-429', /Rate limited \(429\)/i],
|
|
182
|
+
['grok-rate-limit-code', /-32003/],
|
|
183
|
+
['grok-rate-limit-event', /"rate_limit"/i],
|
|
184
|
+
['grok-free-usage-exhausted', /subscription:free-usage-exhausted|You've used all of your free queries/i],
|
|
185
|
+
['grok-too-many-requests', /TOO_MANY_REQUESTS/],
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
// Scans log or stream text for Grok rate limit signals.
|
|
189
|
+
// → { signal, detail, resets_at } | null
|
|
190
|
+
export function scanLog(text) {
|
|
191
|
+
if (!text) return null
|
|
192
|
+
const s = String(text)
|
|
193
|
+
for (const [id, re] of LIMIT_RES) {
|
|
194
|
+
const m = re.exec(s)
|
|
195
|
+
if (!m) continue
|
|
196
|
+
const at = s.lastIndexOf(m[0])
|
|
197
|
+
const detail = s.slice(Math.max(0, at - 80), at + 160).replace(/\s+/g, ' ').trim()
|
|
198
|
+
let resets_at = null
|
|
199
|
+
const dm = /(?:try again in|resets in)\s+((?:\d+\s*[smhd])+)/i.exec(s.slice(at))
|
|
200
|
+
if (dm) {
|
|
201
|
+
let secs = 0
|
|
202
|
+
for (const [, n, u] of dm[1].matchAll(/(\d+)\s*([smhd])/gi)) {
|
|
203
|
+
secs += parseInt(n, 10) * { s: 1, m: 60, h: 3600, d: 86400 }[u.toLowerCase()]
|
|
204
|
+
}
|
|
205
|
+
if (secs) resets_at = Math.floor(Date.now() / 1000) + secs
|
|
206
|
+
}
|
|
207
|
+
return { signal: id, detail, resets_at }
|
|
208
|
+
}
|
|
209
|
+
return null
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function logSize(path) {
|
|
213
|
+
try { return statSync(path).size } catch { return 0 }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function sessionsRootFor(grokHome = defaultGrokHome()) {
|
|
217
|
+
return join(grokHome, 'sessions')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Prompts typed into grok in `cwd` since `sinceMs`.
|
|
221
|
+
// Grok stores sessions under ~/.grok/sessions/<encoded-cwd>/<session-id>/
|
|
222
|
+
// with prompt_history.jsonl in the cwd dir and summary.json in each session dir.
|
|
223
|
+
export function promptsSince({ grokHome = defaultGrokHome(), cwd, sinceMs }) {
|
|
224
|
+
const root = sessionsRootFor(grokHome)
|
|
225
|
+
if (!existsSync(root)) return []
|
|
226
|
+
const encoded = encodeURIComponent(cwd)
|
|
227
|
+
const dir = join(root, encoded)
|
|
228
|
+
if (!existsSync(dir)) return []
|
|
229
|
+
|
|
230
|
+
const histFile = join(dir, 'prompt_history.jsonl')
|
|
231
|
+
const out = []
|
|
232
|
+
if (existsSync(histFile)) {
|
|
233
|
+
try {
|
|
234
|
+
const lines = readFileSync(histFile, 'utf8').split('\n').filter(Boolean)
|
|
235
|
+
for (const line of lines) {
|
|
236
|
+
try {
|
|
237
|
+
const j = JSON.parse(line)
|
|
238
|
+
const tsMs = Date.parse(j.timestamp)
|
|
239
|
+
if (Number.isFinite(tsMs) && tsMs >= sinceMs - 2000) {
|
|
240
|
+
out.push({
|
|
241
|
+
text: String(j.prompt ?? ''),
|
|
242
|
+
ts: tsMs,
|
|
243
|
+
sessionId: j.session_id ?? null,
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
} catch {}
|
|
247
|
+
}
|
|
248
|
+
} catch {}
|
|
249
|
+
}
|
|
250
|
+
return out
|
|
251
|
+
}
|