@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/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
 
@@ -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
 
@@ -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
- return Array.isArray(value) && value.length === HANDOFF_AGENTS.length &&
13
- new Set(value).size === HANDOFF_AGENTS.length &&
14
- value.every((agent) => HANDOFF_AGENTS.includes(agent))
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 { handoff_order: normalizeHandoffOrder(value?.handoff_order) }
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 next = { ...readPreferences(), handoff_order: order }
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 { bin, viaNode, entry } = (await getAdapter(name)).resolve()
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, grok: probe('grok'), chb, git: probe('git') }
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
- land_blocker: s.worktree ? landBlocker(s) : null,
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
- try {
801
- const touched = refreshPointers()
802
- 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`)
803
- } catch (err) { log(`resume pointers not refreshed: ${err.message}`) }
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
+ }